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.
159 lines
5.4 KiB
TypeScript
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'],
|
|
);
|
|
});
|
|
});
|