Each colony can now hold its hive setup (Ist-Zustand) as validated JSON with an append-only change log: - catalog: hive systems and part types with dimensions, placement rules and state fields; Liebig-Zander 10 seeded with fixed ids (owner's equipment incl. Bausperre, Futterzarge, Nassenheider) - pure domain module (operations, validator, derived facts) with shared JSON fixtures the frontend runs too - API: catalog (hive-systems:manage), setup create/change/history/ state at a day (colonies:read/update), 409 HIVE_CONFLICT with the current state, idempotent writes, HiveChanged event - inspections can carry hive ops saved in the same transaction - backup export/import of the new tables (backup wins per colony) - docs: ARCHITECTURE section 13, Annahmen 95-100 Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
115 lines
3.6 KiB
TypeScript
115 lines
3.6 KiB
TypeScript
import { readdirSync, readFileSync } from 'fs';
|
|
import { join } from 'path';
|
|
import { applyHiveOps } from './hive-ops';
|
|
import { validateHiveState } from './hive-validate';
|
|
import type { HiveOp, HiveState } from './hive-types';
|
|
import { FIXTURE_DIR, deepFreeze, loadFixtureCatalog } from './hive-test-utils';
|
|
|
|
interface FixtureCase {
|
|
name: string;
|
|
inactivePartTypeIds?: string[];
|
|
state: HiveState;
|
|
ops: HiveOp[];
|
|
expect:
|
|
| { ok: true; state: HiveState }
|
|
| { ok: false; opIndex: number; reasonCode: string };
|
|
}
|
|
|
|
const cases = readdirSync(FIXTURE_DIR)
|
|
.filter((file) => file.endsWith('.json') && file !== 'catalog.json')
|
|
.sort()
|
|
.map((file) => ({
|
|
file,
|
|
data: JSON.parse(
|
|
readFileSync(join(FIXTURE_DIR, file), 'utf8'),
|
|
) as FixtureCase,
|
|
}));
|
|
|
|
function findCase(name: string): FixtureCase {
|
|
const found = cases.find((c) => c.data.name === name);
|
|
if (!found) throw new Error(`fixture not found: ${name}`);
|
|
return found.data;
|
|
}
|
|
|
|
describe('applyHiveOps fixtures', () => {
|
|
it('has at least 20 cases', () => {
|
|
expect(cases.length).toBeGreaterThanOrEqual(20);
|
|
});
|
|
|
|
it.each(cases.map((c) => [c.file, c.data] as const))('%s', (_file, data) => {
|
|
const catalog = loadFixtureCatalog(data.inactivePartTypeIds);
|
|
const result = applyHiveOps(
|
|
deepFreeze(structuredClone(data.state)),
|
|
deepFreeze(structuredClone(data.ops)),
|
|
catalog,
|
|
);
|
|
if (data.expect.ok) {
|
|
expect(result).toEqual({ ok: true, state: data.expect.state });
|
|
// The expected end state must itself be valid.
|
|
expect(validateHiveState(data.expect.state, catalog)).toEqual([]);
|
|
} else {
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) {
|
|
expect(result.opIndex).toBe(data.expect.opIndex);
|
|
expect(result.reason.split(':')[0]).toBe(data.expect.reasonCode);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('applyHiveOps purity', () => {
|
|
it('does not mutate a deep-frozen input state or ops', () => {
|
|
const catalog = loadFixtureCatalog();
|
|
const sample = findCase('multiple ops apply in order');
|
|
const state = deepFreeze(structuredClone(sample.state));
|
|
const ops = deepFreeze(structuredClone(sample.ops));
|
|
const before = JSON.stringify(state);
|
|
const result = applyHiveOps(state, ops, catalog);
|
|
expect(result.ok).toBe(true);
|
|
expect(JSON.stringify(state)).toBe(before);
|
|
});
|
|
|
|
it('does not share references between input and result', () => {
|
|
const catalog = loadFixtureCatalog();
|
|
const state = structuredClone(
|
|
findCase('setLabel sets and clears a label').state,
|
|
);
|
|
const result = applyHiveOps(state, [], catalog);
|
|
expect(result.ok).toBe(true);
|
|
if (result.ok) {
|
|
expect(result.state).not.toBe(state);
|
|
expect(result.state.stack[0]).not.toBe(state.stack[0]);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('validateHiveState', () => {
|
|
const catalog = loadFixtureCatalog();
|
|
|
|
it('rejects non-objects and wrong schemaVersion', () => {
|
|
expect(validateHiveState(null, catalog).length).toBeGreaterThan(0);
|
|
expect(
|
|
validateHiveState({ schemaVersion: 2, stack: [] }, catalog).length,
|
|
).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('collects several violations', () => {
|
|
const state = {
|
|
schemaVersion: 1,
|
|
stack: [
|
|
{ id: 'a', partTypeId: 'hive-part-zl10-roof', state: {} },
|
|
{ id: 'a', partTypeId: 'hive-part-zl10-floor', state: { x: 1 } },
|
|
],
|
|
};
|
|
const codes = validateHiveState(state, catalog).map((r) => r.split(':')[0]);
|
|
expect(codes).toEqual(
|
|
expect.arrayContaining([
|
|
'DUPLICATE_ID',
|
|
'RULE_FLOOR_BOTTOM',
|
|
'RULE_ROOF_TOP',
|
|
'STATE_UNKNOWN_KEY',
|
|
]),
|
|
);
|
|
});
|
|
});
|