Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion frontend/static/script/js/core/receipt-designer.js
Original file line number Diff line number Diff line change
Expand Up @@ -415,7 +415,7 @@
if (boundary) text += '\n';
}
walk(receipt);
text = text.replace(/₹/g, 'Rs. ').replace(/×/g, 'x').replace(/−/g, '-').replace(/…/g, '...').replace(/\u00a0/g, ' ').replace(/\u00b7/g, '-');
text = text.replace(/\u00a3/g, 'GBP ').replace(/\u20ac/g, 'EUR ').replace(/₹/g, 'Rs. ').replace(/×/g, 'x').replace(/−/g, '-').replace(/…/g, '...').replace(/\u00a0/g, ' ').replace(/\u00b7/g, '-');
if (/[^\x09\x0a\x0d\x20-\x7e]/.test(text)) return null;
var lines = text.split(/\r?\n/).map(function (line) { return line.replace(/\s+/g, ' ').trim().replace(/ \|$/, ''); }).filter(Boolean);
return lines.length ? lines : null;
Expand Down
21 changes: 10 additions & 11 deletions src/windows-print-queue.js
Original file line number Diff line number Diff line change
Expand Up @@ -262,21 +262,24 @@ class WindowsPrintQueue {
if (job.accepted && this.live.has(job.id) && !job.lastJobError && !reason) return this.transition(job, 'sent');
return this.transition(job, 'failed', 'Print outcome unknown; check the printer before requesting a duplicate');
}
// An idle initialization must finish before a receipt is submitted.
// Optional idle traffic must not hold a ready printer for 30 seconds.
// The same per-printer writer and Windows queue preserve submission order.
// Keep failed/offline jobs blocked by readiness; suppress further idle writes
// when an old pulse is still pending rather than adding more reset jobs.
const pulse = this.keepAlive[job.printer.toLowerCase()];
if (pulse?.pending && this.pulsePending(pulse, health)) {
if (this.now() - pulse.at < 30000) return this.transition(job, 'waiting', pulse.status);
// Do not bypass an actual failed Windows job: readiness still applies.
// Optional ESC @ traffic alone cannot permanently starve real receipts.
pulse.pending = false; pulse.suppressed = true;
pulse.suppressed = true;
this.write('keep-alive.json', this.keepAlive);
}
if (job.state === 'failed') {
if (!job.reconnect || reason) return;
job.reconnect = false; job.retries = 0; job.nextAt = 0;
}
if (job.nextAt > this.now()) return;
const canWake = !reason || (health.present === true && reason === 'Printer offline');
// Ready printers receive the receipt immediately: enqueue already prefixes ESC @.
// A separate wake job can remain visible in Windows for 30 seconds.
// Only an actually offline device needs the recovery initialization.
const canWake = health.present === true && reason === 'Printer offline';
if (canWake && job.binding.initialize !== false && this.transport.initialize) {
if (!job.wakeDocument) {
job.wakeDocument = 'Posnic-initialize-' + job.id;
Expand All @@ -300,11 +303,7 @@ class WindowsPrintQueue {
reason = readiness(health, job.binding);
if (reason) return this.defer(job, reason);
}
if (health.jobs.some(item => item.document === job.wakeDocument)) {
if (job.wakeStartedAt == null) { job.wakeStartedAt = this.now(); this.write(job.id + '.json', job); }
if (this.now() - job.wakeStartedAt < 30000) return this.transition(job, 'waiting', 'Waiting for printer initialization');
// Receipt already contains ESC @ in the same job; no new wake is sent.
}

}
if (reason) return this.defer(job, reason, /offline|disconnected|Printer error|failed job|USB discovery failed|could not be identified|Multiple connected USB|Saved USB device is absent|Printer status unknown/i.test(reason));
// Persist BEFORE crossing the spooler boundary. A crash in the following
Expand Down
11 changes: 11 additions & 0 deletions tests/receipt-designer.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1502,3 +1502,14 @@ test('hiding payment method removes cash and card without hiding totals or balan
}
dom.window.close();
});

test('GBP and EUR receipts stay on Fast receipt without raster fallback', () => {
const {dom, engine} = setup();
try {
const lines = engine.fastLines('<section class="rd-document"><p>Total \u00a31.50</p><p>Cash \u00a32.00</p><p>Change \u00a30.50</p><p>Total \u20ac3.00</p></section>');
assert.ok(lines);
const bytes = require('../src/receipt-fast').renderFastReceipt(lines, '80').toString('ascii');
assert.match(bytes, /Total GBP 1.50/); assert.match(bytes, /Cash GBP 2.00/);
assert.match(bytes, /Change GBP 0.50/); assert.match(bytes, /Total EUR 3.00/);
} finally { dom.window.close(); }
});
3 changes: 2 additions & 1 deletion tests/tools/receipt-cold-paint-proof.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ app.whenReady().then(async () => {
const body = Array.from({ length: 12 }, (_, i) => `<div style="height:256px;background:linear-gradient(to right,black ${(i + 1) * 16}px,white ${(i + 1) * 16}px)"></div>`).join('');
const document = `<html><body style="margin:0"><article class="rd-document" data-receipt-design="${paper}" style="width:${width}px"><span style="position:absolute;color:transparent">Synthetic receipt total</span>${body}</article></body></html>`;
for (let attempt = 0; attempt < 3; attempt++) {
const started = performance.now();
const strips = await rasterize({ document, paper, width });
assert.equal(strips.length, 12);
for (let index = 0; index < strips.length; index++) {
Expand All @@ -23,7 +24,7 @@ app.whenReady().then(async () => {
for (let x = 0; x < width / 8; x++) assert.equal(data[row * width / 8 + x], x < (index + 1) * 2 ? 255 : 0, `paper=${paper},attempt=${attempt},strip=${index},row=${row},byte=${x}`);
}
}
results.push({ paper, attempt, strips: strips.length });
results.push({ paper, attempt, strips: strips.length, preparationMs: Math.round(performance.now() - started) });
}
}
fs.writeFileSync(output, JSON.stringify({ passed: results.length, results }, null, 2));
Expand Down
33 changes: 19 additions & 14 deletions tests/windows-print-queue.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,10 @@ function rig(t) {
tick: async (ms = 15000, q = queue) => { now += ms; await q.tick(); }, hold: () => { drain = false; } };
}

test('online: initialization has no paper feed and receipt bytes/cut are preserved', async t => {
test('online: receipt initializes in the same job without a separate wake delay', async t => {
const r = rig(t); const result = await r.send();
assert.equal(result.status, 'Sent to printer'); assert.equal(result.success, true);
assert.equal(r.initialized.length, 1);
assert.equal(r.initialized.length, 0);
assert.deepEqual([...r.calls[0].bytes], [27, 64, 65, 10, 29, 86, 0]);
assert.equal(result.spoolerJobId, 1);
assert.ok(r.events.some(e => e.status === 'Queued'));
Expand Down Expand Up @@ -157,12 +157,16 @@ test('unknown USB presence and permission denied fail visibly without elevation'
assert.equal(r.calls.length, 0);
});

test('retained initialization job blocks receipt and initialization does not repeat', async t => {
const r = rig(t);
r.transport.initialize = async job => { r.initialized.push(job); r.health.jobs.push({ id: 5, document: job.document, error: false, status: 'Spooling' }); return { success: true }; };
assert.equal((await r.send()).status, 'Waiting'); await r.tick();
assert.equal(r.initialized.length, 1); assert.equal(r.calls.length, 0);
r.health.jobs = []; await r.tick(); assert.equal(r.calls.length, 1);
test('retained wake job does not delay a ready receipt or create another wake', async t => {
const r = rig(t); r.health.printerStatus = 7;
r.transport.initialize = async job => {
r.initialized.push(job); r.health.printerStatus = 3;
r.health.jobs.push({ id: 5, document: job.document, error: false, status: 'Spooling' });
return { success: true };
};
assert.equal((await r.send()).status, 'Sent to printer');
assert.equal(r.initialized.length, 1); assert.equal(r.calls.length, 1);
await r.tick(); assert.equal(r.calls.length, 1);
});

test('offline status with a present device can be initialized before readiness is rechecked', async t => {
Expand Down Expand Up @@ -400,7 +404,7 @@ test('missing legacy payload is not automatically recreated or replayed', async
});

test('startup failure is diagnosed accurately and pre-submission retries are bounded', async t => {
const r=rig(t);let attempts=0;
const r=rig(t);r.health.printerStatus=7;let attempts=0;
r.transport.initialize=async()=>{attempts++;return {success:false,unavailable:true,submission:'not-submitted',error:'Print helper startup failed: READY deadline exceeded'};};
await r.send();for(let i=0;i<6;i++)await r.tick(60000);
assert.equal(attempts,4);assert.equal(r.calls.length,0);
Expand All @@ -411,13 +415,14 @@ test('startup failure is diagnosed accurately and pre-submission retries are bou
test('controlled legacy recovery preserves a successful Reception copy and Kitchen identity', async t => {
const r=rig(t);await r.send('reception','Reception');
const submit=r.transport.submit;
r.health.printerStatus=7;
r.transport.initialize=async()=>({success:false,unavailable:true,error:'Print helper startup failed'});
await r.send('kitchen','Kitchen_New');
const job=r.queue.jobs.get(idFor('kitchen'));
job.state='failed';job.reason='Printer initialization failed; check Windows queue';delete job.nonSubmissionVerified;
r.queue.write(job.id+'.json',job);
r.queue.stop();const restored=r.make();
r.transport.initialize=async()=>({success:true});r.transport.submit=submit;
r.health.printerStatus=3; r.transport.initialize=async()=>({success:true});r.transport.submit=submit;
const before={...job.binding};
const result=await restored.recoverUnsubmitted(job.id);
assert.equal(result.success,true);assert.equal(result.jobId,job.id);
Expand All @@ -443,13 +448,13 @@ test('failure before receipt submission is retried once helper recovers; failure
});
test('optional retained initialization cannot starve healthy receipts indefinitely',async t=>{
const r=rig(t);r.transport.initialize=async job=>{r.initialized.push(job);r.health.jobs.push({id:99,document:job.document,status:'Spooling',error:false});return {success:true};};
await r.send();assert.equal(r.calls.length,0);await r.tick(30001);
assert.equal(r.calls.length,1);assert.equal(r.initialized.length,1);
await r.send();assert.equal(r.calls.length,1);assert.equal(r.initialized.length,0);
await r.tick(0);assert.equal(r.calls.length,1);
});
test('unknown idle pulse suppresses further optional traffic but not healthy receipts',async t=>{
const r=rig(t);r.queue.keepAlive.kitchen_printer={at:0,pending:true,document:'Posnic-idle-old',status:'unknown'};
await r.send();assert.equal(r.calls.length,0);await r.tick(30001);
assert.equal(r.calls.length,1);assert.equal(r.queue.keepAlive.kitchen_printer.suppressed,true);
await r.send();assert.equal(r.calls.length,1);
assert.equal(r.queue.keepAlive.kitchen_printer.suppressed,true);
});
test('a failed Windows queue job still blocks receipts after optional wake deadline',async t=>{
const r=rig(t);r.queue.keepAlive.kitchen_printer={at:0,pending:true,document:'Posnic-idle-old',status:'unknown'};
Expand Down