WFDASH-WEBAPP/src/lib/pos-cart-quantity.test.ts
Dave 0377c51602 fix(pos): dead discount toggle + stale discount figure, lift dark palette
Two reported symptoms that turned out to be one bug, plus the dark
mode the user described as "too black".

The `hidden` attribute does nothing on the discount controls

`hidden` is implemented by the UA stylesheet as `[hidden]{display:none}`.
Any author rule that sets `display` outranks it — and `.totals` and
`.discount-body` both set `display:flex`. So `el.hidden = true` was a
no-op on both:

  - the discount tier panel could not be collapsed, making "Add
    discount" look like a dead control;
  - the Discount totals row stayed on screen after the discount was
    cleared, still showing the last figure (R 98.37) under a total
    that no longer contained it. The JS only ever set `hidden` and
    never cleared the text, so the operator had no way to tell stale
    text from a live one.

`.cat-menu` and `.cat-backdrop` already carried per-element
`[hidden]{display:none}` overrides for exactly this reason; the two
discount elements were missed. Replaced with one global rule so the
whole class is covered rather than relying on each element to
remember. The clear path now also blanks the amount.

Verified statically rather than in a browser: jsdom does not model
the UA-vs-author cascade and reported the broken and fixed cases
identically, so the cascade was checked by resolving which rules
apply. Regression test fails without the fix.

Dark palette

The POS canvas was #0f0f0f (L* 4.3 — effectively black) with
surfaces only 3-4 L* above it, mixing a warm #1c1416 against a
neutral #181818 in the same UI. That near-black base plus two
competing surface tones is what read as muddy. Replaced with a
single cool-neutral ramp with four real steps, and menus/modals now
sit on a `--raised` layer instead of reusing the surface colour.

`#717171` was the secondary text colour in 25 places and measured
3.93:1 on the old canvas — below WCAG AA. It now measures 6.49:1.
Both the text greys and the surfaces were hardcoded per component
across seven POS pages; they are now tokens, with the dark block
rebinding them.

The shared `.dark` block in themes.css (drives all 24 themes) sat at
oklch(0.15) ≈ L* 3.1. Lifted +0.04 across the ramp, preserving its
shape and separation; muted-foreground goes 6.32:1 → 5.95:1, still
above AA. Cursor/Supabase, which override it darker, were at L* 1.3
and lifted too.

Service worker cache bumped to pos-v36 — pos.html and pos-app.js are
served cache-first, so without the bump the tills keep serving the
broken files.

3863 tests pass (3 new); build clean.
2026-10-12 23:17:57 +02:00

266 lines
12 KiB
TypeScript

