From e8e30e1c1c61c134a3cd78e34b73f979c636d19a Mon Sep 17 00:00:00 2001 From: Sridhar Bala Date: Tue, 15 Sep 2026 15:29:38 +0530 Subject: [PATCH] One tap says how hot, and the ticket prints it the same in every language Spice is what a restaurant is asked to change more often than anything else on its menu, and the only way to ask was typing it into the note: in whatever words, in whatever language, for a kitchen that then has to read prose off a ticket at speed. Three levels, one chilli more on each, on the dishes the shop says its kitchen can really cook to order. Not choosing is an answer and needs no button. A level prints identically on every ticket whatever language the order was placed in, and can be counted afterwards, which no amount of free text will ever allow. 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. It travels by the road the note already took: sale line, change record, approval queue, paper. The kitchen ticket is printed from changes[], not from sale.items, so a field stored on the sale and missing there never reaches the kitchen. The test asserts the rule rather than the spelling: every place that carries the note carries the level. Also fixes a bug found on the way. 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. nutrition, tags, marks and claims have been sent by the server since the dish-facts release and all four stopped at that literal, so /order drew no numbers, no badges and no marks and its "Good for" filter had nothing to offer, while /menu showed all of it. The test asks the server what a dish carries rather than naming the fields, so a fifth fact fails until the page is told to keep it. --- api/src/models/item.model.js | 12 + api/src/repositories/item.repository.js | 4 + api/src/repositories/sale.repository.js | 53 +++- api/src/utils/spice-level.js | 104 ++++++++ api/tests/unit/models/item.model.test.js | 2 +- .../services/customer-order.service.test.js | 4 +- .../unit/the-kitchen-reads-how-hot.test.js | 138 +++++++++++ api/tests/unit/utils/spice-level.test.js | 99 ++++++++ frontend/modules/items_write.html | 13 + frontend/static/script/js/modules/js/items.js | 17 +- .../script/js/modules/js/online_orders.js | 30 +++ languages/_english.json | 6 + languages/ar.json | 6 + languages/de.json | 6 + languages/es.json | 6 + languages/fr.json | 6 + languages/hi.json | 6 + languages/id.json | 6 + languages/it.json | 6 + languages/kn.json | 6 + languages/ml.json | 6 + languages/ne.json | 6 + languages/nl.json | 6 + languages/pt.json | 6 + languages/si.json | 6 + languages/sw.json | 6 + languages/ta.json | 6 + languages/te.json | 6 + languages/th.json | 6 + menu/i18n.js | 7 + order/assets/cart/script.js | 44 ++++ order/assets/i18n.js | 7 + order/assets/kiosk-core.js | 4 + order/assets/order.css | 82 +++++++ order/assets/products/script.js | 63 ++++- order/assets/spice.js | 160 +++++++++++++ order/cart.html | 4 + order/indexedDB.js | 72 +++++- order/products.html | 5 + src/escpos-kot.js | 39 ++- src/kot-manager.js | 12 +- tests/how-hot-the-customer-asks.test.js | 226 ++++++++++++++++++ tests/the-ticket-says-how-hot.test.js | 204 ++++++++++++++++ 43 files changed, 1505 insertions(+), 8 deletions(-) create mode 100644 api/src/utils/spice-level.js create mode 100644 api/tests/unit/the-kitchen-reads-how-hot.test.js create mode 100644 api/tests/unit/utils/spice-level.test.js create mode 100644 order/assets/spice.js create mode 100644 tests/how-hot-the-customer-asks.test.js create mode 100644 tests/the-ticket-says-how-hot.test.js 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'); +});