import { describe, it, expect } from "vitest"; import { deriveDnStage, dnStageTint, dnTabForStatus, hasSignedCopy, isDoneStatus, matchesDeliveryNoteSearch, matchesDoneFilter, } from "./delivery-note-stage"; import type { DeliveryNote, DeliveryNoteStatus } from "./types"; /** * These tests exist because the two easy ways to get the colour ladder * wrong are both silent: a `signed_copy[0]` check that returns the * first CHARACTER of a string-shaped PB field, and a precedence * order that lets "has a quote" paint a row green. Neither throws. * They just paint the screen wrong. */ const note = ( status: DeliveryNoteStatus, signed_copy?: string | string[], ): Pick => ({ status, signed_copy }); describe("hasSignedCopy", () => { it("handles PB's single-file STRING shape", () => { // The regression this guards: `signed_copy[0]` on a string is the // first character, so the old array-style check reported "no // signed copy" for every note that actually had one. expect(hasSignedCopy({ signed_copy: "Acme-DN26071401.pdf" })).toBe(true); }); it("handles the multi-file ARRAY shape", () => { expect(hasSignedCopy({ signed_copy: ["a.pdf", "b.pdf"] })).toBe(true); }); it("handles the EMPTY ARRAY shape", () => { expect(hasSignedCopy({ signed_copy: [] })).toBe(false); }); it("treats an empty string and undefined as no signed copy", () => { // The two shapes PB actually returns when nothing is attached. expect(hasSignedCopy({ signed_copy: "" })).toBe(false); expect(hasSignedCopy({})).toBe(false); }); it("does not trim — a whitespace string counts as attached", () => { // Documenting the boundary rather than asserting a stricter // contract than the domain has. `hasFile` answers "is anything // attached?" via string length, so `" "` is truthy. That is // unreachable in practice: PB never writes whitespace to a file // field — `maxSelect: 1` returns `""` or a real filename. We // delegate to the shared helper precisely so there is ONE // implementation of this rule, and we don't want a silent // divergence from it appearing here. expect(hasSignedCopy({ signed_copy: " " })).toBe(true); }); }); describe("deriveDnStage", () => { it("gives po_received the green even when a quote and signed copy exist", () => { // po_received is the only state with an action the operator has to // take. It must win over the earlier stages or the actionable row // loses its colour to a less useful one. expect( deriveDnStage(note("po_received", "signed.pdf"), true), ).toBe("ready_to_invoice"); expect(deriveDnStage(note("po_received"), true)).toBe("ready_to_invoice"); }); it("tints blue for a quote with no signed copy", () => { expect(deriveDnStage(note("sent", "signed.pdf"), true)).toBe("quoted"); expect(deriveDnStage(note("sent"), true)).toBe("quoted"); }); it("tints yellow for a signed copy with no quote", () => { expect(deriveDnStage(note("draft", "signed.pdf"), false)).toBe("signed"); }); it("leaves a bare draft untinted", () => { expect(deriveDnStage(note("draft"), false)).toBe("draft"); }); it("files invoiced and completed as done", () => { expect(deriveDnStage(note("invoiced", "s.pdf"), true)).toBe("invoiced"); expect(deriveDnStage(note("completed"), false)).toBe("invoiced"); }); it("files cancelled as cancelled regardless of attachments", () => { expect(deriveDnStage(note("cancelled", "s.pdf"), true)).toBe("cancelled"); }); }); describe("dnStageTint", () => { it("tints the three in-progress stages and nothing else", () => { expect(dnStageTint("draft")).toBeNull(); expect(dnStageTint("invoiced")).toBeNull(); expect(dnStageTint("cancelled")).toBeNull(); expect(dnStageTint("signed")).not.toBeNull(); expect(dnStageTint("quoted")).not.toBeNull(); expect(dnStageTint("ready_to_invoice")).not.toBeNull(); }); it("gives every tinted stage its own colour", () => { const stripes = (["signed", "quoted", "ready_to_invoice"] as const).map( (s) => dnStageTint(s)!.stripe, ); expect(new Set(stripes).size).toBe(3); }); it("explains itself — every tint carries a human-readable reason", () => { // A colour with no stated reason is the "mystery toggle" problem // this whole change set exists to fix. for (const stage of ["signed", "quoted", "ready_to_invoice"] as const) { expect(dnStageTint(stage)!.label.length).toBeGreaterThan(5); } }); }); describe("dnTabForStatus", () => { it("keeps only genuinely-outstanding work in the active bucket", () => { // invoiced used to sit in Active, which meant the active list // never drained and finished work stayed in the operator's face. expect(dnTabForStatus("draft")).toBe("active"); expect(dnTabForStatus("sent")).toBe("active"); expect(dnTabForStatus("loaded")).toBe("active"); expect(dnTabForStatus("po_received")).toBe("active"); }); it("folds invoiced, completed AND cancelled into one done bucket", () => { // Cancelled used to have its own tab. The operator asked for it // to live under the done heading with a filter between them. expect(dnTabForStatus("invoiced")).toBe("done"); expect(dnTabForStatus("completed")).toBe("done"); expect(dnTabForStatus("cancelled")).toBe("done"); }); }); describe("matchesDoneFilter", () => { it("'both' shows every done note", () => { expect(matchesDoneFilter("invoiced", "both")).toBe(true); expect(matchesDoneFilter("completed", "both")).toBe(true); expect(matchesDoneFilter("cancelled", "both")).toBe(true); }); it("'completed' hides cancelled but keeps both finished states", () => { expect(matchesDoneFilter("invoiced", "completed")).toBe(true); expect(matchesDoneFilter("completed", "completed")).toBe(true); expect(matchesDoneFilter("cancelled", "completed")).toBe(false); }); it("'cancelled' shows only cancelled", () => { expect(matchesDoneFilter("cancelled", "cancelled")).toBe(true); expect(matchesDoneFilter("completed", "cancelled")).toBe(false); expect(matchesDoneFilter("invoiced", "cancelled")).toBe(false); }); it("never lets an in-flight status through, whatever the filter", () => { // Guard: the filter lives INSIDE the done tab, so this should be // impossible in the UI. If it ever is, we'd show work-in-progress // in a tab labelled Completed. for (const f of ["both", "completed", "cancelled"] as const) { expect(matchesDoneFilter("draft", f)).toBe(false); expect(matchesDoneFilter("po_received", f)).toBe(false); } }); }); describe("isDoneStatus", () => { it("treats cancelled as done — the work finished, one way or another", () => { expect(isDoneStatus("cancelled")).toBe(true); expect(isDoneStatus("completed")).toBe(true); expect(isDoneStatus("invoiced")).toBe(true); expect(isDoneStatus("draft")).toBe(false); expect(isDoneStatus("sent")).toBe(false); expect(isDoneStatus("loaded")).toBe(false); expect(isDoneStatus("po_received")).toBe(false); }); }); const row = (over: Record = {}) => ({ delivery_note_number: "DN26071401", notes: "counter pickup", customer_po: "PO-9931", ...over, }) as Parameters[0]; describe("matchesDeliveryNoteSearch", () => { it("matches the DN number", () => { expect(matchesDeliveryNoteSearch(row(), "26071401")).toBe(true); expect(matchesDeliveryNoteSearch(row(), "26071401")).toBe(true); }); it("matches the CUSTOMER name — the case the old server filter missed", () => { // This is the regression that motivated moving search // client-side: the old filter only read delivery_note_number and // notes, so "what's outstanding for Acme" returned nothing on a // list that is grouped by company. const n = row({ expand: { order: { order_number: "ORD-2607-118" }, customer: { customer: "Andrew Bester" }, company: { company: "Acme Bolts" }, }, }); expect(matchesDeliveryNoteSearch(n, "acme")).toBe(true); expect(matchesDeliveryNoteSearch(n, "bester")).toBe(true); }); it("matches the company and the order number", () => { const n = row({ expand: { order: { order_number: "ORD-2607-118" }, customer: { customer: "Andrew Bester" }, company: { company: "Acme Bolts" }, }, }); expect(matchesDeliveryNoteSearch(n, "ORD-2607-118")).toBe(true); expect(matchesDeliveryNoteSearch(n, "acme bolts")).toBe(true); }); it("matches the customer's own PO reference", () => { expect(matchesDeliveryNoteSearch(row(), "PO-9931")).toBe(true); }); it("is case-insensitive", () => { expect(matchesDeliveryNoteSearch(row(), "COUNTER")).toBe(true); }); it("requires EVERY word, so 'acme bolt' narrows rather than 404s", () => { const n = row({ expand: { company: { company: "Acme Bolts" } }, }); expect(matchesDeliveryNoteSearch(n, "acme bolt")).toBe(true); expect(matchesDeliveryNoteSearch(n, "acme missing")).toBe(false); }); it("returns everything for an empty or whitespace query", () => { expect(matchesDeliveryNoteSearch(row(), "")).toBe(true); expect(matchesDeliveryNoteSearch(row(), " ")).toBe(true); }); it("does not throw on a note with no expansions at all", () => { // Ad-hoc counter notes can carry a customer but no order, and a // note mid-creation can have neither. A search box that throws on // those rows takes the whole list down. const bare = row(); expect(() => matchesDeliveryNoteSearch(bare, "anything")).not.toThrow(); expect(matchesDeliveryNoteSearch(bare, "anything")).toBe(false); }); it("treats a quote in the query as ordinary text, not a filter break", () => { // The old server-side filter interpolated the term into a PB // filter string with no escaping, so a double quote produced a // malformed filter and a list that failed to load. const n = row({ expand: { company: { company: 'Acme "Ltd" Bolts' } }, }); expect(matchesDeliveryNoteSearch(n, 'acme "ltd"')).toBe(true); }); });