import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
/**
* POS cart quantity entry (`public/pos/pos-app.js` + `public/pos/pos.html`).
*
* Ringing up 800 of something means typing 800, and the only route to the
* quantity field used to be a 600ms long-press on the row. The number
* itself was a plain `<div class="v">` — exactly the thing a cashier reaches
* for — so it rendered inert. The sheet it opens is unchanged and still
* reachable both ways; these guards exist because the *discoverability* is
* what regressed, and that is invisible in a code review: nothing throws,
* the build is green, and the control is simply dead to a first-time user.
*
* Same shape of problem as `pos-inline-handlers.test.ts`: assert the
* affordance exists rather than trusting that it was left in place.
*/
const POS_APP = readFileSync(join(process.cwd(), "public", "pos", "pos-app.js"), "utf8");
const POS_HTML = readFileSync(join(process.cwd(), "public", "pos", "pos.html"), "utf8");
describe("POS cart quantity is directly settable", () => {
it("renders the quantity as a button, not an inert div", () => {
expect(POS_APP).toMatch(/<button class="v"[^>]*onclick="window\.__pos\.openEditLine\(/);
expect(POS_APP).not.toMatch(/<div class="v">\$\{qty\}<\/div>/);
});
it("gives the quantity button an accessible label", () => {
// An unlabelled button reads as just "7" to a screen reader, which is
// no better than the div it replaced.
expect(POS_APP).toMatch(/aria-label="Set quantity for /);
});
it("keeps the steppers as +/- only", () => {
expect(POS_APP).toMatch(/changeQty\('\$\{p\.id\}',-1\)/);
expect(POS_APP).toMatch(/changeQty\('\$\{p\.id\}',1\)/);
});
it("exposes openEditLine on the global the button calls", () => {
// The inline handler guard proves the name is reachable; this proves the
// specific member the button reaches for exists.
expect(POS_APP).toMatch(/window\.__pos\s*=\s*\{[^}]*openEditLine/);
});
it("still opens the sheet on long-press", () => {
expect(POS_APP).toMatch(/startLongPress\(line\)/);
expect(POS_APP).toMatch(/openEditLine\(id\)/);
});
});
describe("POS cart long-press does not hijack the quantity controls", () => {
/** The pointerdown listener body, up to its options argument. */
const pointerdownHandler = () => {
const m = POS_APP.match(
/addEventListener\('pointerdown'[\s\S]*?\{ passive: true \}\);/,
);
expect(m, "pointerdown handler not found").not.toBeNull();
return m![0];
};
it("bails out of the pointerdown handler for .qty and .rm", () => {
// Holding "+" to ring up bulk used to pop the edit sheet open at 600ms,
// mid-tap. The steppers and the new quantity button each handle their
// own tap, so they must not start the row long-press.
expect(pointerdownHandler()).toMatch(/closest\('\.qty, \.rm'\)/);
});
it("bails BEFORE starting the timer, not after", () => {
// Order matters: an early `return` leaves no pending timer to fire.
const handler = pointerdownHandler();
const guardAt = handler.indexOf("closest('.qty, .rm')");
const startAt = handler.indexOf("startLongPress(line)");
expect(guardAt, "guard not found in handler").toBeGreaterThan(-1);
expect(startAt, "startLongPress not found in handler").toBeGreaterThan(-1);
expect(guardAt, "guard must come before startLongPress").toBeLessThan(startAt);
});
it("still falls through to the row long-press for a tap on the row body", () => {
// The guard must not disable the long-press gesture entirely.
expect(pointerdownHandler()).toMatch(/startLongPress\(line\)/);
});
});
describe("POS discount trigger costs no footer height", () => {
it("sits inside the Subtotal row rather than its own row", () => {
// The standalone .discount block was ~54px of always-on footer
// (12px padding + a 42px min-height button). The trigger now rides
// inside the existing Subtotal row, so it adds nothing vertically.
expect(POS_HTML).not.toMatch(/class="discount"/);
expect(POS_HTML).toMatch(
/<div class="totals">\s*<span>Subtotal[^<]*\s*<button class="discount-trigger"/,
);
});
it("offers exactly the two fixed discount tiers, with no free-text input", () => {
// The tiers replaced the old R-off / %-off pills + number field.
// A free-typed discount is what we're deliberately removing, so its
// absence is the thing worth pinning — an input creeping back in
// would let a cashier invent an arbitrary discount again.
expect(POS_HTML).toMatch(/id="discTier20"[^>]*onclick="setDiscountTier\(20\)"/);
expect(POS_HTML).toMatch(/id="discTier40"[^>]*onclick="setDiscountTier\(40\)"/);
expect(POS_HTML).not.toMatch(/id="discInput"/);
expect(POS_HTML).not.toMatch(/setDiscountKind|applyDiscount/);
});
it("keeps every id the discount JS depends on", () => {
// paintDiscount() and toggleDiscount() look these up by id; renaming any
// of them throws on drawer open, which would break the till.
for (const id of ["discountTrigger", "discountTriggerLabel", "discountBody"]) {
expect(POS_HTML, `missing #${id}`).toMatch(new RegExp(`id="${id}"`));
}
});
it("drops the old full-width button styling", () => {
expect(POS_HTML).not.toMatch(/\.discount-trigger\{[^}]*width:100%/);
expect(POS_HTML).toMatch(/\.discount-trigger\{[^}]*border:0/);
});
it("keeps the applied-discount totals line as the always-on indicator", () => {
// The trigger is a small link now; the red totals row is what proves a
// discount is live when nobody is looking at the footer.
expect(POS_HTML).toMatch(/id="discountLine"/);
expect(POS_HTML).toMatch(/class="totals discount-line"/);
});
});
describe("POS Edit line sheet is actually visible when opened", () => {
// This is the bug that hid manual quantity entry entirely: #editSheet is
// a SIBLING of #editBackdrop, but the CSS revealed it with
// `.edit-backdrop.show .edit-sheet` — a descendant selector that cannot
// match a sibling. The sheet stayed at opacity:0 while the backdrop went
// opaque, so long-pressing a cart row showed a grey screen with nothing
// on it. Nothing threw, the build was green, and the feature looked
// simply absent.
it("reveals the sheet with its own .show rule, not a descendant of the backdrop", () => {
expect(POS_HTML).toMatch(/^\.edit-sheet\.show\{/m);
expect(POS_HTML).not.toMatch(/^\.edit-backdrop\.show \.edit-sheet/m);
});
it("stacks the sheet above the backdrop", () => {
const backdrop = POS_HTML.match(/\.edit-backdrop\{[^}]*z-index:(\d+)/)?.[1];
const sheet = POS_HTML.match(/\.edit-sheet\{[^}]*z-index:(\d+)/)?.[1];
expect(backdrop, "backdrop z-index not found").toBeDefined();
expect(sheet, "sheet z-index not found — it would render under the backdrop").toBeDefined();
expect(Number(sheet)).toBeGreaterThan(Number(backdrop));
});
it("keeps the sheet anchored to the viewport, not the document", () => {
// position:absolute resolved against an unpredictable ancestor.
expect(POS_HTML).toMatch(/\.edit-sheet\{position:fixed/);
});
it("still opens and closes both elements from JS", () => {
// openEditLine/closeEditLine add .show to the backdrop AND the sheet —
// that part was always right, which is why the bug looked like a
// missing feature rather than a broken one.
expect(POS_APP).toMatch(/editBackdrop'\)\.classList\.add\('show'\)/);
expect(POS_APP).toMatch(/editSheet'\)\.classList\.add\('show'\)/);
expect(POS_APP).toMatch(/editBackdrop'\)\.classList\.remove\('show'\)/);
expect(POS_APP).toMatch(/editSheet'\)\.classList\.remove\('show'\)/);
});
it("keeps them siblings so a tap on the sheet does not close it", () => {
// #editBackdrop carries onclick="closeEditLine()". Nesting the sheet
// inside it — the "obvious" fix — would make every tap on Save bubble
// to the backdrop and dismiss the dialog first. The backdrop must
// therefore stay an empty, immediately-closed element.
const backdropTag = POS_HTML.match(/<div class="edit-backdrop"[^>]*>/)?.[0];
expect(backdropTag, "edit-backdrop not found").toBeDefined();
expect(backdropTag).toMatch(/id="editBackdrop"/);
const after = POS_HTML.slice(
POS_HTML.indexOf(backdropTag!) + backdropTag!.length,
);
expect(after.startsWith("</div>"), "backdrop must close immediately").toBe(true);
});
});
describe("POS Edit line popup is quantity-only", () => {
// Requested: no price editing from the till. The override it used to set
// was also never persisted (persistCart saves only {id, qty, p}), so it
// was a session-only thing that produced a sale record the server
// ignored. Removing it is a deletion, not a feature loss.
it("has no price field in the sheet", () => {
expect(POS_HTML).not.toMatch(/id="editPrice"/);
expect(POS_HTML).not.toMatch(/Unit price/);
expect(POS_APP).not.toMatch(/getElementById\('editPrice'\)/);
});
it("keeps the quantity field", () => {
expect(POS_HTML).toMatch(/id="editQty"/);
expect(POS_APP).toMatch(/getElementById\('editQty'\)\.value = String\(line\.qty\)/);
});
it("removes the override mechanism entirely", () => {
// Nothing else could set it — not a cart reload, not addToCart — so
// leaving the field in place would be dead code implying the till can
// still change a price.
expect(POS_APP).not.toMatch(/unitPriceOverride/);
expect(POS_HTML).not.toMatch(/price edited/);
});
it("still sends a price, and it is the shelf price", () => {
// /checkout requires unit_price; dropping the override must not drop
// the price itself.
expect(POS_APP).toMatch(/unit_price: lineUnitPrice\(\{p,qty\}\)/);
expect(POS_APP).toMatch(/selling_price: lineUnitPrice\(\{p,qty\}\)/);
expect(POS_APP).toMatch(
/function lineUnitPrice\(line\)\{\s*return Number\(line\.p\.selling_price/,
);
});
it("no longer sends the unused `edited` flag", () => {
// pos-service.ts never read it — only `unit_price` matters.
expect(POS_APP).not.toMatch(/\bedited:/);
});
});
describe("POS service worker cache tracks shell edits", () => {
it("is past v30, since pos-app.js and pos.html are served cache-first", () => {
const v = POS_HTML; // placeholder to keep the import honest
expect(v).toBeDefined();
const sw = readFileSync(join(process.cwd(), "public", "pos", "sw.js"), "utf8");
const m = sw.match(/const CACHE = "pos-v(\d+)"/);
expect(m, "sw.js CACHE constant not found").not.toBeNull();
expect(Number(m![1])).toBeGreaterThanOrEqual(33);
});
});
describe("POS [hidden] attribute actually hides", () => {
/**
* A `hidden` attribute is implemented by the UA stylesheet as
* `[hidden]{display:none}` — but ANY author rule that sets `display`
* beats it, because author styles outrank UA styles regardless of
* specificity. `.totals` and `.discount-body` both set
* `display:flex`, so `el.hidden = true` did nothing: the discount
* panel could not be collapsed, and the "Discount" row could not be
* dismissed — it stayed on screen showing the last figure even
* after the discount was cleared, because the JS only ever set
* `hidden` and never cleared the text.
*
* Both symptoms looked like two unrelated bugs. One line of CSS
* fixes both, and this guards the whole class of them.
*/
it("has a global [hidden]{display:none} override", () => {
expect(POS_HTML).toMatch(/\[hidden\]\s*\{[^}]*display:\s*none\s*!important/);
});
it("covers every element that carries a hidden attribute", () => {
// Any element with `hidden` whose class rule sets `display` would
// otherwise be permanently visible. Assert the override exists
// rather than trusting each element to have remembered one.
const risky = [
...POS_HTML.matchAll(/<div[^>]*\shidden[^>]*>/g),
].filter((tag) => /\bclass="/.test(tag[0]));
expect(risky.length, "expected some hidden elements to audit").toBeGreaterThan(0);
expect(POS_HTML).toMatch(/\[hidden\]\s*\{[^}]*display:\s*none\s*!important/);
});
it("blanks the discount figure when the row is cleared", () => {
// Hiding alone left the old amount painted on screen, which the
// operator had no way to distinguish from a live one. Matched
// loosely because a comment sits between the two statements.
expect(POS_APP).toMatch(
/dl\.hidden\s*=\s*true;[\s\S]{0,400}discountLineAmount'\)\.textContent\s*=\s*''/,
);
});
});