Before this change, org-template.store.ts held the action lifecycle in an actionState signal and the publish impact-confirm gate in an independent pendingPublish signal. The two were representable in combination, so pendingPublish === true and busy === true could both hold at once. That state was meaningless: the UI would show the publish-impact confirmation while a publish was already in flight. OrgTemplateState.Loaded now carries one action field, a four-variant union (Idle | ConfirmingPublish | Busy | Failed). ActionStarted overwrites the field straight to Busy from any prior tag, so ConfirmingPublish and Busy can never coexist — not by convention, but because one field can only hold one tag. requestPublish and cancelPublish become dispatches (PublishRequested/PublishCancelled); as the reducer already no-ops outside Loaded, this changes no behaviour. The other four commands (confirmPublish, rollback, proefbrief, flushSave) keep their existing loaded() guards. busy, lastError and pendingPublish stay on the store as computed values reading the new union, with byte-identical public signatures — no file under brief/ui/ changes. Ran gen:behaviour-spec for the six new reducer cases. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
204 lines
7.0 KiB
TypeScript
204 lines
7.0 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { expectTag } from '@shared/testing/expect-tag';
|
|
import { OrgTemplate, OrgTemplateAdminView } from './org-template';
|
|
import { OrgTemplateState, reduce } from './org-template.machine';
|
|
import { DocumentCategory } from '@shared/domain/upload.machine';
|
|
|
|
const template: OrgTemplate = {
|
|
subOrgId: 'cibg-registers',
|
|
orgName: 'CIBG',
|
|
returnAddress: 'Postbus 1\n2500 AA Den Haag',
|
|
footerContact: 'info@cibg.nl',
|
|
footerLegal: 'CIBG is onderdeel van VWS',
|
|
signatureName: 'A. de Vries',
|
|
signatureRole: 'Hoofd Registratie',
|
|
signatureClosing: 'Met vriendelijke groet,',
|
|
margins: { topMm: 25, rightMm: 20, bottomMm: 25, leftMm: 20 },
|
|
version: 3,
|
|
};
|
|
|
|
const view = (over: Partial<OrgTemplateAdminView> = {}): OrgTemplateAdminView => ({
|
|
draft: template,
|
|
publishedVersion: 3,
|
|
history: [],
|
|
unsentBriefs: 2,
|
|
...over,
|
|
});
|
|
|
|
const loaded = (): OrgTemplateState =>
|
|
reduce({ tag: 'Loading' }, { tag: 'DraftLoaded', view: view() });
|
|
|
|
const logoCategory: DocumentCategory = {
|
|
categoryId: 'org-logo',
|
|
label: 'Logo',
|
|
description: '',
|
|
required: false,
|
|
acceptedTypes: ['image/png'],
|
|
maxSizeMb: 2,
|
|
multiple: false,
|
|
allowPostDelivery: false,
|
|
};
|
|
|
|
describe('org-template.machine', () => {
|
|
it('DraftLoaded moves to loaded with the draft, clean', () => {
|
|
const s = expectTag(loaded(), 'Loaded');
|
|
expect(s.draft.orgName).toBe('CIBG');
|
|
expect(s.subOrgId).toBe('cibg-registers');
|
|
expect(s.unsentBriefs).toBe(2);
|
|
expect(s.dirty).toBe(false);
|
|
});
|
|
|
|
it('LoadFailed carries the reason', () => {
|
|
const s = reduce({ tag: 'Loading' }, { tag: 'LoadFailed', reason: 'boom' });
|
|
expect(s).toEqual({ tag: 'Failed', reason: 'boom' });
|
|
});
|
|
|
|
it('FieldEdited edits the draft and marks dirty', () => {
|
|
const s = expectTag(
|
|
reduce(loaded(), { tag: 'FieldEdited', field: 'orgName', value: 'CIBG Nieuw' }),
|
|
'Loaded',
|
|
);
|
|
expect(s.draft.orgName).toBe('CIBG Nieuw');
|
|
expect(s.dirty).toBe(true);
|
|
});
|
|
|
|
it('MarginEdited edits one edge and marks dirty', () => {
|
|
const s = expectTag(
|
|
reduce(loaded(), { tag: 'MarginEdited', edge: 'topMm', value: 40 }),
|
|
'Loaded',
|
|
);
|
|
expect(s.draft.margins.topMm).toBe(40);
|
|
expect(s.draft.margins.leftMm).toBe(20);
|
|
expect(s.dirty).toBe(true);
|
|
});
|
|
|
|
it('DraftSaved clears dirty when the saved draft is the current one', () => {
|
|
const edited = expectTag(
|
|
reduce(loaded(), { tag: 'FieldEdited', field: 'orgName', value: 'X' }),
|
|
'Loaded',
|
|
);
|
|
const s = expectTag(reduce(edited, { tag: 'DraftSaved', savedDraft: edited.draft }), 'Loaded');
|
|
expect(s.dirty).toBe(false);
|
|
expect(s.draft.orgName).toBe('X');
|
|
});
|
|
|
|
it('DraftSaved keeps dirty when an edit landed during the save round-trip', () => {
|
|
const editing = expectTag(
|
|
reduce(loaded(), { tag: 'FieldEdited', field: 'orgName', value: 'X' }),
|
|
'Loaded',
|
|
);
|
|
const savedDraft = editing.draft;
|
|
// a further edit changes the draft reference before the save resolves
|
|
const raced = reduce(editing, { tag: 'FieldEdited', field: 'orgName', value: 'Y' });
|
|
const s = expectTag(reduce(raced, { tag: 'DraftSaved', savedDraft }), 'Loaded');
|
|
expect(s.dirty).toBe(true);
|
|
});
|
|
|
|
it('edits are no-ops in non-loaded states', () => {
|
|
expect(
|
|
reduce({ tag: 'Loading' }, { tag: 'FieldEdited', field: 'orgName', value: 'x' }),
|
|
).toEqual({
|
|
tag: 'Loading',
|
|
});
|
|
});
|
|
|
|
it('a completed logo upload sets logoDocumentId + dirty', () => {
|
|
const withCat = reduce(loaded(), {
|
|
tag: 'Upload',
|
|
msg: { type: 'CategoriesLoaded', categories: [logoCategory] },
|
|
});
|
|
const selected = reduce(withCat, {
|
|
tag: 'Upload',
|
|
msg: {
|
|
type: 'FileSelected',
|
|
categoryId: 'org-logo',
|
|
localId: 'a',
|
|
fileName: 'l.png',
|
|
fileSizeMb: 0.1,
|
|
},
|
|
});
|
|
const done = expectTag(
|
|
reduce(selected, {
|
|
tag: 'Upload',
|
|
msg: { type: 'UploadComplete', localId: 'a', documentId: 'doc-1' },
|
|
}),
|
|
'Loaded',
|
|
);
|
|
expect(done.draft.logoDocumentId).toBe('doc-1');
|
|
expect(done.dirty).toBe(true);
|
|
});
|
|
|
|
it('removing the logo clears logoDocumentId + dirty', () => {
|
|
const withLogo = reduce(loaded(), {
|
|
tag: 'Upload',
|
|
msg: { type: 'UploadComplete', localId: 'a', documentId: 'doc-1' },
|
|
});
|
|
const removed = expectTag(
|
|
reduce(withLogo, {
|
|
tag: 'Upload',
|
|
msg: { type: 'UploadRemoved', localId: 'a' },
|
|
}),
|
|
'Loaded',
|
|
);
|
|
expect(removed.draft.logoDocumentId).toBeUndefined();
|
|
expect(removed.dirty).toBe(true);
|
|
});
|
|
|
|
it('DraftLoaded (sub-org switch) keeps the loaded logo category, drops uploads', () => {
|
|
const withCat = reduce(loaded(), {
|
|
tag: 'Upload',
|
|
msg: { type: 'CategoriesLoaded', categories: [logoCategory] },
|
|
});
|
|
const switched = expectTag(
|
|
reduce(withCat, {
|
|
tag: 'DraftLoaded',
|
|
view: view({ draft: { ...template, subOrgId: 'cibg-vakbekwaamheid' } }),
|
|
}),
|
|
'Loaded',
|
|
);
|
|
expect(switched.upload.categories).toHaveLength(1);
|
|
expect(switched.upload.uploads).toHaveLength(0);
|
|
expect(switched.subOrgId).toBe('cibg-vakbekwaamheid');
|
|
});
|
|
|
|
// --- the action lifecycle + publish impact-confirm gate, folded into one union (RD-13) ---
|
|
|
|
it('PublishRequested moves a loaded template to ConfirmingPublish', () => {
|
|
const s = expectTag(reduce(loaded(), { tag: 'PublishRequested' }), 'Loaded');
|
|
expect(s.action).toEqual({ tag: 'ConfirmingPublish' });
|
|
});
|
|
|
|
it('PublishCancelled returns to Idle', () => {
|
|
const confirming = reduce(loaded(), { tag: 'PublishRequested' });
|
|
const s = expectTag(reduce(confirming, { tag: 'PublishCancelled' }), 'Loaded');
|
|
expect(s.action).toEqual({ tag: 'Idle' });
|
|
});
|
|
|
|
it('ActionStarted from ConfirmingPublish goes to Busy, so confirming and busy cannot coexist', () => {
|
|
const confirming = expectTag(reduce(loaded(), { tag: 'PublishRequested' }), 'Loaded');
|
|
expect(confirming.action.tag).toBe('ConfirmingPublish');
|
|
const s = expectTag(reduce(confirming, { tag: 'ActionStarted' }), 'Loaded');
|
|
expect(s.action).toEqual({ tag: 'Busy' });
|
|
});
|
|
|
|
it('ActionFailed carries the error', () => {
|
|
const busy = reduce(loaded(), { tag: 'ActionStarted' });
|
|
const s = expectTag(reduce(busy, { tag: 'ActionFailed', error: 'mislukt' }), 'Loaded');
|
|
expect(s.action).toEqual({ tag: 'Failed', error: 'mislukt' });
|
|
});
|
|
|
|
it('DraftLoaded resets a stale action error to Idle', () => {
|
|
const failed = reduce(loaded(), { tag: 'ActionFailed', error: 'mislukt' });
|
|
const s = expectTag(reduce(failed, { tag: 'DraftLoaded', view: view() }), 'Loaded');
|
|
expect(s.action).toEqual({ tag: 'Idle' });
|
|
});
|
|
|
|
it('an action message is a no-op when the template is not loaded', () => {
|
|
expect(reduce({ tag: 'Loading' }, { tag: 'PublishRequested' })).toEqual({ tag: 'Loading' });
|
|
expect(reduce({ tag: 'Loading' }, { tag: 'ActionStarted' })).toEqual({ tag: 'Loading' });
|
|
expect(reduce({ tag: 'Loading' }, { tag: 'ActionFailed', error: 'x' })).toEqual({
|
|
tag: 'Loading',
|
|
});
|
|
});
|
|
});
|