All files / web/src/components/create/abacus print-submit-failure.ts

100% Statements 176/176
100% Branches 48/48
100% Functions 6/6
100% Lines 176/176

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 1771x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 101x 101x 101x 1x 103x 103x 103x 1x 1x 1x 1x 1x 49x 49x 49x 49x 49x 49x 1x 1x 1x 1x 1x 1x 1x 1x 49x 49x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 49x 49x 3x 2x 1x 3x 3x 3x 3x 1x 3x 2x 1x 3x 3x 3x 3x 3x 49x 49x 49x 49x 2x 2x 2x 2x 2x 2x 2x 2x 2x 49x 1x 1x 1x 1x 1x 1x 1x 1x 1x 49x 1x 1x 1x 1x 1x 1x 1x 1x 1x 49x 38x 38x 9x 9x 9x 9x 9x 9x 9x 9x 9x 29x 29x 29x 29x 38x 38x 38x 38x 38x 38x 38x 49x 49x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 28x 28x 28x 28x 8x 8x 1x 29x 29x 29x 14x 29x 8x 8x  
/**
 * Honest, remediation-carrying reading of a print-submit rejection (gh#163).
 *
 * The submit proxy relays the THH service's `{ detail: { code, message, … } }`
 * envelope byte-faithfully, so the browser already holds the service's own
 * explanation of *why* a submit failed — this is the one place that explanation
 * is turned into something the panel can show. The rule mirrors the jobs list
 * (`print-jobs.ts`): if the service says why, the panel says why. We NEVER
 * collapse a coded refusal into a bare status number, and NEVER answer a 409
 * conflict with "try again" — a conflict is a state to resolve, not a retry.
 */
import { type InvalidTicketDetail, parseInvalidTicket } from '@eink/print-dialog'
 
export interface SubmitFailure {
  /** The service's machine code, or a synthetic `http_<status>` when it sent none. */
  code: string
  /** One honest sentence naming what happened — safe as the error headline. */
  headline: string
  /** The concrete next step, or null when there is genuinely nothing to advise. */
  remediation: string | null
  /** `printer_busy`: the job already on the printer, to correlate against the roster. */
  blockingJobId: string | null
  /** `invalid_ticket`: per-key detail routed back into the settings editor. */
  invalidTicket: InvalidTicketDetail | null
  /** `acknowledgement_required`: what the printer is waiting to have confirmed. */
  missing: string[]
}
 
function asRecord(value: unknown): Record<string, unknown> | null {
  return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null
}
 
function str(value: unknown): string | null {
  return typeof value === 'string' && value.length > 0 ? value : null
}
 
/** Translate a non-OK submit Response (its status + parsed JSON body) into an
 *  honest, actionable {@link SubmitFailure}. `body` is the service envelope the
 *  proxy relayed, or null when the body was empty/unparseable. */
export function describeSubmitFailure(status: number, body: unknown): SubmitFailure {
  const detail = asRecord(asRecord(body)?.detail)
  const code = str(detail?.code) ?? `http_${status}`
  const serviceMessage = str(detail?.message)
 
  switch (code) {
    case 'invalid_ticket':
      return {
        code,
        headline: 'The print service rejected some settings — highlighted below.',
        remediation: 'Adjust the highlighted settings, then print again.',
        blockingJobId: null,
        invalidTicket: parseInvalidTicket(body),
        missing: [],
      }
 
    case 'printer_busy': {
      const activeJob = asRecord(detail?.activeJob)
      return {
        code,
        headline: 'The printer is busy with another job, and it can only run one at a time.',
        remediation:
          'Wait for the current job to finish, or cancel it from the print service — then print this abacus again.',
        blockingJobId: str(activeJob?.jobId) ?? str(activeJob?.id),
        invalidTicket: null,
        missing: [],
      }
    }
 
    case 'acknowledgement_required': {
      const missing = Array.isArray(detail?.missing)
        ? (detail.missing as unknown[]).filter((m): m is string => typeof m === 'string')
        : []
      return {
        code,
        headline:
          serviceMessage ??
          'The printer needs you to confirm something before it can start this job.',
        remediation: missing.length
          ? `Confirm on the printer: ${missing.join(', ')} — then release the job.`
          : 'Confirm the pending prompt on the printer, then release the job.',
        blockingJobId: null,
        invalidTicket: null,
        missing,
      }
    }
 
    // Two-stage feet print (Gitea #38 / things-haunt-house#456) — the codes a
    // split or chained submit can come back with.
    case 'invalid_chain':
      return {
        code,
        headline: serviceMessage ?? 'Stage B can no longer chain onto Stage A.',
        remediation:
          'Stage A has to be the last job that touched this printer’s plate — completed, and the plate untouched since. If anything printed in between or the plate moved, run Stage A again.',
        blockingJobId: null,
        invalidTicket: null,
        missing: [],
      }
    case 'invalid_filaments':
      return {
        code,
        headline: serviceMessage ?? 'The print service refused the filament plan.',
        remediation:
          'The external feed must be the same family as the AMS tray the feet print from (TPU). Check the feet slot in the filament map, then print again.',
        blockingJobId: null,
        invalidTicket: null,
        missing: [],
      }
    case 'invalid_job':
      return {
        code,
        headline: serviceMessage ?? 'The print service rejected the shape of this job.',
        remediation:
          'This is a ticket the studio built wrongly, not a settings problem — note the message above and report it.',
        blockingJobId: null,
        invalidTicket: null,
        missing: [],
      }
    default: {
      // Credentials failed — a re-pair, not a retry, is the fix.
      if (status === 401 || status === 403) {
        return {
          code,
          headline: serviceMessage ?? 'The print service rejected our credentials.',
          remediation: 'Re-pair the printer in Settings › Printing, then print again.',
          blockingJobId: null,
          invalidTicket: null,
          missing: [],
        }
      }
      return {
        code,
        // Prefer the service's own words, verbatim — only fall back to a status
        // sentence when it gave us nothing to show.
        headline: serviceMessage ?? httpFallbackHeadline(status),
        remediation: defaultRemediation(status),
        blockingJobId: null,
        invalidTicket: null,
        missing: [],
      }
    }
  }
}
 
/**
 * An Error wrapping a non-OK print-service response, carrying the parsed
 * {@link SubmitFailure} so any surface — the submit panel, a parked-job
 * resolver — renders the same honest, coded copy from one place. Throw it from
 * a mutation's `mutationFn`; read `.failure` off `mutation.error`.
 */
export class PrintServiceError extends Error {
  readonly status: number
  readonly failure: SubmitFailure
  constructor(status: number, body: unknown) {
    const failure = describeSubmitFailure(status, body)
    super(failure.headline)
    this.name = 'PrintServiceError'
    this.status = status
    this.failure = failure
  }
}
 
function httpFallbackHeadline(status: number): string {
  if (status === 0) return 'The print service is unreachable right now.'
  if (status === 409) return 'The print service says this job conflicts with its current state.'
  if (status >= 500) return `The print service hit a problem on its end (error ${status}).`
  return `The print service couldn’t accept this job (error ${status}).`
}
 
function defaultRemediation(status: number): string | null {
  if (status === 0 || status >= 500)
    return 'This is usually temporary — wait a moment, then try again.'
  // A 409 with no machine code: still a conflict, so retrying blind is wrong.
  if (status === 409) return 'Check the job list below to see what conflicts before trying again.'
  return null
}