Files
menu/test/sections.test.ts
T
tanshu c31b73d553 Let the owner manage categories, and give the menus a shelf
Categories were baked into config/<site>.site.json, so adding one meant a
rebuild and a redeploy. They are now data: data/sections.json, seeded from the
config the first time, and managed in the admin (add, rename, reorder, remove).
A category's web address is fixed when it is created because that is what a QR
code points at, and a category cannot be removed while it still holds menus.

The landing and the top bar were the ugliest parts. The landing was a column of
hairlines with tiny meta — a broadsheet, and the paper that makes this app worth
looking at was nowhere on it. It is now a shelf: each category is a row whose
right edge is a sliver of that menu's first page, bleeding off the screen, with
the shelf edge in brass and the name in heavy tight Archivo. The bar was a row
of teal pills that would not survive more categories: it now names the menu you
are reading and opens a sheet listing the rest, each with its own paper sliver,
and the current one is the only teal thing on screen. The sheet is a link to the
landing page, so navigating still works without the script.

Also fixes the category manager's own layout, which I shipped unstyled, and the
markup contract the route tests described.
2026-09-22 22:24:53 +05:30

159 lines
5.4 KiB
TypeScript

import { strict as assert } from 'node:assert';
import { readFile, rm, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { afterEach, beforeEach, describe, it } from 'node:test';
import { SectionError, SectionStore, slugify } from '../src/storage/sections.ts';
import { tempDir } from './support/harness.ts';
const SEED = [
{ slug: 'food', label: 'Food', shortLabel: 'Food', note: null, order: 1 },
{ slug: 'bar', label: 'Bar', shortLabel: 'Bar', note: null, order: 2 },
];
let dataDir: string;
beforeEach(async () => {
dataDir = await tempDir('sections');
});
afterEach(async () => {
await rm(dataDir, { recursive: true, force: true });
});
async function loaded(seed = SEED) {
const store = new SectionStore({ dataDir, seed });
await store.load();
return store;
}
describe('slugify', () => {
it('turns the labels people type into stable web addresses', () => {
assert.equal(slugify('Straight Drinks'), 'straight-drinks');
assert.equal(slugify("What's On"), 'whats-on');
assert.equal(slugify('Desserts & Coffee'), 'desserts-coffee');
assert.equal(slugify(' Bar '), 'bar');
assert.equal(slugify('Wine / Beer'), 'wine-beer');
assert.equal(slugify('----'), '');
});
});
describe('SectionStore', () => {
it('seeds from the config the first time and writes the file', async () => {
const store = await loaded();
assert.deepEqual(
store.list().map((section) => section.slug),
['food', 'bar'],
);
const file = JSON.parse(await readFile(path.join(dataDir, 'sections.json'), 'utf8'));
assert.equal(file.sections.length, 2);
});
it('keeps the file as the source of truth afterwards, ignoring the seed', async () => {
await loaded();
const second = new SectionStore({ dataDir, seed: [] });
await second.load();
assert.deepEqual(
second.list().map((section) => section.slug),
['food', 'bar'],
);
});
it('refuses to serve an unreadable file rather than silently reseeding', async () => {
await writeFile(path.join(dataDir, 'sections.json'), '{ not json');
const store = new SectionStore({ dataDir, seed: SEED });
await assert.rejects(() => store.load(), SectionError);
});
it('complains if it is read before loading', () => {
const store = new SectionStore({ dataDir, seed: SEED });
assert.throws(() => store.list(), /load\(\)/);
});
it('adds a category at the end, with a slug from the name', async () => {
const store = await loaded();
const created = await store.create({ label: 'Straight Drinks' });
assert.equal(created.slug, 'straight-drinks');
assert.equal(created.order, 3);
assert.deepEqual(
store.list().map((section) => section.label),
['Food', 'Bar', 'Straight Drinks'],
);
});
it('makes a slug unique, and steers around the names the app reserves', async () => {
const store = await loaded();
await store.create({ label: 'Desserts' });
assert.equal((await store.create({ label: 'Desserts' })).slug, 'desserts-2');
assert.equal((await store.create({ label: 'Admin' })).slug, 'admin-2');
assert.equal((await store.create({ label: 'Media' })).slug, 'media-2');
});
it('rejects an empty name and a name that makes no web address', async () => {
const store = await loaded();
await assert.rejects(() => store.create({ label: ' ' }), SectionError);
await assert.rejects(() => store.create({ label: '!!!' }), SectionError);
});
it('renames without moving the web address customers already have', async () => {
const store = await loaded();
const renamed = await store.rename('bar', { label: 'Bar & Cocktails' });
assert.equal(renamed.label, 'Bar & Cocktails');
assert.equal(renamed.slug, 'bar');
assert.equal(store.bySlug('bar')?.label, 'Bar & Cocktails');
});
it('moves a category and renumbers the rest', async () => {
const store = await loaded();
await store.move('bar', 'up');
assert.deepEqual(
store.list().map((section) => section.slug),
['bar', 'food'],
);
assert.deepEqual(
store.list().map((section) => section.order),
[1, 2],
);
// Past either end is a no-op, not an error.
await store.move('bar', 'up');
assert.deepEqual(
store.list().map((section) => section.slug),
['bar', 'food'],
);
});
it('removes a category and closes the gap in the order', async () => {
const store = await loaded();
await store.create({ label: 'Wine' });
await store.remove('bar');
assert.deepEqual(
store.list().map((section) => [section.slug, section.order]),
[
['food', 1],
['wine', 2],
],
);
});
it('reports an unknown slug rather than inventing one', async () => {
const store = await loaded();
await assert.rejects(() => store.rename('pizza', { label: 'Pizza' }), SectionError);
await assert.rejects(() => store.remove('pizza'), SectionError);
});
it('survives add, rename, move and remove being read by a second instance', async () => {
const store = await loaded();
const created = await store.create({ label: 'Desserts' });
await store.rename(created.slug, { label: 'Puddings' });
await store.move(created.slug, 'up');
const reopened = new SectionStore({ dataDir, seed: [] });
await reopened.load();
assert.deepEqual(
reopened.list().map((section) => section.label),
['Food', 'Puddings', 'Bar'],
);
});
});