Pantry: barcode scanning with several barcodes per item, a Take one screen for everyone including children, and no longer tied to Mealie
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a7483164b8
commit
ac55520a8c
15 files changed
+520
-62
No files matched your search
@@ -32,7 +32,7 @@ for (const f of listed) {
|
||||
}
|
||||
assert.deepEqual(failures, [], 'every script loads:\n ' + failures.join('\n '));
|
||||
// the pieces the app calls by name across files are there
|
||||
for (const name of ['route', 'viewHome', 'viewSettings', 'viewTasks', 'viewCalendar', 'viewBible', 'viewMail', 'viewRecipes', 'viewVideo', 'themeApply', 'autoSettings', 'bbToday', 'homeListening', 'saveToFiles', 'downloadText']) assert.equal(typeof vm.runInContext(`typeof ${name}`, ctx), 'string') && assert.equal(vm.runInContext(`typeof ${name}`, ctx), 'function', name + ' is defined');
|
||||
for (const name of ['route', 'viewHome', 'viewSettings', 'viewTasks', 'viewCalendar', 'viewBible', 'viewMail', 'viewRecipes', 'viewVideo', 'themeApply', 'autoSettings', 'bbToday', 'homeListening', 'saveToFiles', 'downloadText', 'viewPantry', 'pantryScan']) assert.equal(typeof vm.runInContext(`typeof ${name}`, ctx), 'string') && assert.equal(vm.runInContext(`typeof ${name}`, ctx), 'function', name + ' is defined');
|
||||
// the watch-stats counting (a pure function)
|
||||
{ const now = Date.UTC(2026, 5, 15, 12), at = d => Math.floor((now - d * 864e5) / 1000), agg = vm.runInContext('vdAgg', ctx);
|
||||
const a = agg([{ lastViewedAt: at(1), duration: 6e6, Genre: [{ tag: 'Drama' }, { tag: 'Comedy' }] }, { lastViewedAt: at(3), duration: 3e6, Genre: [{ tag: 'Drama' }] }, { lastViewedAt: at(20), duration: 6e6 }, { lastViewedAt: at(200), duration: 6e6, Genre: [{ tag: 'Drama' }] }, { lastViewedAt: at(400), duration: 6e6 }, { duration: 6e6 }], now);
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// The pantry: items with counts and low levels, several barcodes per item (corn from different brands is one item), product lookup (a stand-in for Open Food Facts), and what happens when a count goes low.
|
||||
const assert = require('assert'), fs = require('fs'), os = require('os'), path = require('path'), http = require('http');
|
||||
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-pantry-'));
|
||||
let asked = [], down = false;
|
||||
const off = http.createServer((req, res) => { const code = /product\/(\d+)\.json/.exec(req.url)[1]; asked.push(code); if (down) { res.destroy(); return; }
|
||||
const P = { '0016000275287': { product_name: 'Green Giant Sweet Corn 15.25 oz', brands: 'Green Giant', quantity: '15.25 oz' }, '0024000163275': { product_name: "Libby's Whole Kernel Corn 425 g", brands: "Libby's" }, '0070470003344': { product_name: 'Greek Yogurt', brands: 'Chobani' } };
|
||||
res.setHeader('content-type', 'application/json'); res.end(JSON.stringify(P[code] ? { status: 1, product: P[code] } : { status: 0 })); }).listen(0, () => {
|
||||
process.env.OPENFOODFACTS_URL = 'http://127.0.0.1:' + off.address().port;
|
||||
const users = require('../users'), pantry = require('../pantry'), automations = require('../automations');
|
||||
const ann = users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), bo = users.create({ name: 'Bo', role: 'user', tabs: ['recipes'], handle: 'ZP' }), kid = users.create({ name: 'Kit', role: 'user', tabs: ['recipes'], handle: 'KD' }); kid.kid = { on: true };
|
||||
const hub = http.createServer((req, res) => users.runAs(req.headers['x-user'] === 'bo' ? bo : req.headers['x-user'] === 'kid' ? kid : ann, () => pantry.handle(req, res))).listen(0, async () => {
|
||||
const base = 'http://127.0.0.1:' + hub.address().port + '/api/pantry', j = r => r.json(), get = p => fetch(base + p), post = (p, o, who) => fetch(base + p, { method: 'POST', body: JSON.stringify(o), headers: who ? { 'x-user': who } : {} });
|
||||
const fired = []; const realFireAll = automations.fireAll; automations.fireAll = async (t, c) => { fired.push(t + ':' + c.title); };
|
||||
try {
|
||||
// names are cleaned from products
|
||||
assert.equal(pantry.shortName('Heinz Tomato Ketchup 500 ml', 'Heinz'), 'Tomato Ketchup'); assert.equal(pantry.shortName('Green Giant Sweet Corn 15.25 oz', 'Green Giant'), 'Sweet Corn'); assert.equal(pantry.shortName('', ''), '');
|
||||
// items: add, count, change by the same name, adjust, low levels
|
||||
assert.equal((await post('/item', { name: ' ' })).status, 400);
|
||||
let r = await j(await post('/item', { name: 'Corn', qty: 2, low: 1 })); assert.deepEqual(r.items.map(x => [x.name, x.qty, x.low, x.isLow, x.codes.length]), [['Corn', 2, 1, false, 0]]); const corn = r.items[0].id;
|
||||
r = await j(await post('/item', { name: 'corn', qty: 2, low: 1 })); assert.equal(r.items.length, 1, 'the same name updates the item'); assert.equal(r.items[0].name, 'Corn');
|
||||
r = await j(await post('/adjust', { id: corn, delta: -1 })); assert.equal(r.items[0].isLow, true); assert.deepEqual(fired, ['pantry.low:Corn'], 'going low starts the rules, once'); await post('/adjust', { id: corn, delta: -1 }); assert.equal(fired.length, 1, 'and not again while it stays low');
|
||||
assert.equal((await j(await post('/adjust', { id: corn, delta: -9 }))).items[0].qty, 0, 'never below none'); await post('/adjust', { id: corn, delta: 5 });
|
||||
// a barcode we have not seen: the product is looked up, and the items it might be are offered
|
||||
assert.equal((await get('/lookup?code=abc')).status, 400);
|
||||
r = await j(await get('/lookup?code=0016000275287')); assert.equal(r.linked, null); assert.equal(r.product.short, 'Sweet Corn'); assert.deepEqual(r.matches.map(m => m.name), ['Corn'], 'Corn is offered, since the product has the word corn');
|
||||
assert.equal((await post('/scan', { code: '0016000275287' })).status, 400, 'a new barcode must say which item it is');
|
||||
// scanning links it to the chosen item and adds one
|
||||
r = await j(await post('/scan', { code: '0016000275287', itemId: corn })); assert.equal(r.item.name, 'Corn'); assert.equal(r.item.qty, 6); assert.equal(r.made, false);
|
||||
asked = []; r = await j(await get('/lookup?code=0016000275287')); assert.deepEqual(r.linked, { id: corn, name: 'Corn' }, 'next time it goes straight to Corn'); assert.deepEqual(asked, [], 'without asking the product database again');
|
||||
r = await j(await post('/scan', { code: '0016000275287' })); assert.equal(r.item.qty, 7);
|
||||
// a second brand is the same item, with one count
|
||||
r = await j(await post('/scan', { code: '0024000163275', itemId: corn })); assert.equal(r.items.length, 1, 'still one item'); assert.equal(r.items[0].qty, 8); assert.equal(r.items[0].codes.length, 2, 'with two barcodes');
|
||||
// use one
|
||||
r = await j(await post('/scan', { code: '0024000163275', mode: 'use' })); assert.equal(r.item.qty, 7);
|
||||
// a new item made from a scan; the same name later finds it; products the database does not know are named by hand
|
||||
r = await j(await post('/scan', { code: '0070470003344', name: 'Yogurt' })); assert.equal(r.made, true); assert.equal(r.item.qty, 1); r = await j(await get('/lookup?code=1234567890128')); assert.equal(r.product, null); assert.equal(r.lookedUp, true);
|
||||
r = await j(await post('/scan', { code: '1234567890128', name: 'yogurt' })); assert.equal(r.made, false, 'a name that exists links to it'); assert.equal(r.item.qty, 2);
|
||||
// using one that takes it to its low level fires the rules
|
||||
fired.length = 0; await post('/item', { id: corn, name: 'Corn', qty: 3, low: 2 }); await post('/scan', { code: '0016000275287', mode: 'use' }); assert.deepEqual(fired, ['pantry.low:Corn']);
|
||||
// the database being down is not an error for the person: they just name it
|
||||
down = true; r = await j(await get('/lookup?code=5000112637922')); assert.equal(r.lookedUp, false); assert.equal(r.product, null); down = false;
|
||||
// moving a barcode to another item takes it off the first; unlinking
|
||||
const yog = (await j(await get('/'))).items.find(x => x.name === 'Yogurt').id; r = await j(await post('/scan', { code: '0016000275287', itemId: yog })); assert.equal((await j(await get('/lookup?code=0016000275287'))).linked.name, 'Corn', 'a linked barcode stays with its item');
|
||||
r = await j(await post('/unlink', { id: corn, code: '0016000275287' })); assert.equal(r.items.find(x => x.id === corn).codes.length, 1);
|
||||
assert.equal((await post('/delete', { id: yog })).status, 200); assert.ok(!(await j(await get('/'))).items.some(x => x.name === 'Yogurt'));
|
||||
// the simple way: take one (children too), with undo, and a note of who took what
|
||||
r = await j(await post('/item', { name: 'Granola bars', qty: 2, low: 1 })); const gb = r.items.find(x => x.name === 'Granola bars').id; fired.length = 0;
|
||||
r = await j(await post('/use', { id: gb }, 'kid')); assert.equal(r.item.qty, 1); assert.equal(r.item.isLow, true); assert.deepEqual(fired, ['pantry.low:Granola bars'], 'taking the second-to-last one starts the low rule'); assert.equal(r.recent[0].who, 'Kit'); assert.equal(r.recent[0].item, 'Granola bars');
|
||||
r = await j(await post('/use', { id: gb, undo: true }, 'kid')); assert.equal(r.item.qty, 2, 'undo puts it back'); await post('/use', { id: gb }, 'kid'); await post('/use', { id: gb }, 'kid'); assert.equal((await post('/use', { id: gb }, 'kid')).status, 409, 'none left to take');
|
||||
const note = (await j(await post('/item', { name: 'Napkins' }))).items.find(x => x.name === 'Napkins').id; r = await j(await post('/use', { id: note })); assert.equal(r.item.qty, null, 'an item that is not counted is only noted');
|
||||
// a child can look and take, and nothing else
|
||||
for (const [p, o] of [['/item', { name: 'Candy', qty: 9 }], ['/adjust', { id: gb, delta: 5 }], ['/delete', { id: gb }], ['/unlink', { id: gb, code: '1' }], ['/scan', { code: '0099999999999', mode: 'add', name: 'Candy' }]]) assert.equal((await post(p, o, 'kid')).status, 403, p + ' is for grown-ups');
|
||||
assert.equal((await get('/')).status, 200); assert.equal((await j(await fetch(base + '/', { headers: { 'x-user': 'kid' } }))).kid, true);
|
||||
r = await j(await post('/scan', { code: '0016000275287', itemId: corn })); const before = r.item.qty; r = await j(await post('/scan', { code: '0016000275287', mode: 'use' }, 'kid')); assert.equal(r.item.qty, before - 1, 'a child can scan a known barcode to take one'); assert.equal((await post('/scan', { code: '0555555555555', mode: 'use', name: 'Mystery' }, 'kid')).status, 403, 'but not make new items');
|
||||
// the pantry is shared, and old pantry files (a list of names) still load
|
||||
assert.equal((await j(await get('/', 'bo'))).items.length >= 1, true); fs.writeFileSync(path.join(process.env.DATA_DIR, 'pantry.json'), JSON.stringify(['Flour', 'Salt'])); r = await j(await get('/')); assert.deepEqual(r.items.map(x => x.name), ['Flour', 'Salt']); assert.equal(r.items[0].qty, null);
|
||||
assert.deepEqual(pantry.inPantry('3 cups all-purpose flour', ['Flour', 'Salt']), ['Flour']);
|
||||
console.log('pantry test ok');
|
||||
} catch (e) { console.error(e); process.exitCode = 1; }
|
||||
automations.fireAll = realFireAll; hub.close(); off.close();
|
||||
});
|
||||
});
|
||||
@@ -62,16 +62,12 @@ const fake = http.createServer((req, res) => {
|
||||
j = await (await post('/plan/shop', { from: '2027-03-02', to: '2027-03-08' })).json(); assert.equal(j.ok, true); assert.equal(j.recipes, 1, 'a recipe planned twice is added once, twice over');
|
||||
assert.equal((await post('/plan/shop', { from: '2027-04-01', to: '2027-04-07' })).status, 400, 'nothing planned, nothing to add');
|
||||
await post('/plan/remove', { id: (await (await get('/plan?from=2027-03-02&to=2027-03-02')).json())['2027-03-02'][0].id }); assert.deepEqual(await (await get('/plan?from=2027-03-02&to=2027-03-02')).json(), {});
|
||||
// the pantry: items with a count and a low level; the plan flags planned ingredients you have ("3 cups flour" is covered by "flour"; butter is not in the pantry)
|
||||
await post('/plan/add', { date: '2027-05-03', slug: 'bread', name: 'Bread' });
|
||||
assert.equal((await post('/pantry/item', { name: ' ' })).status, 400, 'an item needs a name');
|
||||
j = await (await post('/pantry/item', { name: 'Flour', qty: 3, low: 1 })).json(); assert.deepEqual(j.items.map(x => [x.name, x.qty, x.low, x.isLow]), [['Flour', 3, 1, false]]); const fid = j.items[0].id;
|
||||
j = await (await post('/pantry/item', { name: 'salt', qty: '', low: '' })).json(); assert.equal(j.items.find(x => x.name === 'salt').qty, null, 'an item need not be counted'); assert.equal(j.items.find(x => x.name === 'salt').isLow, false);
|
||||
j = await (await post('/pantry/item', { name: 'FLOUR', qty: 3, low: 1 })).json(); assert.equal(j.items.filter(x => x.name.toLowerCase() === 'flour').length, 1, 'the same name updates the item');
|
||||
// the pantry check: a planned ingredient the pantry has ("3 cups flour" is covered by "flour"; butter is not in the pantry, and an empty one is not "had")
|
||||
await post('/plan/add', { date: '2027-05-03', slug: 'bread', name: 'Bread' }); const pf = path.join(process.env.DATA_DIR, 'pantry.json');
|
||||
fs.writeFileSync(pf, JSON.stringify([{ id: 'a1', name: 'Flour', qty: 3, low: 1, codes: [] }, { id: 'a2', name: 'Salt', qty: null, low: null, codes: [] }]));
|
||||
j = await (await get('/plan/pantry?from=2027-05-03&to=2027-05-09')).json(); assert.deepEqual(j.items.map(i => i.text), ['3 cups flour']); assert.equal(j.items[0].have.toLowerCase(), 'flour');
|
||||
j = await (await post('/pantry/adjust', { id: fid, delta: -2 })).json(); assert.equal(j.items[0].qty, 1); assert.equal(j.items[0].isLow, true, 'at the low level counts as running low'); assert.equal(j.items[0].name, 'Flour', 'low ones come first');
|
||||
j = await (await post('/pantry/adjust', { id: fid, delta: -5 })).json(); assert.equal(j.items[0].qty, 0, 'never below none'); assert.deepEqual((await (await get('/plan/pantry?from=2027-05-03&to=2027-05-09')).json()).items, [], 'an empty one is not "had"');
|
||||
await post('/pantry/delete', { id: fid }); j = await (await get('/pantry')).json(); assert.deepEqual(j.items.map(x => x.name), ['salt']);
|
||||
fs.writeFileSync(pf, JSON.stringify([{ id: 'a1', name: 'Flour', qty: 0, low: 1, codes: [] }])); assert.deepEqual((await (await get('/plan/pantry?from=2027-05-03&to=2027-05-09')).json()).items, [], 'an empty one is not "had"');
|
||||
fs.writeFileSync(pf, '[]'); assert.deepEqual((await (await get('/plan/pantry?from=2027-05-03&to=2027-05-09')).json()).items, [], 'an empty pantry flags nothing');
|
||||
// a wrong token is explained
|
||||
process.env.MEALIE_TOKEN = 'bad'; r = await get('/'); assert.equal(r.status, 502); assert.match((await r.json()).error, /token/);
|
||||
process.env.MEALIE_TOKEN = ''; assert.equal((await get('/')).status, 503);
|
||||
|
||||
Reference in new issue
Block a user