WFDASH-WEBAPP/src/lib/pos-cart-quantity.test.ts
Dave 36704bd48b fix(pos): put the stock quantity under the price, not in the sub line
The quantity went into the .sub line, which is nowrap + ellipsis: a
28- or 38-character SKU truncated it, so the number fell off the right
edge and pushed the in-cart count out with it. The sub line is back to
what it was — sku, brand, unit, N in cart — and the quantity now sits
under the price, right-aligned, where nothing truncates it.

The POS tests asserted the file's prose rather than its expressions, so
five mutations of the implementation all passed them: a min_qty cue, a
lost zero boundary, a clamp, a round and the segment deleted. They now
pin the expressions, the interpolation, the class wiring and the dark
rule's colour value. All seven mutations tried are killed.

A non-numeric current_qty rendered "NaN in stock" and was not badged —
NaN <= 0 is false. It now falls back to 0 and badges.

Bumped the service-worker cache: pos-app.js and pos.html are both
served cache-first out of the shell, so without a new CACHE name the
tills keep the old ones.
2026-11-06 04:53:37 +02:00

330 lines
15 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*''/,
);
});
});
describe("the POS tile shows the stock quantity", () => {
// The first version of these assertions matched the file's PROSE: the
// comment above the tile code contained "current_qty" and "-2581 in
// stock", and the local variable was called `oos`. Five mutations of
// the implementation — a min-based cue, a lost zero boundary, a clamp,
// a round, and the whole segment deleted — all sailed past them. So
// the assertions below pin the EXPRESSIONS, not the words.
it("renders the quantity on the tile", () => {
expect(POS_APP).toMatch(/const rawQty = Number\(p\.current_qty\);/);
expect(POS_APP).toMatch(
/const stockQty = Number\.isFinite\(rawQty\) \? rawQty : 0;/,
);
expect(POS_APP).toMatch(/\$\{stockQty\} in stock/);
});
it("marks the tile out of stock when the quantity is not positive", () => {
// current_qty <= 0 is the cue. min_qty is set on 48 of 4,083
// sellable items, so a min-based rule would badge every tile and
// mean nothing — the till must never read it.
expect(POS_APP).not.toMatch(/min_qty/);
expect(POS_APP).toMatch(/const oos = stockQty <= 0;/);
expect(POS_APP).toMatch(/class="prod\$\{oos \? ' oos' : ''\}"/);
});
it("shows the number as it is, negatives included", () => {
// No clamping, no rounding: a negative quantity must still read
// "-2581 in stock", because the real number is the signal.
expect(POS_APP).not.toMatch(
/Math\.(max|round|trunc|floor|ceil)\([^)]*stockQty/,
);
expect(POS_APP).not.toMatch(/toFixed\([^)]*stockQty/);
});
it("keeps the quantity out of the truncating sub line", () => {
// .sub is nowrap + ellipsis, so a long SKU truncated it: the number
// fell off the right edge and pushed the in-cart count out with it.
const sub = POS_APP.match(/<div class="sub">([\s\S]*?)<\/div>/)?.[1] ?? "";
expect(sub).toContain("${inCart?' · '+inCart+' in cart':''}");
expect(sub).not.toContain("stockQty");
expect(sub).not.toContain("in stock");
});
it("puts the quantity under the price instead", () => {
const price =
POS_APP.match(/<div class="price">([\s\S]*?)<\/div>/)?.[1] ?? "";
expect(price).toContain("${stockQty} in stock");
expect(POS_HTML).toMatch(/\.prod \.stock\{[^}]*display:block/);
expect(POS_HTML).toMatch(/\.prod \.price\{[^}]*text-align:right/);
});
it("styles the out-of-stock quantity in both themes", () => {
expect(POS_HTML).toMatch(/\.prod\.oos \.stock\{[^}]*color:var\(--danger\)/);
// The dark theme restates `.prod .stock` at a higher specificity,
// so the dark rule must outrank it — and it must not be the sub-AA
// var(--danger): #ff6680 clears AA on the dark canvas.
expect(POS_HTML).toMatch(
/:root\[data-theme="dark"\] \.prod\.oos \.stock\{[^}]*color:#ff6680\}/,
);
expect(POS_HTML).not.toMatch(
/:root\[data-theme="dark"\] \.prod\.oos \.stock\{[^}]*var\(--danger\)/,
);
});
});