fix(pos): revive the dead "Print cash-up" button, add 20c/10c denominations
The button has never worked. cashup.html wires it with an inline
onclick="printCashup()", but the page's whole script is IIFE-wrapped, so
printCashup was never a global — the click threw "ReferenceError:
printCashup is not defined" into the console and the button silently did
nothing. No alert, no dialog, no feedback.
Dead since 46d5d54 swapped the button from onclick="window.print()" (a
real global, so it worked) to the ePOS-Print path. receipt.html hit this
exact bug earlier and fixed it by exporting its handlers to window — with
a comment noting the buttons "all silently do nothing". cashup.html never
got the same line. It does now.
On the network transport the cash-up slip already mirrored the sale slip
(resolve 'cashup' blocks -> letterheadLogoRaster at 384 -> printViaNetwork),
so the export is the whole fix for a till on a network printer.
Also here:
- 20c and 10c added to the drawer count, with the total rounded to whole
cents. Neither is exact in binary floating point, so 3x10c would
otherwise have summed to 0.30000000000000004 and printed a variance
that reads as a rounding bug on the slip.
- The button restored its label with textContent, which dropped its
printer <svg> permanently on first use. Restores innerHTML instead.
- The Bluetooth branch quietly called window.print() (OS dialog, not the
thermal printer). getSettings() DEFAULTS to bluetooth, so any till with
cleared settings would land there and look dead all over again. It now
fails loudly, matching EposPrint.printTest()'s stance. No ESC/POS
cash-up renderer yet — only the sale slip speaks Bluetooth.
pos-app.js had the same defect in four more places: the edit-line modal's
Save / Cancel / Remove and the customer "Remove" chip are all inline
onclick handlers declared inside its IIFE, and only openEditLine was
exported — so the modal could be opened but not dismissed or committed.
Confirmed by parsing the file (one top-level statement, no top-level
function declarations), fixed with four additive export lines.
src/lib/pos-inline-handlers.test.ts guards the class across every POS
page: any identifier called from an inline on* attribute must be a
builtin, a namespaced call, or exported on window. It was red on exactly
these bugs and is green now.
SW cache bumped to pos-v26 so the tills pick the files up.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
74cde03142
commit
bcd9b88dd4
4 changed files with 155 additions and 15 deletions
|
|
@ -265,7 +265,8 @@ paintNet();
|
|||
|
||||
const DENOMS = [
|
||||
{lbl:'R200', sub:'note', v:200}, {lbl:'R100', sub:'note', v:100}, {lbl:'R50', sub:'note', v:50}, {lbl:'R20', sub:'note', v:20},
|
||||
{lbl:'R10', sub:'coin', v:10}, {lbl:'R5', sub:'coin', v:5}, {lbl:'R2', sub:'coin', v:2}, {lbl:'R1', sub:'coin', v:1}, {lbl:'50c', sub:'coin', v:0.5}
|
||||
{lbl:'R10', sub:'coin', v:10}, {lbl:'R5', sub:'coin', v:5}, {lbl:'R2', sub:'coin', v:2}, {lbl:'R1', sub:'coin', v:1},
|
||||
{lbl:'50c', sub:'coin', v:0.5}, {lbl:'20c', sub:'coin', v:0.2}, {lbl:'10c', sub:'coin', v:0.1}
|
||||
];
|
||||
|
||||
let shift = 'today';
|
||||
|
|
@ -300,7 +301,10 @@ function getDenomCounts(){
|
|||
}
|
||||
function recalc(){
|
||||
const c = getDenomCounts();
|
||||
cashCounted = Object.entries(c).reduce((s,[v,n])=>s + v*n, 0);
|
||||
// Round to whole cents: 10c/20c aren't exact in binary floating point,
|
||||
// so a drawer of 3x10c would otherwise total 0.30000000000000004 and
|
||||
// print a variance that looks like a rounding bug on the slip.
|
||||
cashCounted = Math.round(Object.entries(c).reduce((s,[v,n])=>s + v*n, 0) * 100) / 100;
|
||||
document.getElementById('sCashCounted').textContent = fmt(cashCounted);
|
||||
const diff = cashCounted - cashSales;
|
||||
const drow = document.getElementById('diffRow');
|
||||
|
|
@ -390,8 +394,9 @@ load();
|
|||
// which went to the OS print dialog instead of the thermal printer.
|
||||
async function printCashup(){
|
||||
const btn = document.querySelector('[data-od-id="print-cashup"]');
|
||||
const status = btn ? btn.querySelector('span') : null;
|
||||
const oldLabel = btn ? btn.textContent : '';
|
||||
// Keep the markup, not just the text — the button holds a printer <svg>,
|
||||
// and setting textContent to restore it would drop the icon for good.
|
||||
const oldLabel = btn ? btn.innerHTML : '';
|
||||
if(btn){ btn.disabled = true; btn.textContent = 'Printing…'; }
|
||||
try{
|
||||
// Letterhead must be loaded — same as the sale slip.
|
||||
|
|
@ -454,22 +459,30 @@ async function printCashup(){
|
|||
}
|
||||
await window.EposPrint.printViaNetwork(settings.ip, xml);
|
||||
} else {
|
||||
// Bluetooth — re-use the receipt page's printThermalBluetooth
|
||||
// would couple cashup to receipt.html, so for the cashup slip
|
||||
// we keep things simple: fall back to the OS print dialog
|
||||
// (which is what the till was doing before this whole change).
|
||||
// Operators using Bluetooth need a separate "print from the
|
||||
// browser" workflow for the cashup slip; the sale slip on the
|
||||
// till still uses Bluetooth via the receipt page.
|
||||
window.print();
|
||||
return;
|
||||
// Bluetooth. The ESC/POS builders live in receipt.html, so there's
|
||||
// no cash-up renderer for this transport yet — only the sale slip
|
||||
// speaks Bluetooth. Say so plainly rather than quietly opening the
|
||||
// OS print dialog: getSettings() DEFAULTS to bluetooth, so a till
|
||||
// with cleared settings would otherwise land here and look like the
|
||||
// button does nothing at all. Same stance as EposPrint.printTest().
|
||||
throw new Error(
|
||||
'The cash-up slip prints over the network printer only. ' +
|
||||
'Set the printer to Network (and its IP) in Printer settings.'
|
||||
);
|
||||
}
|
||||
}catch(e){
|
||||
alert('Cash-up print failed: ' + (e.message || e));
|
||||
}finally{
|
||||
if(btn){ btn.disabled = false; btn.textContent = oldLabel || 'Print cash-up'; }
|
||||
if(btn){ btn.disabled = false; btn.innerHTML = oldLabel; }
|
||||
}
|
||||
}
|
||||
|
||||
// This whole script is IIFE-wrapped, so `printCashup` is NOT a global —
|
||||
// and the button wires it up with an inline onclick="printCashup()",
|
||||
// which is evaluated in global scope. Without this line the click throws
|
||||
// ReferenceError into the console and the button silently does nothing.
|
||||
// Same fix, same reason, as receipt.html's print buttons.
|
||||
window.printCashup = printCashup;
|
||||
})();
|
||||
</script>
|
||||
<script>if("serviceWorker" in navigator){navigator.serviceWorker.register("sw.js").catch(function(){});}</script>
|
||||
|
|
|
|||
|
|
@ -1006,6 +1006,13 @@ window.confirmSale = confirmSale;
|
|||
window.changeQty = changeQty;
|
||||
window.removeFromCart = removeFromCart;
|
||||
window.openEditLine = openEditLine;
|
||||
// The edit-line modal could be opened but not dismissed or committed:
|
||||
// its Save / Cancel / Remove buttons (and the customer "Remove" chip)
|
||||
// call these from inline onclick, so they need to be global too.
|
||||
window.closeEditLine = closeEditLine;
|
||||
window.editSave = editSave;
|
||||
window.editRemove = editRemove;
|
||||
window.clearCust = clearCust;
|
||||
window.toggleDiscount = toggleDiscount;
|
||||
window.setDiscountKind = setDiscountKind;
|
||||
window.applyDiscount = applyDiscount;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
// Service worker — cache app shell, network-first for HTML + theme.js, cache-first for other static.
|
||||
const CACHE = "pos-v25";
|
||||
const CACHE = "pos-v26";
|
||||
const APP_SHELL = [
|
||||
'./',
|
||||
'./index.html',
|
||||
|
|
|
|||
120
src/lib/pos-inline-handlers.test.ts
Normal file
120
src/lib/pos-inline-handlers.test.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
/**
|
||||
* The POS till pages under `public/pos` are plain HTML wired with inline
|
||||
* `onclick="doThing()"` attributes. An inline handler is evaluated in the
|
||||
* GLOBAL scope, so `doThing` must be reachable off `window`.
|
||||
*
|
||||
* Most of these pages wrap their script in an IIFE. A function declared
|
||||
* inside that IIFE is NOT global, so the handler throws
|
||||
* `ReferenceError: doThing is not defined` — which surfaces only as an
|
||||
* uncaught console error. The button looks completely dead: no dialog,
|
||||
* no feedback, nothing.
|
||||
*
|
||||
* This has now bitten the till twice (receipt.html's print buttons, then
|
||||
* cashup.html's "Print cash-up"), so it gets a guard. The rule:
|
||||
*
|
||||
* every identifier called from an inline on* attribute must be either a
|
||||
* browser builtin, a namespaced call (`window.Foo.bar()`), or exported
|
||||
* with `window.name = name`.
|
||||
*/
|
||||
|
||||
const POS_DIR = join(process.cwd(), "public", "pos");
|
||||
|
||||
/** Browser globals that inline handlers legitimately call. */
|
||||
const BUILTINS = new Set([
|
||||
"alert",
|
||||
"confirm",
|
||||
"print",
|
||||
"open",
|
||||
"Number",
|
||||
"String",
|
||||
"parseInt",
|
||||
"parseFloat",
|
||||
"encodeURIComponent",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Drop the run of leading comments/whitespace so we can look at the first
|
||||
* real statement. Deliberately anchored and non-global: a line-comment
|
||||
* pattern led by `\s` and flagged `m` matches across newlines, which
|
||||
* silently eats real code rather than just the comments.
|
||||
*/
|
||||
const firstStatement = (src: string) =>
|
||||
src.replace(/^(?:\s|\/\/[^\n]*|\/\*[\s\S]*?\*\/)*/, "");
|
||||
|
||||
/** A script whose whole body is `(function(){ ... })()` leaks nothing. */
|
||||
const isIifeWrapped = (src: string) =>
|
||||
/^\(\s*(?:function\s*\([^)]*\)|\([^)]*\)\s*=>)\s*\{/.test(
|
||||
firstStatement(src),
|
||||
);
|
||||
|
||||
/** Names this source makes reachable from global scope. */
|
||||
function globalsFrom(src: string): Set<string> {
|
||||
const names = new Set<string>();
|
||||
// Explicit exports work regardless of wrapping.
|
||||
for (const m of src.matchAll(/window\.([A-Za-z_$][\w$]*)\s*=/g)) {
|
||||
names.add(m[1]);
|
||||
}
|
||||
// Top-level declarations are global only when not IIFE-wrapped.
|
||||
if (!isIifeWrapped(src)) {
|
||||
for (const m of src.matchAll(
|
||||
/^(?:async\s+)?function\s*\*?\s*([A-Za-z_$][\w$]*)\s*\(/gm,
|
||||
)) {
|
||||
names.add(m[1]);
|
||||
}
|
||||
for (const m of src.matchAll(
|
||||
/^(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:function|\()/gm,
|
||||
)) {
|
||||
names.add(m[1]);
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/** Identifiers invoked from inline `on*="..."` attributes, ignoring `a.b()`. */
|
||||
function inlineHandlerCalls(html: string): Set<string> {
|
||||
const calls = new Set<string>();
|
||||
for (const attr of html.matchAll(/\son[a-z]+\s*=\s*"([^"]*)"/g)) {
|
||||
for (const call of attr[1].matchAll(
|
||||
/(?:^|[^.\w$])([A-Za-z_$][\w$]*)\s*\(/g,
|
||||
)) {
|
||||
calls.add(call[1]);
|
||||
}
|
||||
}
|
||||
return calls;
|
||||
}
|
||||
|
||||
const htmlFiles = readdirSync(POS_DIR).filter((f) => f.endsWith(".html"));
|
||||
|
||||
describe("POS inline event handlers are reachable from global scope", () => {
|
||||
it.each(htmlFiles)("%s has no dead inline handlers", (file) => {
|
||||
const html = readFileSync(join(POS_DIR, file), "utf8");
|
||||
|
||||
// Everything this page executes: its own inline blocks plus local scripts.
|
||||
const sources: string[] = [
|
||||
...[...html.matchAll(/<script(?![^>]*\ssrc=)[^>]*>([\s\S]*?)<\/script>/g)].map(
|
||||
(m) => m[1],
|
||||
),
|
||||
...[...html.matchAll(/<script[^>]*\ssrc="([^"]+\.js)"/g)]
|
||||
.map((m) => m[1])
|
||||
.filter((src) => !src.startsWith("http"))
|
||||
.map((src) => readFileSync(join(POS_DIR, src), "utf8")),
|
||||
];
|
||||
|
||||
const reachable = new Set<string>();
|
||||
for (const src of sources) {
|
||||
for (const name of globalsFrom(src)) reachable.add(name);
|
||||
}
|
||||
|
||||
const dead = [...inlineHandlerCalls(html)]
|
||||
.filter((name) => !BUILTINS.has(name) && !reachable.has(name))
|
||||
.sort();
|
||||
|
||||
expect(dead, `${file}: inline handler(s) not reachable on window`).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue