diff --git a/api/src/models/item.model.js b/api/src/models/item.model.js index 358e8f481..eafe59a45 100644 --- a/api/src/models/item.model.js +++ b/api/src/models/item.model.js @@ -156,6 +156,17 @@ const itemSchema = new mongoose.Schema( */ nutrition_source: { type: String, trim: true, default: '' }, + /* + * May a customer say how hot they want this one? + * + * Off unless the shop turns it on, dish by dish. Not because chillies look + * silly on a dessert - because a kitchen that batch-cooks its gravy CANNOT + * make one portion mild, and a customer who asked for mild and got hot is + * worse off than one who never asked. Only the kitchen knows which dishes + * it can really vary. See utils/spice-level.js. + */ + spice_choice: { type: Boolean, default: false }, + /* * What is and is not IN the dish: plant based, Jain, gluten free, nut * free, organic, no added sugar. See FOOD_TAGS in utils/dish-facts.js. @@ -277,6 +288,7 @@ class ItemModel { prep_minutes: { type: 'Number', select: true }, nutrition: { type: 'Object', select: true }, nutrition_source: { type: 'String', select: true }, + spice_choice: { type: 'Boolean', select: true }, food_tags: { type: 'Array', select: true }, menu_marks: { type: 'Array', select: true }, isAvailable: { type: 'Boolean', select: true }, diff --git a/api/src/repositories/item.repository.js b/api/src/repositories/item.repository.js index 89a111c8e..9c59166a1 100644 --- a/api/src/repositories/item.repository.js +++ b/api/src/repositories/item.repository.js @@ -1748,6 +1748,8 @@ class ItemRepository extends BaseModel { * Sync replaces whole documents, so all three are written on every * save or the next one deletes them. */ + /* Whether a customer may say how hot they want it. */ + spice_choice: Boolean(data.spice_choice), nutrition: dishFacts.cleanNutrition(data.nutrition), /* Only the one word means anything; everything else is a person. A client that omits it is the item screen, where a person is looking @@ -3971,6 +3973,7 @@ class ItemRepository extends BaseModel { /* What is on the plate and what is in it. The health badges are NOT read - they are derived from these below, so a dish can never carry a claim its own nutrition contradicts. */ + spice_choice: 1, nutrition: 1, nutrition_source: 1, food_tags: 1, @@ -4458,6 +4461,7 @@ class ItemRepository extends BaseModel { prep_minutes: '$prep_minutes', /* What is on the plate and what is in it. Folded into facts and CLAIMS below and do not travel raw. */ + spice_choice: '$spice_choice', nutrition: '$nutrition', nutrition_source: '$nutrition_source', food_tags: '$food_tags', diff --git a/api/src/repositories/sale.repository.js b/api/src/repositories/sale.repository.js index 60e26b9e6..7f9d36f83 100644 --- a/api/src/repositories/sale.repository.js +++ b/api/src/repositories/sale.repository.js @@ -40,6 +40,7 @@ async function withDayparts(shop) { const { notifyOrderAttention } = require('../helpers/order-attention'); const orderApproval = require('../utils/order-approval'); +const spiceLevel = require('../utils/spice-level'); const StockLogsRepository = require('./stock-log.repository'); const { PAYMENT_STATUS } = require('../constants'); const moment = require('moment-timezone'); @@ -8034,6 +8035,9 @@ class SalesRepository { /* See the note at the cancel flow below: this list is what the kitchen ticket is printed from. */ item_description: String(si.item_description || ''), + /* And for the same reason: a level stored on the sale and absent + from the change record never reaches the paper. */ + spice_level: spiceLevel.levelOf(si.spice_level), }; }) .filter((it) => it.item_id && it.item_quantity > 0); @@ -8560,6 +8564,13 @@ class SalesRepository { on the sale and printed on the ticket, and never shown to the person deciding whether to accept the order. */ note: item.item_description || '', + /* + * And how hot they asked for it, for the same reason. This is the + * screen where an order is refused, and "we cannot make that one + * mild" is a reason to refuse it - which nobody can act on if the + * request is only visible on the paper in the kitchen. + */ + spice: spiceLevel.levelOf(item.spice_level), })), total: Number(row.total) || 0, delivery_fee: Number(row.delivery_fee) || 0, @@ -9078,6 +9089,7 @@ class SalesRepository { item_name: String(ex.item_name || ''), item_quantity: qty, item_description: String(ex.item_description || ''), + spice_level: spiceLevel.levelOf(ex.spice_level), process: 'cancel', item_code: String(ex.item_sku || ''), unit: String(ex.item_unit || 'qty'), @@ -9120,8 +9132,10 @@ class SalesRepository { quantity: parseFloat(ex.item_quantity || 0), name: String(ex.item_name || ''), /* Carried so a REMOVED line can still say which one it was. Two of - the same dish on one table are told apart by the note. */ + the same dish on one table are told apart by the note, and by how + hot each of them was to be. */ description: String(ex.item_description || ''), + spice_level: spiceLevel.levelOf(ex.spice_level), item_code: String(ex.item_sku || ''), price: parseFloat(ex.item_price || 0), unit: String(ex.item_unit || 'qty'), @@ -9167,6 +9181,11 @@ class SalesRepository { ...(item.item_description != null ? { item_description: String(item.item_description) } : {}), + /* A KOT line the catalogue no longer holds still belongs to + somebody who may have changed their mind about the chillies. */ + ...(item.spice_level != null + ? { spice_level: spiceLevel.levelOf(item.spice_level) } + : {}), }; incomingProductIds.push(productId); } @@ -9191,6 +9210,16 @@ class SalesRepository { /* From the request first: an amendment carries the note the person just typed, and the stored copy is the one before it. */ item_description: String(item.item_note || item.item_description || ''), + /* + * Same order for the spice level, and the stored line is read + * through existingIndex rather than oldItemsData because that map + * has had this id deleted from it a few lines above. + */ + spice_level: spiceLevel.levelOf( + item.spice_level != null + ? item.spice_level + : (updatedItems[existingIndex[productId]] || {}).spice_level + ), process: changeProcess, item_code: String(itemDoc.itemid || ''), unit: String(itemDoc.item_unit || itemDoc.unit || 'qty'), @@ -9236,6 +9265,11 @@ class SalesRepository { }; if (item.item_description) updatedItems[i].item_description = String(item.item_description); + /* The ticket is printed from the change record above; THIS is what + the customer sees back on their own order and what a shop counts + later, so a change of mind has to land on both. */ + if (item.spice_level != null) + updatedItems[i].spice_level = spiceLevel.levelOf(item.spice_level); } else { const itemQuantity = qty; const sellingPrice = price; @@ -9287,6 +9321,7 @@ class SalesRepository { tax_amount: taxAmount, tax_fields: itemDoc.tax_fields || [], item_description: String(item.item_description || itemDoc.description || ''), + spice_level: spiceLevel.levelOf(item.spice_level), track_inventory: itemDoc.track_inventory || false, negative_stock: itemDoc.negative_stock || false, }); @@ -9302,6 +9337,7 @@ class SalesRepository { item_name: String(remItemData.name || ''), item_quantity: remQty, item_description: String(remItemData.description || ''), + spice_level: spiceLevel.levelOf(remItemData.spice_level), process: 'cancel', item_code: String(remItemData.item_code || ''), unit: String(remItemData.unit || 'qty'), @@ -9688,6 +9724,20 @@ class SalesRepository { item_description: String(item.item_note || item.item_description || '') .trim() .slice(0, 200), + /* + * HOW HOT, AS A NUMBER AND NOT AS A SENTENCE. + * + * The obvious build appends "less spicy" to the note above, and it is + * wrong twice: a customer reading a Tamil menu writes Tamil, so the + * ticket carries prose the kitchen may misread, and prose cannot be + * counted afterwards. A level prints identically on every ticket + * whatever language the order was placed in, and a shop can learn that + * four orders in ten ask for mild. + * + * levelOf refuses anything that is not 1, 2 or 3, so a device sending + * nonsense gets no promise made about somebody's food. + */ + spice_level: spiceLevel.levelOf(item.spice_level), // receipt-facing fields item_base_price: round(baseUnitPrice), item_quantity: qty, @@ -9821,6 +9871,7 @@ class SalesRepository { name: String(line.item_name || line.name || ''), quantity: Number(line.item_quantity != null ? line.item_quantity : line.quantity || 0), note: String(line.item_description || ''), + spice: spiceLevel.levelOf(line.spice_level), total: Number(line.total != null ? line.total : line.item_total || 0), })), total: Number(order.total != null ? order.total : order.sales_total || 0), diff --git a/api/src/utils/spice-level.js b/api/src/utils/spice-level.js new file mode 100644 index 000000000..371ce7b65 --- /dev/null +++ b/api/src/utils/spice-level.js @@ -0,0 +1,104 @@ +'use strict'; +/* + * How hot, when the kitchen can decide. + * + * Owner: "when user order if food is speci food. we can have simple option + * like low, medium high with number chilly image like one, two, three chilli + * icons user can customize easy. we can add those into kitchen note." + * + * Spice is the thing an Indian restaurant is asked to change more often than + * anything else on its menu, and until now the only way to ask was typing it + * into the free-text note - in whatever words, in whatever language, for a + * kitchen that then has to read prose off a ticket at speed. + * + * THREE LEVELS, AND NOT CHOOSING IS ONE TOO. + * + * Mild, medium, spicy. "However the kitchen makes it" is simply not picking, + * which is why there is no fourth button for it: a default that has to be + * selected is a question, and this has to stay one tap. Somebody who wants no + * chilli at all still has the note, which has not gone anywhere. + * + * WHY IT IS A FIELD AND NOT A SENTENCE IN THE NOTE. + * + * The obvious build is to append "less spicy" to the kitchen note, and it is + * wrong twice over. A customer reading a Tamil menu writes Tamil, and the + * ticket then carries prose the kitchen may misread; a LEVEL prints the same + * on every ticket whatever language the order was placed in. And a number can + * be counted afterwards - a shop can learn that four orders in ten ask for + * mild, and cook accordingly - which no amount of free text will ever tell it. + * + * NOT EVERY DISH. See item.spice_choice: the shop says which dishes take one. + * That is not about chillies looking silly on a gulab jamun. A kitchen that + * batch-cooks its gravy CANNOT make one portion mild, and a customer who asked + * for mild and got hot is worse off than one who never asked - so the offer + * exists only where the kitchen can honour it, and only the kitchen knows + * where that is. + * + * THE TICKET SAYS IT IN ASCII. The chillies are drawn on the customer's screen + * and nowhere else: escpos-receipt.js puts every character through ascii() and + * latin1, so an emoji on a kitchen ticket prints as a question mark or as + * nothing. The paper gets the word and the count instead, and the count is + * there for a cook who does not read the word. + * + * NO DATABASE IMPORTS. + */ + +/* Stored as the number of chillies, because that is what the customer taps + and what the ticket counts. 0 means nobody chose. */ +const SPICE = Object.freeze({ + NOT_SAID: 0, + MILD: 1, + MEDIUM: 2, + SPICY: 3, +}); + +const LEVELS = Object.freeze([SPICE.MILD, SPICE.MEDIUM, SPICE.SPICY]); + +/* The words the paper prints. English, because the ticket is the kitchen's + and the kitchen is the shop's; the customer's own screen says it in the + customer's language, from the ordering dictionary. */ +const WORDS = Object.freeze({ + [SPICE.MILD]: 'MILD', + [SPICE.MEDIUM]: 'MEDIUM', + [SPICE.SPICY]: 'SPICY', +}); + +/** + * A level somebody actually chose, or nothing. + * + * Anything that is not one of the three reads as NOT SAID, deliberately: a + * value nobody recognises must never become a promise about somebody's food. + */ +function levelOf(value) { + /* + * A NUMBER OR THE TEXT OF ONE, AND NOTHING ELSE. + * + * Number(true) is 1, so a client sending spice_level: true would have had + * MILD printed on a real ticket for a request nobody made. Caught by the + * test rather than by anybody reading this, which is the point of the test. + * A string is still allowed: a handset that stringifies its form sends "2", + * and refusing that would silently drop the request. + */ + if (typeof value !== 'number' && typeof value !== 'string') return SPICE.NOT_SAID; + const n = Number(value); + return LEVELS.includes(n) ? n : SPICE.NOT_SAID; +} + +/** Does this dish offer the choice at all? */ +function offersChoice(item) { + return Boolean(item && item.spice_choice === true); +} + +/** + * What the kitchen ticket says, or '' when nobody asked. + * + * The count is not decoration. A cook who does not read English still reads + * 2 of 3, and a ticket that printed only a word would be useless to them. + */ +function ticketLine(value) { + const level = levelOf(value); + if (!level) return ''; + return 'SPICE: ' + WORDS[level] + ' (' + level + ' of ' + LEVELS.length + ')'; +} + +module.exports = { SPICE, LEVELS, WORDS, levelOf, offersChoice, ticketLine }; diff --git a/api/tests/unit/models/item.model.test.js b/api/tests/unit/models/item.model.test.js index e7b939f35..3521d70f1 100644 --- a/api/tests/unit/models/item.model.test.js +++ b/api/tests/unit/models/item.model.test.js @@ -791,7 +791,7 @@ describe('Item.LegacyItemModel › class identity', () => { // deliberately NOT fields, so a dish can never store one). // + nutrition_source (who said so: a person, or a machine that guessed; // dish-facts publishes nothing derived from an estimate). - expect(Object.keys(LegacyItemModel.fields)).toHaveLength(77); + expect(Object.keys(LegacyItemModel.fields)).toHaveLength(78); expect(LegacyItemModel.fields).toEqual( expect.objectContaining({ /* Named as well as counted: a count alone passes if one field is diff --git a/api/tests/unit/services/customer-order.service.test.js b/api/tests/unit/services/customer-order.service.test.js index 35de0e6da..58ecd398b 100644 --- a/api/tests/unit/services/customer-order.service.test.js +++ b/api/tests/unit/services/customer-order.service.test.js @@ -279,8 +279,10 @@ describe('the order, read back by the phone that placed it', () => { can_change: false, why_not: 'already_paid', }); + /* `spice` is 0 because nobody chose one: the level is only ever a number + the customer tapped, and a dish nobody asked about carries none. */ expect(out.data.items).toEqual([ - { item_id: 'm1', name: 'Chicken Biryani', quantity: 2, note: '', total: 660 }, + { item_id: 'm1', name: 'Chicken Biryani', quantity: 2, note: '', spice: 0, total: 660 }, ]); }); diff --git a/api/tests/unit/the-kitchen-reads-how-hot.test.js b/api/tests/unit/the-kitchen-reads-how-hot.test.js new file mode 100644 index 000000000..8b9fbf1bc --- /dev/null +++ b/api/tests/unit/the-kitchen-reads-how-hot.test.js @@ -0,0 +1,138 @@ +'use strict'; + +/* + * "Two chillies" reaches the kitchen, by the road "less spicy" already took. + * + * Owner: "when user order if food is speci food. we can have simple option + * like low, medium high with number chilly image like one, two, three chilli + * icons user can customize easy. we can add those into kitchen note." + * + * THE ROAD IS THE POINT, and the-kitchen-reads-less-spicy.test.js is beside + * this file because it paid for the map. The kitchen ticket is NOT printed + * from `sale.items`: the poller builds its print jobs out of `changes[].items`, + * the record of what changed. The note was stored perfectly on the sale, both + * ticket builders printed it, and it still never reached paper - because three + * separate places build that change list and all three left it out. + * + * A spice level stored on the sale and absent from the change record would be + * the same bug, shipped again, in the same week the last one was fixed. So + * this asserts the RULE rather than one spelling: every change list that + * carries the customer's note carries the level beside it. + */ + +const fs = require('fs'); +const path = require('path'); + +const ROOT = path.join(__dirname, '..', '..'); +const REPO = fs.readFileSync(path.join(ROOT, 'src', 'repositories', 'sale.repository.js'), 'utf8'); + +/** + * Every object literal in the repository that carries the customer's note. + * + * Found by the note rather than by function name: the note is on the sale + * line, on three change lists and on the map a removed line is rebuilt from, + * and those are exactly the places a level has to travel too. Anchored this + * way, a fourth one added tomorrow is caught the day it appears. + */ +function placesCarryingTheNote() { + const out = []; + const re = /(item_description|description):\s*String\(/g; + let m; + while ((m = re.exec(REPO))) { + /* + * The literal this line sits in: back to its opening brace, forward to + * the matching close. Counted rather than regexed, because these objects + * hold nested ternaries and template strings. + * + * And out again while the literal is a ONE-LINE fragment. Two of these + * sites are conditional spreads - `...(x != null ? { item_description: … + * } : {})` - whose own braces enclose nothing but the note; the level + * sits beside them in the object those spreads build, which is the object + * this test means by "place". + */ + let open = m.index; + let close; + do { + open = REPO.lastIndexOf('{', open - 1); + let depth = 0; + close = open; + while (close < REPO.length) { + if (REPO[close] === '{') depth += 1; + else if (REPO[close] === '}') { + depth -= 1; + if (depth === 0) break; + } + close += 1; + } + } while (open > 0 && !REPO.slice(open, close).includes('\n')); + out.push({ at: m.index, body: REPO.slice(open, close + 1) }); + } + return out; +} + +test('every place that carries the note carries the level too', () => { + /* + * The whole test. A level that stops at any one of these is a level the + * kitchen never sees, and nothing anywhere fails: the order goes through, + * the ticket prints, and the food comes out wrong. + */ + const places = placesCarryingTheNote(); + expect(places.length).toBeGreaterThanOrEqual(5); + + const without = places.filter((p) => !/spice_level:/.test(p.body)); + expect(without.map((p) => REPO.slice(p.at, p.at + 60))).toEqual([]); +}); + +test('an amendment prefers the level just chosen over the stored one', () => { + /* + * Same rule as the note, for the same reason: the request carries what the + * person has only now said and the stored copy is what they said before. + * Reading the stored one first would quietly ignore a change of mind. + * + * Read through existingIndex rather than oldItemsData, because that map has + * had this id deleted from it a few lines above - which is the sort of thing + * that only shows up when somebody actually changes an order. + */ + expect(REPO).toMatch(/item\.spice_level != null\s*\?\s*item\.spice_level/); + expect(REPO).toMatch(/updatedItems\[existingIndex\[productId\]\] \|\| \{\}\)\.spice_level/); +}); + +test('every level is cleaned on the way in, never trusted', () => { + /* + * This arrives as JSON from a customer's own phone over the open internet. + * levelOf refuses anything that is not 1, 2 or 3 - a stray true included, + * because Number(true) is 1 and would have printed MILD on real paper. + */ + const raw = REPO.match(/spice_level:\s*([^\n]*)/g) || []; + expect(raw.length).toBeGreaterThanOrEqual(6); + for (const line of raw) { + expect(line).toMatch(/spiceLevel\.levelOf\(/); + } + expect(REPO).toMatch(/require\('\.\.\/utils\/spice-level'\)/); +}); + +test('the shop sees how hot before it decides whether to accept', () => { + /* + * The approval queue is where an order is REFUSED, and "we cannot make that + * one mild" is a reason to refuse it. A request visible only on the paper in + * the kitchen reaches the person who has to cook it and not the person who + * has to agree to it. + */ + expect(REPO).toMatch(/spice: spiceLevel\.levelOf\(item\.spice_level\)/); +}); + +test('the customer can see it back on their own order', () => { + /* Somebody who asked for mild and is waiting should be able to check that + the restaurant heard them, without ringing the counter. */ + expect(REPO).toMatch(/spice: spiceLevel\.levelOf\(line\.spice_level\)/); +}); + +test('the reason is written where the next person will look', () => { + /* + * The note bug was invisible because every layer worked and the ticket was + * still wrong. Whoever adds the next per-line field deserves to be told, in + * the file, that the ticket is printed from the CHANGE record. + */ + expect(REPO).toMatch(/THE NOTE TRAVELS WITH THE ITEM/); + expect(REPO).toMatch(/never reaches the paper/); +}); diff --git a/api/tests/unit/utils/spice-level.test.js b/api/tests/unit/utils/spice-level.test.js new file mode 100644 index 000000000..2f16937b3 --- /dev/null +++ b/api/tests/unit/utils/spice-level.test.js @@ -0,0 +1,99 @@ +'use strict'; + +/* + * How hot, and what may be said about it. + * + * Owner: "when user order if food is speci food. we can have simple option + * like low, medium high with number chilly image like one, two, three chilli + * icons user can customize easy. we can add those into kitchen note." + * + * The rule this file guards is small and worth stating plainly: a level that + * nobody chose must never become a level somebody did. Everything a customer + * sends arrives as JSON over the open internet, so "2" from a phone, 2 from a + * handset, 7 from a broken client and "medium" from somebody experimenting + * all land in the same field, and exactly three of those may reach a cook. + */ + +const spice = require('../../../src/utils/spice-level'); + +describe('a level somebody actually chose', () => { + test('the three the customer can tap are the three that count', () => { + expect(spice.levelOf(1)).toBe(1); + expect(spice.levelOf(2)).toBe(2); + expect(spice.levelOf(3)).toBe(3); + }); + + test('a number in a string is still that number, because JSON is JSON', () => { + /* The ordering page sends a number; a handset that stringifies its form + sends "2". Refusing the second would silently drop the request. */ + expect(spice.levelOf('2')).toBe(2); + }); + + test('anything else is nobody asked', () => { + /* + * Not an error, and not a guess. A value nobody recognises must not become + * a promise about somebody's food, and it must not stop the order either: + * the dish is still wanted, the request simply cannot be honoured. + */ + [0, 4, -1, 99, '', null, undefined, NaN, 'medium', {}, [], true].forEach((bad) => { + expect(spice.levelOf(bad)).toBe(0); + }); + }); +}); + +describe('which dishes offer the choice', () => { + test('only a dish the shop ticked', () => { + /* + * Off by default and per dish, because a kitchen that batch-cooks its + * gravy cannot make one portion mild - and a customer who asked for mild + * and got hot is worse off than one who never asked. + */ + expect(spice.offersChoice({ spice_choice: true })).toBe(true); + expect(spice.offersChoice({ spice_choice: false })).toBe(false); + expect(spice.offersChoice({})).toBe(false); + expect(spice.offersChoice(null)).toBe(false); + }); + + test('a truthy value that is not true does not count', () => { + /* Settings have arrived as the string "false" in this codebase before, + and read as ON through a loose check. A boolean field is a boolean. */ + expect(spice.offersChoice({ spice_choice: 'false' })).toBe(false); + expect(spice.offersChoice({ spice_choice: 1 })).toBe(false); + }); +}); + +describe('what the paper says', () => { + test('the word and the count, in that order', () => { + expect(spice.ticketLine(1)).toBe('SPICE: MILD (1 of 3)'); + expect(spice.ticketLine(2)).toBe('SPICE: MEDIUM (2 of 3)'); + expect(spice.ticketLine(3)).toBe('SPICE: SPICY (3 of 3)'); + }); + + test('nobody asked, nothing printed', () => { + /* A ticket line reading "SPICE: NOT SAID" is a line every cook learns to + skip, on every ticket, forever. */ + expect(spice.ticketLine(0)).toBe(''); + expect(spice.ticketLine(undefined)).toBe(''); + expect(spice.ticketLine('hot')).toBe(''); + }); + + test('every character survives a thermal printer', () => { + /* + * escpos-receipt.js puts every character through ascii() and latin1. A + * chilli emoji on this line would reach the kitchen as a question mark or + * as nothing, which is why the chillies are drawn on the customer's phone + * and the paper gets a word and a number instead. + */ + for (const level of spice.LEVELS) { + const line = spice.ticketLine(level); + + expect(line).toMatch(/^[\x20-\x7e]+$/); + } + }); + + test('the count is there for a cook who does not read the word', () => { + /* The whole reason this is a level and not a sentence: 2 of 3 means the + same thing to a cook in any language. */ + expect(spice.ticketLine(2)).toContain('2 of 3'); + }); +}); diff --git a/frontend/modules/items_write.html b/frontend/modules/items_write.html index da4210e77..561d5983f 100644 --- a/frontend/modules/items_write.html +++ b/frontend/modules/items_write.html @@ -1220,6 +1220,19 @@
+
+ + What a customer may ask for + + Tick this only for a dish your kitchen can really cook to order. A customer who asks for mild and gets hot is worse off than one who never asked. + +
+ +
+ +
diff --git a/order/indexedDB.js b/order/indexedDB.js index d6de7d118..56a615226 100644 --- a/order/indexedDB.js +++ b/order/indexedDB.js @@ -1177,6 +1177,36 @@ async function fetchAndStoreBranch(branchId, redirect = true, options = {}) { icon: item.icon || "", available: item.available !== false, served_in: Array.isArray(item.served_in) ? item.served_in.filter(Boolean) : [], + /* + * WHAT IS ON THE PLATE, AND IT WAS BEING THROWN AWAY. + * + * This loop is the ordering bundle's whole catalogue: + * a field it does not NAME here never reaches the page, + * however correctly the server sent it. The server has + * been sending nutrition, the shop's own marks, the + * "made without" tags and the earned health claims + * since the dish-facts release, and all four stopped + * at this object literal. + * + * So on /order the dish sheet drew no numbers, no + * badges and no marks, and the "Good for" filter group + * had nothing to offer and hid itself - while /menu, + * which reads the same endpoint straight without a + * local store, showed all of it. Nothing failed and + * nothing was logged; the fields simply were not there. + * + * `claims` is computed on the server from the shop's + * own numbers and is never stored on a dish, so + * carrying it here cannot invent a badge - it can only + * deliver one the numbers already earned. + */ + nutrition: item.nutrition && typeof item.nutrition === "object" ? item.nutrition : {}, + tags: Array.isArray(item.tags) ? item.tags : [], + marks: Array.isArray(item.marks) ? item.marks : [], + claims: Array.isArray(item.claims) ? item.claims : [], + /* Whether the kitchen said it can cook this one to + order. See api/src/utils/spice-level.js. */ + spice_choice: item.spice_choice === true, category_name: category.category_name }); }); @@ -1314,6 +1344,14 @@ async function validateCartWithProducts(updatedProducts, renderUI = true) { diet: updatedProduct.diet || "", price: updatedProduct.price, tax_price: updatedProduct.tax_price, + /* + * Whether the dish still OFFERS a spice level comes from + * the catalogue, because the shop may have turned it off + * since this basket was filled. What the customer CHOSE is + * theirs and is not touched here, exactly as their note is + * not: the spread above carries both forward. + */ + spice_choice: updatedProduct.spice_choice === true, }; } return null; // Item no longer exists in product list @@ -1416,6 +1454,13 @@ async function renderCart(cartData = null) { * where there is a kitchen to read it; a stationer gets an order * note at the foot instead. */ + /* + * How hot, on the line it is about. Read-only here; the picker + * that changes it is one tap away behind the request button, and + * a basket is for checking rather than for fiddling. + */ + const spiceHtml = window.PosnicSpice ? window.PosnicSpice.chip(item.spice) : ""; + const note = String(item.note || "").trim(); const noteHtml = shop.notes ? (note ? `
${escapeHtml(note)}
` : "") + @@ -1432,6 +1477,7 @@ async function renderCart(cartData = null) { ${escapeHtml(money(price))} each ${escapeHtml(money(lineTotal))}
+ ${spiceHtml} ${noteHtml}
@@ -1520,6 +1566,23 @@ async function renderCart(cartData = null) { } } +/** + * How hot one line is to be cooked, kept with the line. + * + * Stored as the number of chillies the customer tapped; 0 is nobody asked, + * and 0 is what an unrecognised value becomes. Written the way a note is + * written - straight onto the line and saved - so the two cannot get out of + * step over which one survives a reload. + */ +async function setCartItemSpice(id, level) { + const cartData = await getCartData(); + const line = cartData.find(item => String(item.id) === String(id)); + if (!line) return; + line.spice = window.PosnicSpice ? window.PosnicSpice.levelOf(level) : 0; + await saveCartData(cartData); + renderCart(cartData); +} + /** A note on one line of the order, kept with the line. */ async function setCartItemNote(id, text) { const cartData = await getCartData(); @@ -2721,7 +2784,14 @@ async function performCheckout(transactionId, paymentStatus = "Upi", options = { gst: item.tax_price * item.quantity, /* What the customer asked for on this line; printed on the kitchen ticket under the dish. */ - item_note: String(item.note || "").trim().slice(0, 200) + item_note: String(item.note || "").trim().slice(0, 200), + /* + * How hot, as a number rather than as a sentence in the note. + * A level prints the same on every ticket whatever language + * the order was placed in, and can be counted afterwards. The + * server refuses anything that is not 1, 2 or 3. + */ + spice_level: window.PosnicSpice ? window.PosnicSpice.levelOf(item.spice) : 0 }; }); diff --git a/order/products.html b/order/products.html index 00a3212f5..8fff03d40 100644 --- a/order/products.html +++ b/order/products.html @@ -38,6 +38,7 @@ so there is no file to fetch and nothing for the CSP to allow. --> + @@ -356,6 +357,10 @@

+ + diff --git a/src/escpos-kot.js b/src/escpos-kot.js index 36528c8b8..5da338f2f 100644 --- a/src/escpos-kot.js +++ b/src/escpos-kot.js @@ -34,6 +34,35 @@ */ const { Receipt } = require('./escpos-receipt'); +/* + * HOW HOT, ON THE PAPER. + * + * The customer taps one, two or three chillies; the ticket says the word and + * the count. ASCII, because every character on this path goes through + * Receipt.text -> ascii() -> latin1, and an emoji arrives as a question mark + * or as nothing at all. The count is not decoration: a cook who does not read + * English still reads 2 of 3. + * + * WHY THIS IS NOT IMPORTED. The same three words live in + * api/src/utils/spice-level.js, which is where the server decides them, and + * the desktop shell cannot require across into the API package. Rather than a + * byte-copy pipeline for eight lines, tests/the-ticket-says-how-hot.test.js + * runs BOTH implementations over every level and refuses a commit where they + * disagree - so the drift this would otherwise invite fails a build instead of + * misreporting somebody's food. + */ +const SPICE_WORDS = { 1: 'MILD', 2: 'MEDIUM', 3: 'SPICY' }; + +/** 'SPICE: MEDIUM (2 of 3)', or '' when nobody asked. */ +function spiceLine(value) { + /* A number or the text of one. Number(true) is 1, and a stray boolean must + not print MILD on a ticket; see levelOf in api/src/utils/spice-level.js. */ + if (typeof value !== 'number' && typeof value !== 'string') return ''; + const n = Number(value); + if (!SPICE_WORDS[n]) return ''; + return 'SPICE: ' + SPICE_WORDS[n] + ' (' + n + ' of 3)'; +} + /** A quantity the way a kitchen reads it: x2, never 2x or "qty 2". */ function qtyText(value) { const n = Number(value); @@ -134,6 +163,14 @@ function renderKitchenTicket(ticket = {}, options = {}) { r.bold(true); r.pair(name.toUpperCase(), qty, { bold: true, strike: strikeThem }); r.bold(false); + /* + * The level BEFORE the note, and on its own line rather than folded into + * it. A level is the same three words on every ticket in every language; + * the note is whatever somebody typed. Printing them as one line would + * make the reliable half as hard to trust as the unreliable half. + */ + const hot = spiceLine(item && (item.spice_level != null ? item.spice_level : item.spice)); + if (hot) r.line(' ' + hot); const note = String((item && (item.description || item.item_description)) || '').trim(); if (note) r.line(' ** ' + note + ' **'); } @@ -144,4 +181,4 @@ function renderKitchenTicket(ticket = {}, options = {}) { return r.build(); } -module.exports = { renderKitchenTicket }; +module.exports = { renderKitchenTicket, spiceLine }; diff --git a/src/kot-manager.js b/src/kot-manager.js index 5949740f6..6e1266d05 100644 --- a/src/kot-manager.js +++ b/src/kot-manager.js @@ -10,7 +10,7 @@ const os = require('os'); const { printPdfFile } = require('./print-pdf'); const { hardenPrintWindow } = require('./print-window-guard'); const { normalizeTargets, pageSizeFor, columnsFor } = require('./printer-targets'); -const { renderKitchenTicket } = require('./escpos-kot'); +const { renderKitchenTicket, spiceLine } = require('./escpos-kot'); const printLedger = require('./print-ledger'); /* @@ -680,6 +680,9 @@ class KOTManager { name: it.item_name || it.name || it.product_name || it.itemName || '', quantity: it.item_quantity ?? it.quantity ?? it.qty ?? 1, description: it.item_description || it.description || it.note || '', + /* Carried through so the thermal renderer can print it; see + spiceLine in escpos-kot.js. */ + spice_level: it.spice_level != null ? it.spice_level : it.spice, })), /* The HTML ticket has struck out cancelled dishes for as long as it has existed; the bytes could not, until strikeLine. Same field @@ -1087,11 +1090,15 @@ class KOTManager { const name = it.item_name || it.name || it.product_name || it.itemName || ''; const qty = it.item_quantity || it.quantity || it.qty || it.item_qty || 1; const desc = it.item_description || it.description || it.desc || ''; + /* The same line the thermal path prints, from the same function, so the + two ways of printing one ticket cannot say different things. */ + const hot = spiceLine(it.spice_level != null ? it.spice_level : it.spice); return `
${this._esc(String(name))}
x${qty}
+ ${hot ? `
${this._esc(hot)}
` : ''} ${desc ? `
** ${this._esc(String(desc))} **
` : ''}
`; }).join(''); @@ -1116,6 +1123,9 @@ body{padding:6px;width:72mm;box-sizing:border-box;} .in.cx{text-decoration:line-through;} .iq{font-weight:700;font-size:14px;min-width:24px;text-align:right;} .id{font-size:11px;font-style:italic;font-weight:700;} +/* How hot, upright and bold rather than italic: it is a setting the cook + acts on, not a remark somebody added. */ +.is{font-size:12px;font-weight:800;letter-spacing:0.5px;} .nt{font-size:12px;font-weight:700;border:1px dashed #000;padding:3px 4px;margin:4px 0;white-space:pre-wrap;} @media print{@page{size:72mm auto;margin:0;}body{width:72mm;margin:0;padding:0;}} diff --git a/tests/how-hot-the-customer-asks.test.js b/tests/how-hot-the-customer-asks.test.js new file mode 100644 index 000000000..035d5553d --- /dev/null +++ b/tests/how-hot-the-customer-asks.test.js @@ -0,0 +1,226 @@ +'use strict'; + +/* + * How hot would you like it? + * + * Owner: "when user order if food is speci food. we can have simple option + * like low, medium high with number chilly image like one, two, three chilli + * icons user can customize easy. we can add those into kitchen note. what you + * think?" + * + * What I think, and what this file holds the build to: + * + * - PER DISH, because a kitchen that batch-cooks its gravy cannot make one + * portion mild, and a customer who asked for mild and got hot is worse off + * than one who never asked. The offer exists only where it can be honoured. + * - NOT CHOOSING IS AN ANSWER, and it is the one most people give. There is + * no fourth button for "however you make it": a default that has to be + * selected turns a tap into a question. + * - ONE CONTROL, TWO PLACES. The dish sheet is where somebody chooses and + * the basket is where they change their mind; the day the two copies + * disagree is the day a customer picks mild in one and the kitchen reads + * medium from the other. + * + * Driven in a DOM rather than read as source. Every earlier test on this + * bundle's dish facts asserted the shape of the code and passed for months + * while the feature was dead on the page - see the last test in this file, + * which is about exactly that. + */ + +const test = require('node:test'); +const assert = require('node:assert'); +const fs = require('node:fs'); +const path = require('node:path'); +const { JSDOM } = require('jsdom'); + +const ROOT = path.join(__dirname, '..'); +const SPICE = fs.readFileSync(path.join(ROOT, 'order', 'assets', 'spice.js'), 'utf8'); + +/** The real module, in a real document, with nothing stubbed but the page. */ +function picker(level) { + const dom = new JSDOM('
', { + runScripts: 'outside-only', + }); + const { window } = dom; + window.eval(SPICE); + const picked = []; + const control = window.PosnicSpice.mount(window.document.getElementById('box'), (n) => + picked.push(n) + ); + if (level !== undefined) control.set(level); + return { window, document: window.document, control, picked }; +} + +const steps = (doc) => Array.from(doc.querySelectorAll('.spice-step')); +const chosen = (doc) => steps(doc).filter((s) => s.getAttribute('aria-checked') === 'true'); + +/* ------------------------------------------------------------ the control */ + +test('three levels, one chilli more on each', () => { + const page = picker(); + const drawn = steps(page.document); + assert.strictEqual(drawn.length, 3, 'there are not three levels'); + assert.deepStrictEqual( + drawn.map((s) => s.getAttribute('data-spice')), + ['1', '2', '3'] + ); + /* The chillies are the whole ask: one, two, three, drawn. */ + const chillies = drawn.map((s) => s.querySelector('.spice-chillies').textContent); + assert.strictEqual([...chillies[0]].length < [...chillies[1]].length, true); + assert.strictEqual([...chillies[1]].length < [...chillies[2]].length, true); + /* And a word beside each, because an emoji renders differently on every + phone and nobody should have to count peppers to order dinner. */ + assert.deepStrictEqual( + drawn.map((s) => s.querySelector('.spice-word').textContent), + ['Mild', 'Medium', 'Spicy'] + ); +}); + +test('nothing is chosen until somebody chooses', () => { + /* + * The design decision worth defending. A picker that opens on Medium has + * decided for the customer, and every order then carries a request the + * kitchen has to honour whether or not anybody wanted it. + */ + const page = picker(); + assert.strictEqual(chosen(page.document).length, 0, 'a level was pre-selected'); + assert.strictEqual(page.control.value(), 0); +}); + +test('a tap chooses, and only one is chosen at a time', () => { + const page = picker(); + steps(page.document)[1].dispatchEvent(new page.window.Event('click')); + assert.deepStrictEqual(page.picked, [2], 'the tap was not reported'); + assert.strictEqual(page.control.value(), 2); + assert.deepStrictEqual( + chosen(page.document).map((s) => s.getAttribute('data-spice')), + ['2'] + ); + + steps(page.document)[0].dispatchEvent(new page.window.Event('click')); + assert.deepStrictEqual( + chosen(page.document).map((s) => s.getAttribute('data-spice')), + ['1'], + 'two levels are lit at once' + ); +}); + +test('the way back out appears only once there is something to undo', () => { + /* + * The alternative was a fourth button reading "however the kitchen makes + * it", which is the default and therefore not a choice anybody should be + * asked to make. A link that shows up after the fact costs nothing until + * it is wanted. + */ + const page = picker(); + const clear = page.document.querySelector('.spice-clear'); + assert.strictEqual(clear.hidden, true, 'an undo was offered before anything was done'); + + steps(page.document)[2].dispatchEvent(new page.window.Event('click')); + assert.strictEqual(clear.hidden, false, 'no way to take the choice back'); + + clear.dispatchEvent(new page.window.Event('click')); + assert.strictEqual(page.control.value(), 0, 'clearing did not clear'); + assert.strictEqual(chosen(page.document).length, 0); + assert.deepStrictEqual(page.picked, [3, 0], 'the clear was not reported'); + assert.strictEqual(clear.hidden, true); +}); + +test('putting a stored level back on screen is not a choice somebody just made', () => { + /* + * set() is called every time the sheet opens on a line that already has a + * level. Firing the callback there would write the value straight back to + * storage on every open, and on the basket sheet it would commit a change + * the customer had not saved yet - which makes Cancel a lie. + */ + const page = picker(2); + assert.strictEqual(page.control.value(), 2); + assert.deepStrictEqual( + chosen(page.document).map((s) => s.getAttribute('data-spice')), + ['2'] + ); + assert.deepStrictEqual(page.picked, [], 'showing a stored level reported it as a new choice'); +}); + +test('a level nobody recognises is no level at all', () => { + /* + * Everything here arrives from storage a customer's own browser owns and a + * payload anybody can post. A value that is not one of the three must never + * become a promise about somebody's food - and must never stop the order. + */ + const page = picker(); + const { levelOf } = page.window.PosnicSpice; + assert.strictEqual(levelOf(2), 2); + assert.strictEqual(levelOf('2'), 2); + [0, 4, -1, '', null, undefined, NaN, true, 'medium', {}, []].forEach((bad) => { + assert.strictEqual(levelOf(bad), 0, JSON.stringify(bad) + ' read as a level'); + }); +}); + +/* ------------------------------------------------ read back in the basket */ + +test('the basket says what was asked for, and says nothing when nothing was', () => { + const page = picker(); + const { chip } = page.window.PosnicSpice; + assert.strictEqual(chip(0), '', 'a line nobody chose for carries a badge'); + assert.strictEqual(chip(undefined), ''); + assert.match(chip(2), /Medium/); + /* Drawn as well as named, the same as in the picker above. */ + assert.match(chip(3), /Spicy/); +}); + +/* -------------------------------------- what the page is given to work with */ + +test('the ordering catalogue keeps every fact the server sends it', () => { + /* + * THE BUG THIS FOUND, which is not the spice level. + * + * order/indexedDB.js builds the ordering bundle's whole catalogue in one + * object literal, and a field it does not NAME there never reaches the page + * however correctly the server sent it. The server has been sending + * nutrition, the shop's own marks, the "made without" tags and the earned + * health claims since the dish-facts release, and all four stopped at that + * literal: /order drew no numbers, no badges and no marks, and its "Good + * for" filter group had nothing to offer and hid itself - while /menu, which + * reads the same endpoint without a local store, showed all of it. Nothing + * failed and nothing was logged. + * + * So this asks the SERVER what a dish carries rather than naming the fields + * here: add a fifth fact tomorrow and this fails until the page is told to + * keep it, which is the only version of this test worth having. + */ + const dishFacts = require(path.join(ROOT, 'api', 'src', 'utils', 'dish-facts.js')); + const src = fs.readFileSync(path.join(ROOT, 'order', 'indexedDB.js'), 'utf8'); + + const at = src.indexOf('products.push({'); + assert.ok(at !== -1, 'the ordering catalogue is not built where this test thinks'); + const block = src.slice(at, src.indexOf('});', at)); + + for (const field of Object.keys(dishFacts.factsFor({}))) { + assert.ok( + new RegExp('(^|\\s)' + field + ':').test(block), + 'the ordering catalogue drops "' + field + '", so the page never sees it' + ); + } + /* And the one this change adds, for the same reason. */ + assert.ok(/(^|\s)spice_choice:/.test(block), 'the catalogue drops spice_choice'); +}); + +test('a cart line is told whether its dish offers the choice', () => { + /* + * The basket has no catalogue open. Without this the picker behind the + * request button could not tell a dish that takes a level from one that + * does not, and would either offer it on everything or on nothing. + */ + const core = require(path.join(ROOT, 'order', 'assets', 'kiosk-core.js')); + const { item } = core.changeCartQuantity( + [], + { id: 'a', name: 'Chicken Chettinad', price: 220, tax_price: 0, spice_choice: true }, + 'a', + 1 + ); + assert.strictEqual(item.spice_choice, true); + + const plain = core.changeCartQuantity([], { id: 'b', name: 'Gulab Jamun', price: 80 }, 'b', 1); + assert.strictEqual(plain.item.spice_choice, false, 'a dessert was offered a spice level'); +}); diff --git a/tests/the-ticket-says-how-hot.test.js b/tests/the-ticket-says-how-hot.test.js new file mode 100644 index 000000000..8f097580b --- /dev/null +++ b/tests/the-ticket-says-how-hot.test.js @@ -0,0 +1,204 @@ +'use strict'; + +/* + * The paper says how hot. + * + * Owner: "when user order if food is speci food. we can have simple option + * like low, medium high with number chilly image like one, two, three chilli + * icons user can customize easy. we can add those into kitchen note." + * + * Spice is what an Indian restaurant is asked to change more often than + * anything else on its menu, and the only way to ask was typing it into the + * free-text note: in whatever words, in whatever language, for a kitchen that + * then has to read prose off a ticket at speed. A LEVEL prints the same on + * every ticket whatever language the order was placed in, and can be counted + * afterwards, which no amount of free text will ever allow. + * + * TWO COPIES OF THREE WORDS, ON PURPOSE, for the reason + * the-ticket-says-where-the-order-came-from.test.js already writes down: the + * API ships OUTSIDE the asar archive as extraResources/server.js while src/ + * lives inside it, so src/ cannot require into api/ - a require there resolves + * to a second copy or to nothing. So the words are written twice and this file + * runs BOTH over every input and refuses a commit where they disagree. Two + * copies that are checked beat one copy that cannot be reached. + */ + +const test = require('node:test'); +const assert = require('node:assert'); +const os = require('node:os'); +const path = require('node:path'); +const Module = require('node:module'); + +const ROOT = path.join(__dirname, '..'); + +/* Same guard as kitchen-ticket-as-bytes.test.js: reaching for a window means + the byte path was not taken, and the test should say so rather than pass. */ +const load = Module._load; +Module._load = function (request, ...rest) { + if (request === 'electron') { + return { + BrowserWindow: class { constructor() { throw new Error('the window path was used'); } }, + app: { getPath: () => os.tmpdir() }, + }; + } + return load.call(this, request, ...rest); +}; +const { renderKitchenTicket, spiceLine } = require(path.join(ROOT, 'src', 'escpos-kot.js')); +const KOTManager = require(path.join(ROOT, 'src', 'kot-manager.js')); +Module._load = load; + +const api = require(path.join(ROOT, 'api', 'src', 'utils', 'spice-level.js')); + +/** The bytes as a person would read them: printable kept, controls as dots. */ +const readable = (buffer) => { + let out = ''; + for (const b of buffer) { + if (b === 10) out += '\n'; + else if (b >= 32 && b <= 126) out += String.fromCharCode(b); + else out += '.'; + } + return out; +}; + +const ticket = (item) => + readable( + renderKitchenTicket({ + title: 'New Order', + number: 12, + tableNo: '4', + items: [Object.assign({ name: 'Chicken Chettinad', quantity: 1 }, item)], + }) + ); + +/* ------------------------------------------- the two copies say one thing */ + +test('the shell and the server word every level identically', () => { + /* + * The whole reason this file exists. If somebody renames MEDIUM on one side, + * a ticket and an order screen start describing the same food differently + * and nothing anywhere fails - until a cook makes the wrong thing. + */ + const inputs = [0, 1, 2, 3, 4, -1, '1', '2', '3', '', null, undefined, NaN, true, 'hot', {}, []]; + for (const value of inputs) { + assert.strictEqual( + spiceLine(value), + api.ticketLine(value), + 'the two spice tables disagree about ' + JSON.stringify(value) + ); + } +}); + +test('a stray boolean prints nothing, because Number(true) is 1', () => { + /* + * Found by the test and not by reading it: every one of these paths does + * Number(value), and a client sending `true` would have had MILD printed on + * real paper for a request nobody made. A level nobody chose must never + * become a level somebody did. + */ + assert.strictEqual(spiceLine(true), ''); + assert.strictEqual(api.ticketLine(true), ''); +}); + +/* ------------------------------------------------------------ on the roll */ + +test('the level reaches the paper, worded and counted', () => { + const paper = ticket({ spice_level: 2 }); + assert.match(paper, /SPICE: MEDIUM \(2 of 3\)/); +}); + +test('the count is on the paper for a cook who does not read the word', () => { + /* The reason this is a number and not a sentence: 2 of 3 means the same + thing in every kitchen, whatever language the order was placed in. */ + assert.match(ticket({ spice_level: 1 }), /\(1 of 3\)/); + assert.match(ticket({ spice_level: 3 }), /\(3 of 3\)/); +}); + +test('nobody asked, nothing printed', () => { + /* + * A line reading "SPICE: NOT SAID" on every ticket is a line every cook + * learns to skip, and the day it matters they skip it too. + */ + assert.doesNotMatch(ticket({}), /SPICE/); + assert.doesNotMatch(ticket({ spice_level: 0 }), /SPICE/); +}); + +test('the level is its own line, not folded into the note', () => { + /* + * A level is three fixed words on every ticket; a note is whatever somebody + * typed. Printed as one line, the reliable half becomes as hard to trust as + * the unreliable half - and the level reads FIRST, because it is the part + * the cook acts on before reading anything. + */ + const paper = ticket({ spice_level: 3, description: 'no coriander' }); + const spiceAt = paper.indexOf('SPICE: SPICY'); + const noteAt = paper.indexOf('no coriander'); + assert.ok(spiceAt !== -1 && noteAt !== -1, 'one of the two lines is missing'); + assert.ok(spiceAt < noteAt, 'the note printed above the spice level'); + assert.ok( + paper.slice(spiceAt, noteAt).includes('\n'), + 'the level and the note printed on one line' + ); +}); + +test('every byte of the line is printable, so no printer swallows it', () => { + /* + * Receipt.text puts everything through ascii() and latin1. A chilli emoji + * here would arrive as a question mark or as nothing at all, which is why + * the chillies are drawn on the customer's phone and the paper gets words. + */ + const bytes = renderKitchenTicket({ + title: 'New Order', + items: [{ name: 'Dish', quantity: 1, spice_level: 3 }], + }); + const line = readable(bytes).split('\n').find((l) => l.includes('SPICE')); + assert.ok(line, 'no spice line on the ticket'); + /* From SPICE onward is the text itself; what precedes it on the same line + is the printer's own bold-and-align bytes, which readable() shows as + dots and which every line of every ticket carries. */ + assert.doesNotMatch( + line.slice(line.indexOf('SPICE')), + /\./, + 'an unprintable byte reached the spice line' + ); + // eslint-disable-next-line no-control-regex + assert.match(spiceLine(3), /^[\x20-\x7e]+$/, 'the line is not plain ASCII at source'); +}); + +/* --------------------------------------------- and on the window fallback */ + +test('the window-and-PDF ticket says exactly the same thing', () => { + /* + * Two ways of printing one ticket. A thermal roll gets bytes and anything + * else gets HTML, and a shop on the second path has no way of knowing it is + * reading a different ticket from the shop on the first. + */ + const manager = new KOTManager({ config: {} }); + const html = manager._buildKOTHtml( + { + sales_id: '7', + table_number: '4', + items: [ + { item_name: 'Chicken Chettinad', item_quantity: 1, spice_level: 2, item_description: 'no coriander' }, + ], + }, + 'new', + 12 + ); + assert.ok(html.includes('SPICE: MEDIUM (2 of 3)'), 'the HTML ticket lost the level'); + assert.ok( + html.indexOf('SPICE: MEDIUM') < html.indexOf('no coriander'), + 'the HTML ticket printed the note above the level' + ); + /* Its own element, so a shop can restyle one without touching the other. */ + assert.match(html, /
SPICE: MEDIUM \(2 of 3\)<\/div>/); +}); + +test('a dish nobody chose for gets no line on the HTML ticket either', () => { + const manager = new KOTManager({ config: {} }); + const html = manager._buildKOTHtml( + { sales_id: '7', items: [{ item_name: 'Dish', item_quantity: 1 }] }, + 'new', + 12 + ); + assert.ok(!html.includes('SPICE'), 'an unasked-for level printed anyway'); +});