WFDASH-WEBAPP/src/lib/pos-cart-quantity.test.ts

227 lines
10 KiB
TypeScript
Raw Normal View History

fix(pos): make cart quantity editable, and fix the sheet that hid it Ringing up 800 of something means typing 800, and the only route to the quantity field was a 600ms long-press with no affordance anywhere on screen. The quantity itself — the thing a cashier reaches for — was a plain <div class="v">, so it rendered inert. - the quantity is now a real button that opens the Edit line sheet, with an aria-label and a 44px touch target (it was 30px, too narrow to even show "800") - holding +/- no longer pops the sheet open mid-tap: the pointerdown handler bails for .qty/.rm before starting the timer - the sheet is quantity-only. The Unit price field is gone on request; a mis-keyed price is expensive and easy to miss. The per-line override it fed is removed with it — pos-service.ts only ever read unit_price, the "price edited" label and the sale record's `edited` flag were unreachable, and nothing else could set an override, including a cart reload (persistCart saves only {id, qty, p}) The discount trigger moved out of its own 54px footer row to ride inside the Subtotal row, so it costs no vertical height at all while staying visible — a discount control that appears from nowhere is one nobody finds under pressure. The applied-discount red totals row remains the always-on indicator. Fixes a pre-existing bug found while testing: long-pressing a cart row showed a grey screen with no dialog. #editSheet is a SIBLING of #editBackdrop, but the CSS revealed it with `.edit-backdrop.show .edit-sheet` — a descendant selector that can never match a sibling — so the sheet sat at opacity:0 while the backdrop went opaque. Nothing threw and the build was green, which is why it survived; it is also why manual quantity entry looked missing. The sheet now has its own z-index above the backdrop and its own .show rule. They stay siblings on purpose: nesting the sheet inside the backdrop would make every tap on Save bubble to the backdrop's onclick and close the dialog. Verified in jsdom against the real markup: old CSS reproduces the grey screen (backdrop opacity 1, sheet opacity 0, not clickable), the fix clears it (sheet opacity 1, z-index 71 > 70, position fixed). 125 POS tests pass, including 23 new ones asserting the tap target exists, the long-press guard fires before the timer, the sheet stacks above its backdrop, and the popup stays quantity-only. sw.js v30 -> v33: pos-app.js and pos.html are both served cache-first out of the shell, so without the bump tills keep the old ones.
2026-10-07 10:54:47 +00:00
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"/);
feat(pos): ex-VAT totals, ad-hoc custom lines, fixed discount tiers Three changes to the POS sale pipeline, all in one commit because pos-service.ts / types.ts / pos-app.js are each touched by all three and splitting them would only produce intermediate broken states. VAT is now charged on top, not backed out Catalogue prices are ex-VAT: `selling_price` is derived as `cost_price x markup` from the supplier's ex-VAT cost, with no VAT uplift anywhere (verified — median selling/cost across the catalogue is exactly 1.8). The till nevertheless treated them as VAT-inclusive and recovered the net with `total / 1.15`, so every sale was recorded with an understated net and an understated VAT line. An R100 ex-VAT line now rings up as R115. The rate comes from the till's letterhead, read server-side rather than accepted off the wire, and is stored per sale so a later letterhead rate change cannot rewrite what was charged. All 3,623 catalogue prices are untouched. Ad-hoc "custom" lines A cashier can now ring up something that was never a catalogue item — "bolt cutting" at R20 agreed at the counter — via a new "+ Add a custom item" composer. The line prints on the slip and lands in pos_sale_items, but carries no stock_item relation and takes no stock movement: no ledger row, no current_qty decrement, no catalogue entry. pos_sale_items.stock_item is no longer required at the schema level and an explicit `is_custom` flag marks the line, rather than inferring it from a blank relation — so "how much did we make on bolt cutting this month" is a plain query, and a future stock-less line type cannot be confused with a corrupt one. Custom lines are refused on account sales: those raise a delivery note the customer signs for and feed their statement, so an ad-hoc charge must not slip onto one. Enforced server-side, not just in the UI. Discounts are now 20% or 40%, nothing else The R-off / %-off pills and the free-typed number box are gone. The server treats DISCOUNT_PERCENT_OPTIONS as an allowlist and refuses anything outside it rather than coercing it, so a stale till cannot invent a discount. The discount lands on the ex-VAT subtotal, making "20% off" a true 20% off both the net and the VAT on it. Two defects found while doing this pos_sales was missing six fields that checkoutSale has been writing all along (gross_subtotal, gross_vat, gross_total, discount_amount, discount_kind, discount_input). PocketBase accepts unknown fields without error and silently drops them, so all 192 historical sales lack them and a discounted sale reprinted as though no discount had been applied. Added, plus vat_percent. History also passed gross_total (VAT-inclusive) where the receipt derives the gross VAT from an ex-VAT base — a reprint double-counted VAT. Corrected. Verification: 3860 tests pass (14 new); typecheck, lint and production build clean; live PocketBase probe confirms a pos_sale_items row with stock_item="" and is_custom=true round-trips, with the probe removed afterwards.
2026-10-12 20:54:09 +00:00
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/);
fix(pos): make cart quantity editable, and fix the sheet that hid it Ringing up 800 of something means typing 800, and the only route to the quantity field was a 600ms long-press with no affordance anywhere on screen. The quantity itself — the thing a cashier reaches for — was a plain <div class="v">, so it rendered inert. - the quantity is now a real button that opens the Edit line sheet, with an aria-label and a 44px touch target (it was 30px, too narrow to even show "800") - holding +/- no longer pops the sheet open mid-tap: the pointerdown handler bails for .qty/.rm before starting the timer - the sheet is quantity-only. The Unit price field is gone on request; a mis-keyed price is expensive and easy to miss. The per-line override it fed is removed with it — pos-service.ts only ever read unit_price, the "price edited" label and the sale record's `edited` flag were unreachable, and nothing else could set an override, including a cart reload (persistCart saves only {id, qty, p}) The discount trigger moved out of its own 54px footer row to ride inside the Subtotal row, so it costs no vertical height at all while staying visible — a discount control that appears from nowhere is one nobody finds under pressure. The applied-discount red totals row remains the always-on indicator. Fixes a pre-existing bug found while testing: long-pressing a cart row showed a grey screen with no dialog. #editSheet is a SIBLING of #editBackdrop, but the CSS revealed it with `.edit-backdrop.show .edit-sheet` — a descendant selector that can never match a sibling — so the sheet sat at opacity:0 while the backdrop went opaque. Nothing threw and the build was green, which is why it survived; it is also why manual quantity entry looked missing. The sheet now has its own z-index above the backdrop and its own .show rule. They stay siblings on purpose: nesting the sheet inside the backdrop would make every tap on Save bubble to the backdrop's onclick and close the dialog. Verified in jsdom against the real markup: old CSS reproduces the grey screen (backdrop opacity 1, sheet opacity 0, not clickable), the fix clears it (sheet opacity 1, z-index 71 > 70, position fixed). 125 POS tests pass, including 23 new ones asserting the tap target exists, the long-press guard fires before the timer, the sheet stacks above its backdrop, and the popup stays quantity-only. sw.js v30 -> v33: pos-app.js and pos.html are both served cache-first out of the shell, so without the bump tills keep the old ones.
2026-10-07 10:54:47 +00:00
});
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);
});
});