diff --git a/src/apps/duly.app.ts b/src/apps/duly.app.ts index 0990229..c4ff419 100644 --- a/src/apps/duly.app.ts +++ b/src/apps/duly.app.ts @@ -26,6 +26,11 @@ export const DulyApp = App.create({ children: [ { id: 'nav_my_week', type: 'object', objectName: 'duly_task', viewName: 'my_week', label: 'My week', icon: 'calendar-check' }, { id: 'nav_my_duties', type: 'object', objectName: 'duly_duty', viewName: 'mine', label: 'My duties', icon: 'clipboard-list' }, + // Directly under "My duties", because it is the same list filtered to + // the rows that are waiting on this person — to confirm, or to correct + // and send back up. An unconfirmed duty dispatches nothing, so this is + // the one entry in the group whose emptiness is the goal. + { id: 'nav_to_confirm', type: 'object', objectName: 'duly_duty', viewName: 'to_confirm', label: 'To confirm', icon: 'clipboard-check' }, { id: 'nav_standing', type: 'object', objectName: 'duly_duty', viewName: 'standing', label: 'Standing duties', icon: 'anchor' }, { id: 'nav_log', type: 'object', objectName: 'duly_log_entry', label: 'Work log', icon: 'notebook-pen' }, // The board lives HERE, not under Team, and that placement is a @@ -72,6 +77,11 @@ export const DulyApp = App.create({ label: 'People', icon: 'users-round', }, + // A reviewer's queue, and the only entry in this group that is a + // WRITE surface. It sits with the manager's screens rather than under + // "My work" for the same reason the board does not: the rows in it + // belong to other people. + { id: 'nav_to_review', type: 'object', objectName: 'duly_duty', viewName: 'to_review', label: 'To review', icon: 'clipboard-pen' }, { id: 'nav_late', type: 'object', objectName: 'duly_task', viewName: 'late', label: 'Late', icon: 'alert-circle' }, { id: 'nav_stalled', type: 'object', objectName: 'duly_task', viewName: 'stalled', label: 'Not moving', icon: 'pause-circle' }, { id: 'nav_assignments', type: 'object', objectName: 'duly_assignment', viewName: 'sent_by_me', label: 'Assignments', icon: 'send' }, diff --git a/src/data/demo-catalog.ts b/src/data/demo-catalog.ts index 14bf704..a24c221 100644 --- a/src/data/demo-catalog.ts +++ b/src/data/demo-catalog.ts @@ -381,6 +381,19 @@ export interface DemoDuty { owner: string; source: 'catalog' | 'assigned' | 'self'; status?: 'active' | 'paused' | 'retired'; + /** + * Where this duty sits in the review pipeline TODAY (#107). Omitted means + * `approved` — the steady state of a duty that is actually being worked, + * and the only state that dispatches. + * + * The fixture keeps a handful of rows off `approved` on purpose: an empty + * "To confirm" and an empty "To review" are two screens a demo cannot show, + * and "unapproved duties do not dispatch" is not a claim anybody can check + * against a fixture where every duty is approved. + */ + review?: 'to_confirm' | 'to_review' | 'approved' | 'returned'; + /** Mandatory on a `returned` row — `returned_needs_note` refuses one without it. */ + reviewNote?: string; /** Self-declared duties carry their own cadence — there is no catalog row behind them. */ own?: Partial & { form: Form }; } @@ -403,7 +416,11 @@ export const DUTIES: readonly DemoDuty[] = [ { name: 'Emissions return — Northgate', item: 'Emissions return', owner: ADMIN, source: 'catalog' }, { name: 'Waste transfer log review — Northgate', item: 'Waste transfer log review', owner: ADMIN, source: 'catalog' }, { name: 'Permit condition review — Northgate', item: 'Permit condition review', owner: ADMIN, source: 'catalog' }, - { name: 'Site environmental audit — Northgate', item: 'Site environmental audit', owner: ADMIN, source: 'catalog' }, + // Waiting on the evaluator's OWN confirmation, so the pipeline is + // clickable on the first record page they open. Its in-flight task stays + // where it is — returning or un-confirming a duty stops the NEXT run, it + // does not retract work already dispatched. + { name: 'Site environmental audit — Northgate', item: 'Site environmental audit', owner: ADMIN, source: 'catalog', review: 'to_confirm' }, { name: 'Keep the permit register current — Northgate', item: 'Keep the permit register current', owner: ADMIN, source: 'catalog' }, { name: 'Keep up with regulator bulletins', @@ -411,6 +428,12 @@ export const DUTIES: readonly DemoDuty[] = [ owner: ADMIN, source: 'self', own: { form: 'recurring', frequency: 'monthly', dueAnchor: 'period_start', dueOffsetDays: 7, leadDays: 7, graceDays: 0, description: 'Read the month\'s bulletins and note anything that changes what the site owes.' }, + // The returned one. Owned by the evaluator, so `returned → to_review` — + // the leg only the OWNER may walk — is demonstrable without a second + // login. It is also the state that stops dispatch, which is the most + // persuasive thing this card has to show. + review: 'returned', + reviewNote: 'Reading the bulletins is not the duty — the duty is recording what changed and who has to act. Rewrite the acceptance bar and send it back.', }, // ── Northgate Quality ───────────────────────────────────────────────── @@ -422,12 +445,20 @@ export const DUTIES: readonly DemoDuty[] = [ owner: 'Priya Raman', source: 'self', own: { form: 'recurring', frequency: 'monthly', dueAnchor: 'period_start', dueOffsetDays: 5, leadDays: 7, graceDays: 0, description: 'Half an hour with the month\'s nonconformances and calibration deviations, looking for the shape rather than the individual events.' }, + // Self-declared duties are born `to_review`, not `to_confirm` — writing + // one down IS the confirmation. This row is what that default looks like + // on screen, and it gives the reviewer's queue a second entry that is not + // a catalog duty. + review: 'to_review', }, { name: 'Calibration verification — Lab 1', item: 'Calibration verification', owner: 'Rosa Delgado', source: 'catalog' }, - { name: 'Retained sample review — Lab 1', item: 'Retained sample review', owner: 'Rosa Delgado', source: 'catalog' }, + // A second unconfirmed list, on somebody else — so a manager opening + // "To review" sees a queue rather than one row, and so "To confirm" is + // visibly a per-person list rather than a global one. + { name: 'Retained sample review — Lab 1', item: 'Retained sample review', owner: 'Rosa Delgado', source: 'catalog', review: 'to_confirm' }, { name: 'Nonconformance log review — Northgate Quality', item: 'Nonconformance log review', owner: 'Rosa Delgado', source: 'catalog' }, { name: 'Calibration verification — Lab 2', item: 'Calibration verification', owner: 'Ibrahim Chaudhry', source: 'catalog' }, - { name: 'Instrument drift check — Lab 2', item: 'Instrument drift check', owner: 'Ibrahim Chaudhry', source: 'catalog' }, + { name: 'Instrument drift check — Lab 2', item: 'Instrument drift check', owner: 'Ibrahim Chaudhry', source: 'catalog', review: 'to_review' }, { name: 'Track my own training hours', item: null, diff --git a/src/data/demo-history.ts b/src/data/demo-history.ts index 792fbfe..8133ca6 100644 --- a/src/data/demo-history.ts +++ b/src/data/demo-history.ts @@ -89,6 +89,20 @@ export const DISPATCH_DUTIES: readonly DispatchDuty[] = DUTIES.map((duty) => { owner: duty.owner, business_unit: unit, source: duty.source, + // ── Always `approved`, whatever the duty's review state is TODAY ────── + // The planner refuses an unapproved duty (`not_approved`), so passing + // each row's real review state would delete the history of every duty + // this fixture leaves mid-pipeline — including the two the "Late" and + // "Not moving" stories are told with. + // + // That would also be the wrong history. `review_status` is a fact about + // now, not about the six months behind it: those tasks WERE dispatched, + // which is precisely why returning a duty stops the next run rather than + // retracting the work already owed (pinned in `test/dispatch.test.ts`). + // A duty sitting in `to_confirm` with tasks behind it is the import case + // this card exists for — the work was always being done; the list is only + // now being formalised. + review_status: 'approved', frequency: cadence.frequency ?? null, due_anchor: cadence.due_anchor ?? null, due_offset_days: cadence.due_offset_days ?? null, diff --git a/src/data/duty.seed.ts b/src/data/duty.seed.ts index 9862185..6399107 100644 --- a/src/data/duty.seed.ts +++ b/src/data/duty.seed.ts @@ -35,6 +35,12 @@ import { HISTORY_FROM } from './demo-history.js'; * obligation, and the duty stays on screen. * - **One one-off**, dispatched by hand rather than by the scheduler. Its * single task is seeded directly in `task.seed.ts`. + * - **Five duties off `approved`** (#107) — two waiting on their owner to + * confirm, two waiting on a reviewer, one returned with a reason. They hold + * zero NEW tasks for a third reason (`not_approved`) while keeping the + * history they were dispatched with, which is the import case the review + * pipeline exists for. Two of the five are the demo account's own, so the + * pipeline can be walked on a record page without a second login. */ /** A self-declared duty has no catalog row behind it, so it carries its own cadence. */ @@ -65,6 +71,22 @@ export const dutySeed = defineSeed(Duty, { // exercises the zone handling rather than leaving every row on UTC. timezone: timezoneOf(unit), status: duty.status ?? 'active', + // ── Review state: the seed states it, and states it on every row ────── + // Not left to the field default, for the same reason `source` is not + // (see the ⚠️ above): the default keys on `source`, so leaving it out + // would silently put every self-declared duty in `to_review` and every + // catalog duty in `to_confirm` — a demo where nothing dispatches and + // the dashboard reads zero, with nothing erroring anywhere. + // + // The seed loader writes with `seedReplay: true`, which is what lets it + // land `approved` and `returned` rows directly: the platform skips + // `state_machine` rules on that context, so the fixture does not have + // to walk 30 duties through the pipeline one legal transition at a + // time. `returned_needs_note` is an ordinary script rule and is NOT + // skipped — a returned row without a reason would be refused, which is + // why `reviewNote` is required on that shape in `demo-catalog.ts`. + review_status: duty.review ?? 'approved', + ...(duty.reviewNote ? { review_note: duty.reviewNote } : {}), // The history this seed backfills starts here, so the duties say so. // Without it the effective window is open-ended and a later backfill // would happily invent obligations that predate the demo. diff --git a/src/jobs/dispatch.job.ts b/src/jobs/dispatch.job.ts index be4602a..9b4130b 100644 --- a/src/jobs/dispatch.job.ts +++ b/src/jobs/dispatch.job.ts @@ -5,6 +5,7 @@ import { defineJob } from '@objectstack/spec'; import { DISPATCH_DUTY_FIELDS, DISPATCHABLE_FORM, + DISPATCHABLE_REVIEW_STATUS, DISPATCHABLE_STATUS, FAULT_SKIP_REASONS, nextDispatchedPeriod, @@ -300,14 +301,22 @@ export async function runDispatch(engine: DispatchEngine, options: DispatchOptio } /** - * Every active recurring duty, paged. + * Every active, approved, recurring duty, paged. * - * The filter is deliberately only the two zone-independent facts. The + * The filter is deliberately only the three zone-independent facts. The * effective-window test is NOT pushed into the query: `effective_from` and * `effective_to` are calendar days in the DUTY's own zone, and "today" is a * different day in Auckland and in Los Angeles at the same instant, so a single * SQL predicate cannot be right for every row it matches. The window test * belongs where the zone is known, which is the planner. + * + * `review_status` is pushed down here AND checked again in the planner, and + * that is not a redundant belt: this query is one of two callers. A backfill + * driven straight through `planDispatch` — and every planner test — never + * passes this filter at all, so the planner's own `not_approved` check is what + * makes the rule true for both. What the filter adds is that the nightly sweep + * does not read, page and walk every unapproved duty in the tenant to discard + * them one at a time. */ async function readDispatchableDuties(engine: DispatchEngine): Promise { const duties: DispatchDuty[] = []; @@ -315,7 +324,7 @@ async function readDispatchableDuties(engine: DispatchEngine): Promise { */ const OPAQUE: Readonly> = { 'object.indexes': 'index definitions — field names and uniqueness scopes', + // `{ fromState: [toState] }` — state VALUES on both sides, never a label. + // Opaque rather than one verdict per state, so adding a state to a pipeline + // does not also mean adding a row to this table. + 'object.validations[].transitions': 'state-machine transition table — stored state values', 'object.fields{}.summaryOperations': 'rollup wiring — object, field and function names', 'object.enable': 'capability flags', 'view.data': 'the view\'s data binding — provider and object name', @@ -255,6 +259,7 @@ const VERDICTS: Readonly> = { 'object.sharingModel': machine('security posture'), 'object.datasource': machine('datasource name'), 'object.nameField': machine('field name'), + 'object.stageField': machine('field name — the record\'s lifecycle field (ADR-0085)'), 'object.highlightFields[]': machine('field names'), 'object.fields{}.label': translate((c) => id(c) ? ['objects', id(c)!, 'fields', c.path[1]!, 'label'] : undefined), @@ -268,6 +273,11 @@ const VERDICTS: Readonly> = { }), 'object.fields{}.options[].value': machine('the stored option value — the option key itself'), 'object.fields{}.options[].color': machine('option colour'), + // Per-option `visibleWhen` (#107) — a CEL predicate over `record` and + // `current_user`, evaluated by the form AND by the write path. Machine, like + // every other expression envelope in this table. + 'object.fields{}.options[].visibleWhen.dialect': machine('expression dialect'), + 'object.fields{}.options[].visibleWhen.source': machine('CEL source'), 'object.fields{}.type': machine('field type'), 'object.fields{}.reference': machine('lookup target object'), 'object.fields{}.deleteBehavior': machine('referential action'), @@ -289,6 +299,11 @@ const VERDICTS: Readonly> = { 'object.validations[].events[]': machine('lifecycle events'), 'object.validations[].condition.dialect': machine('expression dialect'), 'object.validations[].condition.source': machine('CEL source'), + // The `state_machine` variant's own keys (#107). `field` and + // `initialStates` name a field and stored values; the transition table + // itself is opaque above. + 'object.validations[].field': machine('the state field a state_machine rule governs'), + 'object.validations[].initialStates[]': machine('stored states a record may be created in'), // ── view (walked per expanded view item, so `list` and `listViews.*` // normalise to the same paths) ──────────────────────────────────────── diff --git a/src/translations/zh-CN.ts b/src/translations/zh-CN.ts index 794d86d..21c5ed8 100644 --- a/src/translations/zh-CN.ts +++ b/src/translations/zh-CN.ts @@ -67,6 +67,21 @@ import { defineTranslationBundle } from '@objectstack/spec'; * below uses it — recorded so the next translator does not invent a second * word for it. * + * **confirm / review / return → 确认 / 审定 / 打回** (`review_status`, #107) + * The three moves in the review pipeline, and all three words come from the + * card that specified it — product wording, not a translator's choice, so + * they are transcribed rather than re-derived. + * **确认** is the OWNER's move: "yes, this list is mine". **审定** is the + * reviewer's: an organisational sign-off, which is why the field is + * `审定状态` even though the owner's step is inside it — 审定 names the whole + * flow, 确认 names one step of it. **打回** is the return, the ordinary + * Chinese office word for sending a submission back with a reason. + * ⛔ NOT 审批 for 审定 — 审批 is approval of a REQUEST somebody raised + * (leave, expense), and this is adjudication of a standing list; the frame + * is different and the demo audience hears the difference. + * ⛔ NOT 驳回 for 打回 — 驳回 is a rejection that ends the matter (a court + * dismisses a claim). A returned duty is expected back, corrected. + * * Supporting choices, same reasoning, less contested: * business unit → 部门 · role catalog → 岗位职责库 · assignment → 指派 · * dispatch → 派发 · not moving / stagnation → 停滞 · grace → 宽限期 · @@ -169,6 +184,24 @@ export const dulyChinese = defineTranslationBundle({ retired: '停用', }, }, + // 审定流水线(#107)。四个选项的中文取自卡片本身的用词——它同时是 + // 产品方的措辞裁决,不需要再译一遍。「状态」这个词此处不与上面的 + // `status`(启用/暂停/停用)冲突:那是职责本身还在不在,这是它有没有 + // 被认领与批准,两个都要读得出来,所以一个是「状态」一个是「审定状态」。 + review_status: { + label: '审定状态', + help: '这条职责在「确认—审定」流程中的位置。只有「已审定」才会派发任务——被打回的职责当天就不再生成新任务,这正是打回的意义。', + options: { + to_confirm: '待确认', + to_review: '待审定', + approved: '已审定', + returned: '已打回', + }, + }, + review_note: { + label: '打回原因', + help: '为什么把这条职责退回去,要写成负责人拿得起、改得动的话。打回时必填(`returned_needs_note`);重新提交后不会被清空——正在照着修改的人还要再读一遍。', + }, effective_from: { label: '生效日期' }, effective_to: { label: '失效日期' }, last_dispatched_period: { @@ -178,6 +211,8 @@ export const dulyChinese = defineTranslationBundle({ }, _views: { mine: { label: '我的职责' }, + to_confirm: { label: '待我确认' }, + to_review: { label: '待我审定' }, standing: { label: '常设职责' }, catalog_tree: { label: '各团队应尽的职责' }, default: { label: '全部职责' }, @@ -459,12 +494,14 @@ export const dulyChinese = defineTranslationBundle({ group_me: { label: '我的工作' }, nav_my_week: { label: '我的本周' }, nav_my_duties: { label: '我的职责' }, + nav_to_confirm: { label: '待我确认' }, nav_standing: { label: '常设职责' }, nav_log: { label: '工作日志' }, nav_board: { label: '看板' }, group_team: { label: '团队' }, nav_duty_health: { label: '职责健康度' }, nav_people: { label: '成员' }, + nav_to_review: { label: '待我审定' }, nav_late: { label: '逾期' }, nav_stalled: { label: '停滞' }, nav_assignments: { label: '指派' }, diff --git a/src/views/duty.view.ts b/src/views/duty.view.ts index 9ff6508..504954d 100644 --- a/src/views/duty.view.ts +++ b/src/views/duty.view.ts @@ -15,6 +15,7 @@ export const DutyViews = defineView({ { field: 'frequency' }, { field: 'owner' }, { field: 'source' }, + { field: 'review_status' }, { field: 'status' }, ], }, @@ -29,11 +30,76 @@ export const DutyViews = defineView({ { field: 'form' }, { field: 'frequency' }, { field: 'due_anchor' }, + // Where each of my duties stands in the pipeline — including the ones + // that are approved and therefore NOT in "To confirm". A duty list + // that showed only `status` would say "Active" about a duty producing + // no tasks at all. + { field: 'review_status' }, { field: 'status' }, ], filter: [{ field: 'owner', operator: 'equals', value: '{current_user_id}' }], }, + /** + * "What is waiting on ME" — the owner's half of the review pipeline. + * + * Two states in one list, deliberately. `to_confirm` (the organisation + * put this on you; is it yours?) and `returned` (a reviewer sent it back) + * are different sentences, but they are the same ACTION — read it, fix it + * if it needs fixing, send it up — and splitting them would make the + * second one a list most people never open. `review_note` is a column + * rather than a detail-page trip: a returned duty whose reason you have + * to click into is a reason you read once. + */ + to_confirm: { + label: 'To confirm', + type: 'grid', + data, + columns: [ + { field: 'name' }, + { field: 'review_status' }, + { field: 'review_note' }, + { field: 'form' }, + { field: 'frequency' }, + { field: 'source' }, + ], + filter: [ + { field: 'owner', operator: 'equals', value: '{current_user_id}' }, + { field: 'review_status', operator: 'in', value: ['to_confirm', 'returned'] }, + ], + sort: [{ field: 'name', order: 'asc' }], + }, + + /** + * "What is waiting on a reviewer" — the other half. + * + * No owner filter, and that is the point: this list is somebody ELSE's + * duties. How far it reaches is not this view's decision — it is + * `duly_manager`'s `readScope: 'unit_and_below'` on `duly_duty` + * (`src/security/permission-sets.ts`), which resolves to owner-only on an + * open-edition boot and to the unit tree with `@objectstack/security- + * enterprise` installed. A view that tried to express reach in its own + * filter would be a second, weaker copy of the security model. + * + * `owner` is a column here for the same reason it is not a filter: the + * first question a reviewer asks of this list is whose duty each row is. + */ + to_review: { + label: 'To review', + type: 'grid', + data, + columns: [ + { field: 'name' }, + { field: 'owner' }, + { field: 'business_unit' }, + { field: 'form' }, + { field: 'frequency' }, + { field: 'source' }, + ], + filter: [{ field: 'review_status', operator: 'equals', value: 'to_review' }], + sort: [{ field: 'business_unit', order: 'asc' }, { field: 'name', order: 'asc' }], + }, + // Standing duties never produce a task, so they would otherwise be // invisible. They get their own view rather than an infinite backlog. standing: { diff --git a/test/dispatch-wiring.test.ts b/test/dispatch-wiring.test.ts index 2c36751..8cc7d08 100644 --- a/test/dispatch-wiring.test.ts +++ b/test/dispatch-wiring.test.ts @@ -99,7 +99,15 @@ describe('the dispatch engine is bound at boot, through the real onEnable path', due_offset_days: 4, lead_days: 0, timezone: 'UTC', - }); + // Only an APPROVED duty dispatches (#107). Written straight, through + // the platform's own historical-write door — `skipStateMachine` is what + // the REST import endpoint sets for `treatAsHistorical`, and what the + // seed loader reaches via `seedReplay`; the pipeline itself is exercised + // by ordinary writes in `test/duty-review.test.ts`. Without this the + // duty is born `to_confirm` and this suite would report the wiring + // broken when what is actually true is that nobody approved anything. + review_status: 'approved', + }, { context: { skipStateMachine: true } }); const dutyId = String((Array.isArray(created) ? created[0] : created).id); // If `registerDulyActionHandlers` never called `bindDispatchEngine`, this diff --git a/test/dispatch.test.ts b/test/dispatch.test.ts index a6622f5..2be8757 100644 --- a/test/dispatch.test.ts +++ b/test/dispatch.test.ts @@ -23,6 +23,7 @@ import { DEFAULT_GRACE_DAYS, DEFAULT_TIMEZONE, DISPATCH_DUTY_FIELDS, + FAULT_SKIP_REASONS, nextDispatchedPeriod, planDispatch, type DispatchDuty, @@ -149,6 +150,10 @@ describe('the duty projection covers every field the planner reads', () => { 'owner', 'business_unit', 'source', + // The dispatch gate. Omitted from the projection it comes back + // `undefined`, which the planner reads as "not approved" — every duty + // in the tenant would stop dispatching, quietly, on the next deploy. + 'review_status', 'frequency', 'due_anchor', 'due_offset_days', @@ -180,6 +185,11 @@ const duty = (over: Partial = {}): DispatchDuty => ({ owner: 'user_alice', business_unit: 'bu_plant', source: 'catalog', + // The steady state of a duty that is actually being worked. Stated on the + // fixture rather than defaulted inside the planner, so the gate below has + // something to turn OFF — a planner that silently treated a missing review + // state as approved would pass every test in this file. + review_status: 'approved', frequency: 'monthly', due_anchor: 'period_start', due_offset_days: 4, @@ -217,6 +227,49 @@ describe('what is never dispatched', () => { } }); + it('an unapproved duty produces nothing, whatever the review state is', () => { + // The four states the pipeline can be in, and the one that dispatches. + // Written as a sweep rather than one case, because the failure this + // guards against is a gate written as "not returned" — which would let + // `to_confirm` and `to_review` through and make the confirmation step + // decorative. + for (const review_status of ['to_confirm', 'to_review', 'returned']) { + expect(keysOf([duty({ review_status })], now), review_status).toEqual([]); + expect( + planDispatch({ duties: [duty({ review_status })], now }).skipped[0]?.reason, + review_status, + ).toBe('not_approved'); + } + expect(keysOf([duty({ review_status: 'approved' })], now)).toEqual(['2026-08']); + }); + + it('a duty carrying NO review state produces nothing either', () => { + // Fail-closed, and this is the case that decides the direction. A row + // that predates the column, or arrived through a path that skipped the + // default, is not "grandfathered in" — it stops until somebody approves + // it. The recoverable failure is the one that is visible. + expect(keysOf([duty({ review_status: null })], now)).toEqual([]); + expect(keysOf([duty({ review_status: undefined })], now)).toEqual([]); + expect(planDispatch({ duties: [duty({ review_status: null })], now }).skipped[0]?.reason) + .toBe('not_approved'); + }); + + it('an unapproved duty is an ordinary skip, not a degraded run', () => { + // `not_approved` must stay OUT of `FAULT_SKIP_REASONS`. A tenant part-way + // through a rollout has hundreds of them, and a nightly job that reports + // `degraded` every night is a job whose alerts get muted — after which the + // one real fault it exists to report goes unread too. + expect(FAULT_SKIP_REASONS as readonly string[]).not.toContain('not_approved'); + }); + + it('backfilling does not smuggle an unapproved duty past the gate', () => { + // A backfill skips the duty-level effective window on purpose, so it is + // worth pinning that it does NOT skip this. Returning a duty and then + // asking for last month must not re-create the work it stopped. + expect(keysOf([duty({ review_status: 'returned' })], now, { from: '2026-01-01', to: '2026-08-31' })) + .toEqual([]); + }); + it('un-pausing produces only the current period, never the gap', () => { // The duty was last dispatched in February and has been paused since. The // rule is "the current period", not "everything you missed" — a person @@ -605,8 +658,24 @@ const seedDuty = async (over: AnyRow = {}, options?: AnyRow): Promise => due_offset_days: 4, lead_days: 0, timezone: 'UTC', + review_status: 'approved', ...over, - }, options); + }, { + ...options, + context: { + // A duty may not be CREATED `approved` — `review_status_transitions` + // declares `initialStates: ['to_confirm', 'to_review']`, and that is the + // rule, not an obstacle to route around. This fixture is writing + // established history (a duty that was approved some time ago), so it + // takes the platform's own door for exactly that: `skipStateMachine` is + // the context key the REST import endpoint sets for a `treatAsHistorical` + // import, and the seed loader's `seedReplay` reaches the same branch. + // The pipeline itself is exercised through ordinary writes in + // `test/duty-review.test.ts`, which is where it must not be bypassed. + skipStateMachine: true, + ...((options?.context as AnyRow | undefined) ?? {}), + }, + }); const row = (Array.isArray(created) ? created[0] : created) as AnyRow; seeded.push(String(row.id)); return String(row.id); @@ -633,6 +702,51 @@ describe('the index is real on this engine', () => { }); }); +describe('the review gate, against the real engine', () => { + it('a returned duty generates nothing; walking it back to approved generates its tasks', async () => { + // The demo beat, end to end: change one field, tomorrow's work changes. + const dutyId = await seedDuty({ + lead_days: 0, + review_status: 'returned', + review_note: 'Monthly is wrong for this — the permit says quarterly.', + }); + + const blocked = await runDispatch(data, { now: NOW }); + expect(await tasksFor(dutyId), 'a returned duty owes nobody anything').toHaveLength(0); + // It is not even READ: the sweep's own query filters on `review_status`, + // so an unapproved duty costs the nightly run nothing at all. + expect(blocked.skipped.some((skip) => skip.duty === dutyId)).toBe(false); + + // The owner corrects it and sends it back up; the manager approves. Two + // writes, because `returned → approved` is not a step the machine has. + await data.update('duly_duty', { id: dutyId, review_status: 'to_review' }); + await data.update('duly_duty', { id: dutyId, review_status: 'approved' }); + + await runDispatch(data, { now: NOW }); + expect((await tasksFor(dutyId)).map((task) => task.period_key)).toEqual(['2026-08']); + }); + + it('returning an approved duty stops the NEXT run, and leaves the tasks already dispatched alone', async () => { + // The other half, and the one a "just filter the view" implementation + // would get wrong: returning a duty is not a retraction of work already + // owed. August's task stays; September's is never created. + const dutyId = await seedDuty({ lead_days: 0, frequency: 'monthly' }); + await runDispatch(data, { now: NOW }); + expect((await tasksFor(dutyId)).map((task) => task.period_key)).toEqual(['2026-08']); + + await data.update('duly_duty', { + id: dutyId, + review_status: 'returned', + review_note: 'Superseded by the group standard — rewrite the acceptance bar.', + }); + + const september = new Date('2026-09-15T09:00:00Z'); + const after = await runDispatch(data, { now: september }); + expect(after.created, 'a returned duty creates nothing on the next night').toBe(0); + expect((await tasksFor(dutyId)).map((task) => task.period_key)).toEqual(['2026-08']); + }); +}); + describe('running twice over the same clock', () => { it('the second pass inserts nothing, and that is a successful run', async () => { const dutyId = await seedDuty({ lead_days: 0 }); diff --git a/test/duty-review.test.ts b/test/duty-review.test.ts new file mode 100644 index 0000000..7c3c177 --- /dev/null +++ b/test/duty-review.test.ts @@ -0,0 +1,370 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { AppPlugin, ObjectKernel, createStandaloneStack } from '@objectstack/runtime'; + +import stack from '../objectstack.config.js'; +import { Duty } from '../src/objects/index.js'; + +/** + * #107 — confirmation and approval: the pipeline `duly_duty.review_status` + * declares, and the three enforcements behind it. + * + * ── Why every assertion here goes through a booted engine ──────────────── + * All three mechanisms this card rests on are INVISIBLE to `pnpm validate`, + * and each is invisible in a different way: + * + * - The **conditional default** is a CEL expression. `field.zod.ts`'s + * authoring gate returns unconditionally on an expression envelope ("a CEL + * result type is unknowable at parse time"), so a structural pin + * (`Duty.fields.review_status.defaultValue === …`) proves the KEY exists + * and nothing about what it evaluates to. Same reasoning, same shape as + * `test/cadence-conditional-defaults.test.ts`. + * - The **state machine** is a `transitions` table the validator reads at + * write time with the prior row in hand. Nothing at author time compares it + * to the option list, so a table naming a state that does not exist — or + * omitting one that does — parses clean. (Omitting is the dangerous + * direction: measured on `@objectstack/objectql` 17.2.0, a state with NO + * row accepts every transition out of it, so a missing row reads like + * "locked" and behaves like "wide open".) + * - The **per-option `visibleWhen`** is the authorization half, and it is the + * one an author is most likely to mistake for decoration. It is not: the + * rule validator evaluates the PICKED option's predicate on every insert + * and update and refuses the write. The tests at the bottom drive it with + * three different callers, because a predicate that never refuses anybody + * is indistinguishable from no predicate at all. + * + * The dispatch half of #107 — an unapproved duty produces no tasks — lives in + * `test/dispatch.test.ts`, next to the rest of the planner. + */ + +type AnyRow = Record; + +let kernel: { getService(name: string): unknown; shutdown?(): Promise } | undefined; +let data: { + find(o: string, q?: AnyRow, x?: AnyRow): Promise; + insert(o: string, d: AnyRow, x?: AnyRow): Promise; + update(o: string, d: AnyRow, x?: AnyRow): Promise; +}; + +beforeAll(async () => { + const { plugins } = await createStandaloneStack({ + databaseDriver: 'memory', + skipSeedData: true, + // See dispatch.test.ts: left to its default this resolves + // `/dist/objectstack.json`, and a local `pnpm build` would make the + // suite report on the last BUILD instead of on `src/`. + artifactPath: 'dist/objectstack.this-suite-must-not-load-an-artifact.json', + }); + const k = new ObjectKernel(); + for (const plugin of plugins) await k.use(plugin); + await k.use(new AppPlugin(stack, undefined, { skipSeedData: true })); + await k.bootstrap(); + kernel = k as unknown as typeof kernel; + data = k.getService('data') as typeof data; +}, 180_000); + +afterAll(async () => { + await kernel?.shutdown?.(); +}); + +/** Assert a refusal by its ENVELOPE (ADR-0112), never by the bare fact that it threw. */ +async function refusal(promise: Promise): Promise<{ code: unknown; message: string }> { + try { + await promise; + } catch (error: any) { + return { code: error?.code, message: String(error?.message ?? '') }; + } + throw new Error('expected the write to be refused, but it resolved'); +} + +let seq = 0; + +const OWNER = 'user_owner'; +const REVIEWER = 'user_reviewer'; + +/** + * A duty, inserted through the ordinary write path. + * + * No `skipStateMachine` and no `isSystem` anywhere in this file: the rules + * under test are exactly the ones those keys turn off, so a fixture that used + * either would be testing the escape hatch. Where an already-approved duty is + * needed, it is WALKED there through the pipeline, one legal transition at a + * time — which is also the shortest honest statement of what the pipeline is. + */ +const insertDuty = async (over: AnyRow = {}, options?: AnyRow): Promise => { + const created = await data.insert('duly_duty', { + name: `Duty ${++seq}`, + form: 'recurring', + owner: OWNER, + source: 'catalog', + status: 'active', + frequency: 'monthly', + timezone: 'UTC', + ...over, + }, options); + return (Array.isArray(created) ? created[0] : created) as AnyRow; +}; + +const readDuty = async (id: unknown): Promise => + (await data.find('duly_duty', { where: { id }, limit: 1 }))[0] as AnyRow; + +/** Move one duty one step, as `who` (a bare user id, or nobody at all). */ +const advance = (id: unknown, to: string, over: AnyRow = {}, who?: string) => + data.update( + 'duly_duty', + { id, review_status: to, ...over }, + who === undefined ? undefined : { context: { userId: who, positions: [] } }, + ); + +/** A duty walked all the way to `approved` by somebody who does not own it. */ +const approvedDuty = async (over: AnyRow = {}): Promise => { + const duty = await insertDuty(over); + await advance(duty.id, 'to_review', {}, OWNER); + await advance(duty.id, 'approved', {}, REVIEWER); + return await readDuty(duty.id); +}; + +// ─────────────────────────────────────────────────────────────────────────── +// Where a duty enters the pipeline +// ─────────────────────────────────────────────────────────────────────────── + +describe('a new duty enters the pipeline where its SOURCE puts it', () => { + it('an organisation-supplied duty waits for the owner to confirm it', async () => { + // The import / catalog case, and the reason the card exists: a list the + // organisation produced is visible immediately and productive only once + // the person who owes it has said "yes, this is mine". + for (const source of ['catalog', 'assigned']) { + const duty = await insertDuty({ source }); + expect((await readDuty(duty.id)).review_status, source).toBe('to_confirm'); + } + }); + + it('a self-declared duty is already confirmed — writing it down IS the confirmation', async () => { + const duty = await insertDuty({ source: 'self' }); + expect((await readDuty(duty.id)).review_status).toBe('to_review'); + }); + + it('the default is reached through `source`, not through the payload', async () => { + // `source` itself defaults to `self` (#54), so a duty created with no + // source at all is self-declared and lands in `to_review`. This is the + // hand-created case, and it is the one that proves the default is being + // EVALUATED rather than copied off a literal. + const duty = await insertDuty({ source: undefined }); + const row = await readDuty(duty.id); + expect(row.source).toBe('self'); + expect(row.review_status).toBe('to_review'); + }); + + it('is required, so no duty can sit outside the pipeline', () => { + expect(Duty.fields.review_status.required).toBe(true); + }); +}); + +describe('a duty cannot be BORN mid-pipeline', () => { + it('refuses an insert that arrives already approved', async () => { + // `transitions` governs updates only, and a `select` accepts any declared + // option on create — so without `initialStates` the whole card could be + // walked around by writing `approved` on the way in, which is exactly the + // defect it exists to close ("imported lists take effect and dispatch + // immediately"). + const { code, message } = await refusal(insertDuty({ review_status: 'approved' })); + expect(code).toBe('VALIDATION_FAILED'); + expect(message).toContain('not a step this review can take'); + }); + + it('refuses an insert that arrives already returned', async () => { + const { code } = await refusal(insertDuty({ review_status: 'returned', review_note: 'no' })); + expect(code).toBe('VALIDATION_FAILED'); + }); + + it('admits the two states a duty may legitimately start in', async () => { + for (const review_status of ['to_confirm', 'to_review']) { + const duty = await insertDuty({ review_status }); + expect((await readDuty(duty.id)).review_status, review_status).toBe(review_status); + } + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// The transitions +// ─────────────────────────────────────────────────────────────────────────── + +describe('the pipeline admits exactly the steps it declares', () => { + it('refuses to_confirm → approved — the card\'s own example', async () => { + const duty = await insertDuty(); + const { code, message } = await refusal(advance(duty.id, 'approved', {}, REVIEWER)); + expect(code).toBe('VALIDATION_FAILED'); + expect(message).toContain('not a step this review can take'); + // And the refused write left the row where it was. + expect((await readDuty(duty.id)).review_status).toBe('to_confirm'); + }); + + it('walks to_confirm → to_review → approved', async () => { + const duty = await insertDuty(); + await advance(duty.id, 'to_review', {}, OWNER); + expect((await readDuty(duty.id)).review_status).toBe('to_review'); + await advance(duty.id, 'approved', {}, REVIEWER); + expect((await readDuty(duty.id)).review_status).toBe('approved'); + }); + + it('walks to_review → returned → to_review', async () => { + const duty = await insertDuty({ source: 'self' }); + await advance(duty.id, 'returned', { review_note: 'Quarterly, not monthly.' }, REVIEWER); + expect((await readDuty(duty.id)).review_status).toBe('returned'); + await advance(duty.id, 'to_review', {}, OWNER); + expect((await readDuty(duty.id)).review_status).toBe('to_review'); + }); + + it('refuses returned → approved — a correction goes back through review', async () => { + const duty = await insertDuty({ source: 'self' }); + await advance(duty.id, 'returned', { review_note: 'The owner is wrong.' }, REVIEWER); + const { code } = await refusal(advance(duty.id, 'approved', {}, REVIEWER)); + expect(code).toBe('VALIDATION_FAILED'); + }); + + it('lets an approved duty be RETURNED — the correction path', async () => { + // The one edge the card's table does not draw, added deliberately: with + // `approved` terminal there is no way to correct a live duty at all, by + // anyone. See the rule's comment in `duty.object.ts`. + const duty = await approvedDuty(); + await advance(duty.id, 'returned', { review_note: 'Superseded by the group standard.' }, REVIEWER); + expect((await readDuty(duty.id)).review_status).toBe('returned'); + }); + + it('refuses approved → to_review — the way back is a return, with a reason', async () => { + const duty = await approvedDuty(); + const { code } = await refusal(advance(duty.id, 'to_review', {}, REVIEWER)); + expect(code).toBe('VALIDATION_FAILED'); + expect((await readDuty(duty.id)).review_status).toBe('approved'); + }); + + it('every state the field declares has a row in the table', () => { + // The measured trapdoor: a state with no `transitions` row is not frozen, + // it is UNGOVERNED — `if (!Array.isArray(allowed)) return null` lets every + // transition out of it through. Nothing at author time compares the two + // lists, so this is the comparison. + const rule = (Duty.validations ?? []).find((v: any) => v.name === 'review_status_transitions') as any; + const declared = (Duty.fields.review_status.options ?? []).map((option: any) => String(option.value)); + expect(Object.keys(rule.transitions).sort()).toEqual([...declared].sort()); + // And every TARGET is a real state too — a typo'd target is a step nobody + // can ever take, which reads as "the pipeline is stuck" at run time. + for (const [from, targets] of Object.entries(rule.transitions as Record)) { + for (const target of targets) expect(declared, `${from} → ${target}`).toContain(target); + } + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// The reason a return carries +// ─────────────────────────────────────────────────────────────────────────── + +describe('returning a duty says why', () => { + it('refuses a return with no reason', async () => { + const duty = await insertDuty({ source: 'self' }); + const { code, message } = await refusal(advance(duty.id, 'returned', {}, REVIEWER)); + expect(code).toBe('VALIDATION_FAILED'); + expect(message).toBe('Say why the duty is being returned — the owner needs something to act on.'); + expect((await readDuty(duty.id)).review_status).toBe('to_review'); + }); + + it('refuses blanking the reason while the duty is still returned', async () => { + // The rule reads the MERGED record, so an update carrying only + // `review_note: null` is judged against the stored `returned` — which is + // what stops a return from being emptied out after the fact. + const duty = await insertDuty({ source: 'self' }); + await advance(duty.id, 'returned', { review_note: 'The frequency is wrong.' }, REVIEWER); + const { code } = await refusal(data.update('duly_duty', { id: duty.id, review_note: null })); + expect(code).toBe('VALIDATION_FAILED'); + expect((await readDuty(duty.id)).review_note).toBe('The frequency is wrong.'); + }); + + it('keeps the last reason on the record after it goes back for review', async () => { + // Deliberately NOT cleared: the owner is correcting the duty while + // reading it, and a reason that vanishes the moment they act on it is a + // reason they cannot re-read. + const duty = await insertDuty({ source: 'self' }); + await advance(duty.id, 'returned', { review_note: 'Wrong owner — this is the Lab 2 duty.' }, REVIEWER); + await advance(duty.id, 'to_review', {}, OWNER); + const row = await readDuty(duty.id); + expect(row.review_status).toBe('to_review'); + expect(row.review_note).toBe('Wrong owner — this is the Lab 2 duty.'); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// Who may issue a verdict +// ─────────────────────────────────────────────────────────────────────────── + +describe('a review is not something you issue on your own list', () => { + it('refuses the OWNER approving their own duty', async () => { + // The per-option `visibleWhen` doing the job the spec says it must do: + // "Client-side hiding is UX, not authorization … the server MUST also + // reject writes of its value". This is that rejection, server-side, with + // no hook anywhere in the path. + const duty = await insertDuty(); + await advance(duty.id, 'to_review', {}, OWNER); + const { code, message } = await refusal(advance(duty.id, 'approved', {}, OWNER)); + expect(code).toBe('VALIDATION_FAILED'); + // The platform's own `option_unavailable` catalog entry, naming the field + // by its LABEL. Pinned rather than shrugged at, because this string is + // what the reviewer actually reads, and because it comes from the + // catalog the platform already ships translated — an authored rule + // message would not have (`object.validations[].message` has no bundle + // key at all; see `src/translations/authored-text.ts`). + expect(message).toBe("Review status: option 'approved' is not available"); + expect((await readDuty(duty.id)).review_status).toBe('to_review'); + }); + + it('refuses the OWNER returning their own duty', async () => { + const duty = await insertDuty(); + await advance(duty.id, 'to_review', {}, OWNER); + const { code } = await refusal( + advance(duty.id, 'returned', { review_note: 'I disagree with myself.' }, OWNER), + ); + expect(code).toBe('VALIDATION_FAILED'); + }); + + it('admits anybody else — WHO that is, is the permission set\'s question', async () => { + // The option predicate answers "may this value be written by this + // caller", not "may this caller write this record at all". The second + // question is `writeScope`'s, one layer up, and keeping them apart is why + // this predicate names the record relationship rather than a position. + const duty = await insertDuty(); + await advance(duty.id, 'to_review', {}, OWNER); + await advance(duty.id, 'approved', {}, REVIEWER); + expect((await readDuty(duty.id)).review_status).toBe('approved'); + }); + + it('lets the owner move their OWN duty into review — confirming is theirs to do', async () => { + const duty = await insertDuty(); + await advance(duty.id, 'to_review', {}, OWNER); + expect((await readDuty(duty.id)).review_status).toBe('to_review'); + }); + + it('admits a write with no acting user at all, and that is the seed path', async () => { + // Measured and deliberately pinned, because it is the half that surprises + // people: with no user in the context the predicate cannot bind + // `current_user`, the evaluation FAILS rather than returning false, and + // the platform logs `option visibleWhen … failed to evaluate — allowed + // through` and admits the write. That is what lets the demo seed carry + // approved rows and what keeps in-process jobs working — and it is why + // this predicate is a rule about people, not a containment boundary for + // server code. If this ever starts refusing, the seed goes with it. + const duty = await insertDuty(); + await advance(duty.id, 'to_review'); + await advance(duty.id, 'approved'); + expect((await readDuty(duty.id)).review_status).toBe('approved'); + }); + + it('gates the two VERDICTS and nothing else', () => { + // A predicate on `to_confirm` or `to_review` would put the owner's own + // confirmation behind an authorization test, which is not what any of + // this is for. + const gated = (Duty.fields.review_status.options ?? []) + .filter((option: any) => option.visibleWhen !== undefined) + .map((option: any) => String(option.value)) + .sort(); + expect(gated).toEqual(['approved', 'returned']); + }); +}); diff --git a/test/i18n-coverage.test.ts b/test/i18n-coverage.test.ts index f3faf16..d328ccd 100644 --- a/test/i18n-coverage.test.ts +++ b/test/i18n-coverage.test.ts @@ -296,7 +296,13 @@ describe('untranslatable display text is declared rather than dropped', () => { const count = (prefix: string): number => walk.untranslatable.filter((entry) => entry.path.startsWith(prefix)).length; expect(count('view.bulkActionDefs'), 'bulk-action toolbar copy').toBe(35); - expect(count('object.validations'), 'custom validation messages').toBe(11); + // Was 11 before #107 added `review_status_transitions` and + // `returned_needs_note`. Both messages are read by whoever is stopped by + // them — a reviewer taking a step the pipeline does not have, an owner + // returning a duty with no reason — so both enlarge the same declared gap + // (a custom rule message has no bundle key anywhere in the platform's + // schema) rather than opening a new kind of one. + expect(count('object.validations'), 'custom validation messages').toBe(13); // Was 26 before #52 added the three on-time measures — `Done on time`, // `Completed late` and the `On-time rate` derived from them. A measure // label still has no bundle key anywhere in the platform's schema, so each diff --git a/test/import-samples.test.ts b/test/import-samples.test.ts index f3f212e..9bf2581 100644 --- a/test/import-samples.test.ts +++ b/test/import-samples.test.ts @@ -89,12 +89,12 @@ function csvHeader(file: string): string[] { } /** `[fieldName, definition]` for every field the schema declares. */ -function fieldEntries(schema: unknown): Array<[string, { readonly?: boolean; required?: boolean }]> { +function fieldEntries(schema: unknown): Array<[string, { readonly?: boolean; required?: boolean; defaultValue?: unknown }]> { const fields = (schema as { fields?: Record> }).fields; if (!fields || typeof fields !== 'object') throw new Error('schema declares no fields'); return Object.entries(fields).map(([key, def]) => [ typeof def?.name === 'string' ? (def.name as string) : key, - def as { readonly?: boolean; required?: boolean }, + def as { readonly?: boolean; required?: boolean; defaultValue?: unknown }, ]); } @@ -104,11 +104,43 @@ const declaredFields = (schema: unknown): string[] => fieldEntries(schema).map(( const writableFields = (schema: unknown): string[] => fieldEntries(schema).filter(([, def]) => def?.readonly !== true).map(([name]) => name); -/** Fields a row cannot omit AND a caller may set. Both halves matter: a - * `required` field that is also `readonly` is the platform's to write. */ +/** A default the ROW supplies for itself — a CEL expression over `record.*`, + * as opposed to a scalar or a caller token like `'current_user'`. + * + * The distinction decides whether an omitted column is a defect (#107). Both + * kinds stop the import refusing the row, so neither is caught by "required"; + * what separates them is what lands instead: + * + * - **Record-derived** (`F`record.source == "self" ? … : …``): the value is + * computed from other columns of the SAME row, so an omitted column yields + * exactly what the sample would have had to type. Nothing is substituted + * and nothing is lost. + * - **Caller-derived** (`'current_user'` on `duly_duty.owner`): an omitted + * column yields the IMPORTER, silently, on every row — nineteen duties + * owned by whoever ran the import. That is the worse half of the failure + * this test exists for, so it stays in the required set even though it too + * would not be refused. + */ +const isRecordDerivedDefault = (value: unknown): boolean => + typeof value === 'object' && value !== null && 'dialect' in (value as Record); + +/** Fields a row cannot omit AND a caller may set AND the row cannot supply for + * itself. Each exclusion drops a field for its own reason: + * + * - `readonly` — the platform's to write, never the CSV's. + * - a record-derived default — see above. `duly_duty.review_status` is the + * first field to be required, writable and defaulted this way (#107): + * `record.source == "self" ? "to_review" : "to_confirm"`, so an imported + * catalog row lands `to_confirm`, which is precisely what the product + * wants an imported list to be. A column for it would restate the default + * the walkthrough already documents ("A blank cell means leave this field + * unset … the object's `defaultValue` then decides") and would invite an + * author to type `approved` there, which `initialStates` refuses row by + * row. */ const requiredWritableFields = (schema: unknown): string[] => fieldEntries(schema) - .filter(([, def]) => def?.required === true && def?.readonly !== true) + .filter(([, def]) => + def?.required === true && def?.readonly !== true && !isRecordDerivedDefault(def?.defaultValue)) .map(([name]) => name); describe('samples/ — the duty list an evaluator imports (#19)', () => { diff --git a/test/reminders.test.ts b/test/reminders.test.ts index 53adb1b..17fca91 100644 --- a/test/reminders.test.ts +++ b/test/reminders.test.ts @@ -335,6 +335,11 @@ describe('reminder sweeps — volume discipline', () => { form: 'recurring', owner: 'u1', status: 'active', + // The control leg has to be a duty that really does dispatch, and + // since #107 that means approved as well as active and recurring — + // otherwise this leg proves the fixture inert for a second reason and + // the standing assertion above stops meaning anything. + review_status: 'approved', frequency: 'monthly', due_anchor: 'period_end', due_offset_days: 0, diff --git a/test/seed.test.ts b/test/seed.test.ts index 92921cd..5033f40 100644 --- a/test/seed.test.ts +++ b/test/seed.test.ts @@ -9,6 +9,7 @@ import { ADMIN, PEOPLE, UNITS } from '../src/data/demo-org.js'; import { CATALOG_ITEMS, DUTIES } from '../src/data/demo-catalog.js'; import { AD_HOC_TASKS, ASSIGNMENTS } from '../src/data/demo-assignments.js'; import { SEEDED_TASKS, SKIPS, TODAY } from '../src/data/demo-history.js'; +import { planDispatch } from '../src/jobs/dispatch.plan.js'; /** * The demo seed, asserted against a REAL BOOTED KERNEL with the declarative @@ -360,6 +361,86 @@ describe('view populations — what an evaluator actually opens', () => { }); }); +// ─────────────────────────────────────────────────────────────────────────── +describe('the review pipeline has something to show (#107)', () => { + it('seeds a mix, not thirty approved rows', async () => { + // A fixture where every duty is approved makes three screens untestable + // and two of them unshowable: "To confirm" and "To review" are empty, and + // "an unapproved duty dispatches nothing" is a claim with no instance + // behind it. + const duties = await all('duly_duty'); + const byState = new Map(); + for (const duty of duties) { + const state = String(duty.review_status); + byState.set(state, (byState.get(state) ?? 0) + 1); + } + expect(byState.get('to_confirm') ?? 0, 'duties waiting on their owner').toBeGreaterThanOrEqual(2); + expect(byState.get('to_review') ?? 0, 'duties waiting on a reviewer').toBeGreaterThanOrEqual(1); + expect(byState.get('returned') ?? 0, 'returned duties').toBe(1); + // …and the overwhelming majority still work, or the demo opens on an app + // that dispatches nothing. + expect(byState.get('approved') ?? 0).toBeGreaterThan(duties.length / 2); + // Every row is in one of the four declared states — a blank would not + // dispatch and would render as an empty pipeline stage. + const declared = new Set(['to_confirm', 'to_review', 'approved', 'returned']); + expect([...byState.keys()].filter((state) => !declared.has(state))).toEqual([]); + }); + + it('the returned duty carries the reason the rule requires', async () => { + const returned = (await all('duly_duty')).filter((duty) => duty.review_status === 'returned'); + expect(returned).toHaveLength(1); + // `returned_needs_note` is an ordinary script rule and is NOT skipped on + // the seed path (only `state_machine` is), so a returned row without a + // note would have been refused outright and this row would be missing — + // which is worth stating, because "the row is here" is the evidence. + expect(String(returned[0]?.review_note ?? '').length).toBeGreaterThan(20); + }); + + it('leaves the evaluator their own duty in each state they can advance alone', async () => { + // The demo walk: the record page's pipeline is clickable on the first + // login, without a second account. `to_confirm → to_review` and + // `returned → to_review` are the owner's own steps; the two verdicts are + // deliberately NOT self-issuable (see `duty.object.ts`), so those are + // walked on somebody else's row from "To review". + const me = await userId(ADMIN); + const mine = (await all('duly_duty')).filter((duty) => String(duty.owner) === me); + const states = new Set(mine.map((duty) => String(duty.review_status))); + expect(states.has('to_confirm'), 'no unconfirmed duty on the demo account').toBe(true); + expect(states.has('returned'), 'no returned duty on the demo account').toBe(true); + }); + + it('an unapproved duty stops the NEXT dispatch and keeps the history it has', async () => { + // The fixture's own statement of the product rule. History is planned as + // `approved` (see `demo-history.ts`), so these rows DO carry tasks — and + // the planner would refuse them today, which is what makes "returning a + // duty stops tomorrow's work, not yesterday's" visible in the demo rather + // than only in a unit test. + const unapproved = (await all('duly_duty')).filter((duty) => duty.review_status !== 'approved'); + expect(unapproved.length).toBeGreaterThanOrEqual(4); + const plan = planDispatch({ + duties: unapproved.map((duty) => ({ + id: String(duty.id), + name: String(duty.name), + form: String(duty.form), + status: String(duty.status), + owner: String(duty.owner), + source: String(duty.source), + review_status: String(duty.review_status), + frequency: duty.frequency as string | null, + due_anchor: duty.due_anchor as string | null, + due_offset_days: duty.due_offset_days as number | null, + lead_days: duty.lead_days as number | null, + timezone: String(duty.timezone), + })), + now: new Date(), + }); + expect(plan.drafts, 'an unapproved duty drafted a task').toEqual([]); + // And for the right reason on every one of them — `not_approved`, not + // `standing` or `not_active` happening to cover for it. + expect(new Set(plan.skipped.map((skip) => skip.reason))).toEqual(new Set(['not_approved'])); + }); +}); + // ─────────────────────────────────────────────────────────────────────────── describe('the standing invariant', () => { it('no task exists whose duty is standing', async () => { diff --git a/test/task-hook.test.ts b/test/task-hook.test.ts index f5c6f8f..ac212a9 100644 --- a/test/task-hook.test.ts +++ b/test/task-hook.test.ts @@ -731,6 +731,12 @@ describe('WRITE-ONCE — editing a duty\'s grace never rewrites history', () => owner: 'user_alice', business_unit: null, source: 'catalog', + // Only an approved duty dispatches (#107). This fixture is asking + // "what deadline would the dispatcher have stamped", so the duty it + // asks about has to be one the dispatcher would actually have picked + // up — otherwise there is no draft and the assertion below reads as a + // `late_after` bug rather than an unapproved duty. + review_status: 'approved', frequency: 'monthly', due_anchor: 'period_start', due_offset_days: 4,