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.
330 lines
15 KiB
TypeScript
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\)/,
|
|
);
|
|
});
|
|
});
|