@owlmeans/planning 0.1.18-rc.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +169 -0
- package/agent-meta/manifest.json +16 -0
- package/agent-meta/skills/planning/SKILL.md +248 -0
- package/build/consts.d.ts +126 -0
- package/build/consts.d.ts.map +1 -0
- package/build/consts.js +129 -0
- package/build/consts.js.map +1 -0
- package/build/entrypoints.d.ts +14 -0
- package/build/entrypoints.d.ts.map +1 -0
- package/build/entrypoints.js +69 -0
- package/build/entrypoints.js.map +1 -0
- package/build/errors.d.ts +94 -0
- package/build/errors.d.ts.map +1 -0
- package/build/errors.js +169 -0
- package/build/errors.js.map +1 -0
- package/build/helpers/apply.d.ts +27 -0
- package/build/helpers/apply.d.ts.map +1 -0
- package/build/helpers/apply.js +162 -0
- package/build/helpers/apply.js.map +1 -0
- package/build/helpers/card.d.ts +17 -0
- package/build/helpers/card.d.ts.map +1 -0
- package/build/helpers/card.js +31 -0
- package/build/helpers/card.js.map +1 -0
- package/build/helpers/changes.d.ts +51 -0
- package/build/helpers/changes.d.ts.map +1 -0
- package/build/helpers/changes.js +277 -0
- package/build/helpers/changes.js.map +1 -0
- package/build/helpers/code.d.ts +24 -0
- package/build/helpers/code.d.ts.map +1 -0
- package/build/helpers/code.js +47 -0
- package/build/helpers/code.js.map +1 -0
- package/build/helpers/index.d.ts +9 -0
- package/build/helpers/index.d.ts.map +1 -0
- package/build/helpers/index.js +9 -0
- package/build/helpers/index.js.map +1 -0
- package/build/helpers/query.d.ts +39 -0
- package/build/helpers/query.d.ts.map +1 -0
- package/build/helpers/query.js +293 -0
- package/build/helpers/query.js.map +1 -0
- package/build/helpers/specification.d.ts +21 -0
- package/build/helpers/specification.d.ts.map +1 -0
- package/build/helpers/specification.js +48 -0
- package/build/helpers/specification.js.map +1 -0
- package/build/helpers/status.d.ts +44 -0
- package/build/helpers/status.d.ts.map +1 -0
- package/build/helpers/status.js +78 -0
- package/build/helpers/status.js.map +1 -0
- package/build/helpers/validate.d.ts +30 -0
- package/build/helpers/validate.d.ts.map +1 -0
- package/build/helpers/validate.js +93 -0
- package/build/helpers/validate.js.map +1 -0
- package/build/i18n/be.json +51 -0
- package/build/i18n/de.json +51 -0
- package/build/i18n/en.json +51 -0
- package/build/i18n/es.json +51 -0
- package/build/i18n/pl.json +51 -0
- package/build/i18n/ru.json +51 -0
- package/build/i18n/uk.json +51 -0
- package/build/i18n.d.ts +2 -0
- package/build/i18n.d.ts.map +1 -0
- package/build/i18n.js +19 -0
- package/build/i18n.js.map +1 -0
- package/build/index.d.ts +10 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +9 -0
- package/build/index.js.map +1 -0
- package/build/models/index.d.ts +7 -0
- package/build/models/index.d.ts.map +1 -0
- package/build/models/index.js +19 -0
- package/build/models/index.js.map +1 -0
- package/build/models/project.d.ts +4 -0
- package/build/models/project.d.ts.map +1 -0
- package/build/models/project.js +17 -0
- package/build/models/project.js.map +1 -0
- package/build/models/specification.d.ts +4 -0
- package/build/models/specification.d.ts.map +1 -0
- package/build/models/specification.js +15 -0
- package/build/models/specification.js.map +1 -0
- package/build/models/workcard.d.ts +14 -0
- package/build/models/workcard.d.ts.map +1 -0
- package/build/models/workcard.js +103 -0
- package/build/models/workcard.js.map +1 -0
- package/build/queue/index.d.ts +25 -0
- package/build/queue/index.d.ts.map +1 -0
- package/build/queue/index.js +14 -0
- package/build/queue/index.js.map +1 -0
- package/build/registry.d.ts +10 -0
- package/build/registry.d.ts.map +1 -0
- package/build/registry.js +75 -0
- package/build/registry.js.map +1 -0
- package/build/schemas.d.ts +56 -0
- package/build/schemas.d.ts.map +1 -0
- package/build/schemas.js +578 -0
- package/build/schemas.js.map +1 -0
- package/build/types.d.ts +785 -0
- package/build/types.d.ts.map +1 -0
- package/build/types.js +2 -0
- package/build/types.js.map +1 -0
- package/package.json +58 -0
- package/src/consts.ts +136 -0
- package/src/entrypoints.ts +127 -0
- package/src/errors.ts +207 -0
- package/src/helpers/apply.ts +183 -0
- package/src/helpers/card.ts +42 -0
- package/src/helpers/changes.ts +333 -0
- package/src/helpers/code.ts +66 -0
- package/src/helpers/index.ts +8 -0
- package/src/helpers/query.ts +345 -0
- package/src/helpers/specification.ts +65 -0
- package/src/helpers/status.ts +117 -0
- package/src/helpers/validate.ts +107 -0
- package/src/i18n/be.json +51 -0
- package/src/i18n/de.json +51 -0
- package/src/i18n/en.json +51 -0
- package/src/i18n/es.json +51 -0
- package/src/i18n/pl.json +51 -0
- package/src/i18n/ru.json +51 -0
- package/src/i18n/uk.json +51 -0
- package/src/i18n.ts +21 -0
- package/src/index.ts +9 -0
- package/src/models/index.ts +21 -0
- package/src/models/project.ts +24 -0
- package/src/models/specification.ts +21 -0
- package/src/models/workcard.ts +138 -0
- package/src/queue/index.ts +31 -0
- package/src/registry.ts +90 -0
- package/src/schemas.ts +654 -0
- package/src/types.ts +837 -0
- package/tests/apply.spec.ts +176 -0
- package/tests/code.spec.ts +40 -0
- package/tests/entrypoints.spec.ts +57 -0
- package/tests/fixtures.ts +124 -0
- package/tests/i18n.spec.ts +30 -0
- package/tests/models.spec.ts +133 -0
- package/tests/query.spec.ts +90 -0
- package/tests/schemas.spec.ts +74 -0
- package/tests/status.spec.ts +75 -0
- package/tests/tsconfig.json +17 -0
- package/tsconfig.json +15 -0
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { IntrinsicStatus, TransitionAction, WorkcardKind } from '../src/consts.js'
|
|
3
|
+
import { IllegalTransition, PlanningError } from '../src/errors.js'
|
|
4
|
+
import { applyRelationship, applyTransition } from '../src/helpers/apply.js'
|
|
5
|
+
import { assertMutable, computeChanges, isEmptyChange } from '../src/helpers/changes.js'
|
|
6
|
+
import type { Relationship, Specification, Workcard } from '../src/types.js'
|
|
7
|
+
import {
|
|
8
|
+
AT, ENTITY, LATER, PROJECT_TYPE, SPEC_TYPE, STORY_TYPE, TASK_TYPE, makeRegistry, transitionOf,
|
|
9
|
+
} from './fixtures.js'
|
|
10
|
+
|
|
11
|
+
const registry = makeRegistry()
|
|
12
|
+
|
|
13
|
+
const created = (): Workcard => {
|
|
14
|
+
const { changes } = computeChanges(undefined, {
|
|
15
|
+
action: TransitionAction.Create,
|
|
16
|
+
card: {
|
|
17
|
+
kind: WorkcardKind.Card, type: STORY_TYPE.type, parent: 'p1', title: 'As a user I sign in',
|
|
18
|
+
fields: { area: 'user', primary: false }, code: 'US-ABCDE',
|
|
19
|
+
},
|
|
20
|
+
}, STORY_TYPE, registry, AT)
|
|
21
|
+
|
|
22
|
+
return applyTransition(undefined, transitionOf({ card: 'c1', seq: 1, action: TransitionAction.Create, changes }))!
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('applyTransition — the fold', () => {
|
|
26
|
+
test('create builds the whole record from the changes and the transition identity', () => {
|
|
27
|
+
const card = created()
|
|
28
|
+
|
|
29
|
+
expect(card).toEqual({
|
|
30
|
+
id: 'c1', kind: WorkcardKind.Card, type: STORY_TYPE.type, entityId: ENTITY,
|
|
31
|
+
title: 'As a user I sign in', code: 'US-ABCDE', parent: 'p1', parents: ['p1'], labels: [],
|
|
32
|
+
fields: { area: 'user', primary: false }, flows: { 'test:story': 'planned' },
|
|
33
|
+
status: 'planned', intrinsic: IntrinsicStatus.Planned,
|
|
34
|
+
seq: 1, head: 1, createdAt: AT, updatedAt: AT,
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('update merges fields, replaces the rest, clears unset — and never mutates its input', () => {
|
|
39
|
+
const card = created()
|
|
40
|
+
const before = structuredClone(card)
|
|
41
|
+
const next = applyTransition(card, transitionOf({
|
|
42
|
+
card: 'c1', seq: 2, action: TransitionAction.Update, at: LATER,
|
|
43
|
+
changes: { title: 'Renamed', fields: { warning: 'x' }, labels: ['ui'] },
|
|
44
|
+
unset: ['code', 'fields.primary'],
|
|
45
|
+
}))!
|
|
46
|
+
|
|
47
|
+
expect(card).toEqual(before)
|
|
48
|
+
expect(next.title).toBe('Renamed')
|
|
49
|
+
expect(next.fields).toEqual({ area: 'user', warning: 'x' })
|
|
50
|
+
expect(next.labels).toEqual(['ui'])
|
|
51
|
+
expect(next.code).toBeUndefined()
|
|
52
|
+
expect([next.seq, next.head, next.updatedAt, next.createdAt]).toEqual([2, 2, LATER, AT])
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('an applied seq returns the card unchanged; a gap or a missing card is refused', () => {
|
|
56
|
+
const card = { ...created(), head: 3 }
|
|
57
|
+
|
|
58
|
+
expect(applyTransition(card, transitionOf({ card: 'c1', seq: 1, action: TransitionAction.Update }))).toBe(card)
|
|
59
|
+
expect(() => applyTransition(card, transitionOf({ card: 'c1', seq: 3, action: TransitionAction.Update })))
|
|
60
|
+
.toThrow(PlanningError)
|
|
61
|
+
expect(() => applyTransition(undefined, transitionOf({ card: 'c1', seq: 2, action: TransitionAction.Update })))
|
|
62
|
+
.toThrow('fold:out-of-order')
|
|
63
|
+
expect(applyTransition({ ...card, seq: 1 }, transitionOf({ card: 'c1', seq: 2, action: TransitionAction.Link, at: LATER })))
|
|
64
|
+
.toMatchObject({ seq: 2, head: 3, updatedAt: LATER, title: card.title })
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('delete answers null, and a revised document carries its new revision', () => {
|
|
68
|
+
const card = created()
|
|
69
|
+
expect(applyTransition(card, transitionOf({ card: 'c1', seq: 2, action: TransitionAction.Delete }))).toBeNull()
|
|
70
|
+
|
|
71
|
+
const spec = { ...card, kind: WorkcardKind.Specification, category: 'design', format: 'json', body: '{}', revision: 1 } as Specification
|
|
72
|
+
const { changes } = computeChanges(spec, { card: 'c1', action: TransitionAction.Update, changes: { body: '{"a":1}' } },
|
|
73
|
+
SPEC_TYPE, registry, LATER, { slot: STORY_TYPE.specifications[0] })
|
|
74
|
+
const revised = applyTransition(spec, transitionOf({ card: 'c1', seq: 2, action: TransitionAction.Update, changes })) as Specification
|
|
75
|
+
|
|
76
|
+
expect([revised.revision, revised.body, revised.bodyChars]).toEqual([2, '{"a":1}', 7])
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
describe('applyRelationship', () => {
|
|
81
|
+
test('a create folds its links, from the card unless a draft names another end', () => {
|
|
82
|
+
const links = applyRelationship([], transitionOf({
|
|
83
|
+
id: 't1', card: 'c2', seq: 1, action: TransitionAction.Create, project: 'p1',
|
|
84
|
+
links: [{ type: 'follows', to: 'c1' }, { type: 'follows', from: 'c0', to: 'c2' }],
|
|
85
|
+
}))
|
|
86
|
+
|
|
87
|
+
expect(links).toEqual([
|
|
88
|
+
{ entityId: ENTITY, type: 'follows', from: 'c2', to: 'c1', project: 'p1', createdAt: AT, transition: 't1' },
|
|
89
|
+
{ entityId: ENTITY, type: 'follows', from: 'c0', to: 'c2', project: 'p1', createdAt: AT, transition: 't1' },
|
|
90
|
+
])
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('link is idempotent and unlink removes only the matching edge', () => {
|
|
94
|
+
const link = transitionOf({ card: 'c2', seq: 2, action: TransitionAction.Link, link: { type: 'follows', to: 'c1' } })
|
|
95
|
+
const once = applyRelationship([], link)
|
|
96
|
+
const twice = applyRelationship(once, link)
|
|
97
|
+
const other = applyRelationship(twice, { ...link, link: { type: 'blocks', to: 'c1' } })
|
|
98
|
+
|
|
99
|
+
expect(twice).toHaveLength(1)
|
|
100
|
+
expect(applyRelationship(other, { ...link, action: TransitionAction.Unlink }).map(edge => edge.type))
|
|
101
|
+
.toEqual(['blocks'])
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('a delete removes every edge touching the card and keeps the rest', () => {
|
|
105
|
+
const edge = (from: string, to: string): Relationship => ({ entityId: ENTITY, type: 'follows', from, to, createdAt: AT })
|
|
106
|
+
const links = [edge('c1', 'c2'), edge('c3', 'c1'), edge('c3', 'c4')]
|
|
107
|
+
|
|
108
|
+
expect(applyRelationship(links, transitionOf({ card: 'c1', seq: 5, action: TransitionAction.Delete })))
|
|
109
|
+
.toEqual([edge('c3', 'c4')])
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
describe('computeChanges', () => {
|
|
114
|
+
test('an update records only what differs, and one that changes nothing is empty', () => {
|
|
115
|
+
const card = created()
|
|
116
|
+
const set = computeChanges(card, {
|
|
117
|
+
card: 'c1', action: TransitionAction.Update,
|
|
118
|
+
changes: { title: card.title, description: 'New', fields: { area: 'user', primary: true } },
|
|
119
|
+
}, STORY_TYPE, registry, LATER)
|
|
120
|
+
|
|
121
|
+
expect(set).toEqual({ changes: { description: 'New', fields: { primary: true } }, unset: [] })
|
|
122
|
+
expect(isEmptyChange(computeChanges(card, {
|
|
123
|
+
card: 'c1', action: TransitionAction.Update, changes: { title: card.title, code: null as never },
|
|
124
|
+
}, STORY_TYPE, registry, LATER))).toBe(false)
|
|
125
|
+
expect(isEmptyChange(computeChanges(card, {
|
|
126
|
+
card: 'c1', action: TransitionAction.Update, changes: { title: card.title, description: null as never },
|
|
127
|
+
}, STORY_TYPE, registry, LATER))).toBe(true)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test('a transit moves the flow, mirrors status and intrinsic, and sets or clears closedAt', () => {
|
|
131
|
+
const card = { ...created(), status: 'in-progress', flows: { 'test:story': 'in-progress' }, intrinsic: IntrinsicStatus.InProgress }
|
|
132
|
+
const completed = computeChanges(card, { card: 'c1', action: TransitionAction.Transit, transition: 'complete' }, STORY_TYPE, registry, LATER)
|
|
133
|
+
|
|
134
|
+
expect(completed).toEqual({
|
|
135
|
+
changes: { flows: { 'test:story': 'completed' }, status: 'completed', intrinsic: IntrinsicStatus.Closed, closedAt: LATER },
|
|
136
|
+
unset: [], flow: 'test:story', from: 'in-progress', to: 'completed',
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
const closed = { ...card, status: 'completed', flows: { 'test:story': 'completed' }, intrinsic: IntrinsicStatus.Closed, closedAt: LATER }
|
|
140
|
+
expect(computeChanges(closed, { card: 'c1', action: TransitionAction.Transit, transition: 'reset' }, STORY_TYPE, registry, LATER).unset)
|
|
141
|
+
.toEqual(['closedAt'])
|
|
142
|
+
expect(() => computeChanges(closed, { card: 'c1', action: TransitionAction.Transit, transition: 'start' }, STORY_TYPE, registry, LATER))
|
|
143
|
+
.toThrow(IllegalTransition)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
test('a non-primary transit under IntrinsicPolicy.All keeps the card open until every flow closes', () => {
|
|
147
|
+
const task = { ...created(), type: TASK_TYPE.type, status: 'completed', intrinsic: IntrinsicStatus.Planned,
|
|
148
|
+
flows: { 'test:story': 'completed', 'test:review': 'reviewing' } }
|
|
149
|
+
const set = computeChanges(task, { card: 'c1', action: TransitionAction.Transit, transition: 'approve', flow: 'test:review' },
|
|
150
|
+
TASK_TYPE, registry, LATER)
|
|
151
|
+
|
|
152
|
+
expect(set.changes).toEqual({ flows: { 'test:review': 'approved' }, intrinsic: IntrinsicStatus.Closed, closedAt: LATER })
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
test('a create of a revisioned document starts at revision 1 with its body length', () => {
|
|
156
|
+
const { changes } = computeChanges(undefined, {
|
|
157
|
+
action: TransitionAction.Create,
|
|
158
|
+
card: { kind: WorkcardKind.Specification, type: SPEC_TYPE.type, parent: 'p1', title: 'scaffold', category: 'scaffold', body: '{}' },
|
|
159
|
+
}, SPEC_TYPE, registry, AT, { slot: PROJECT_TYPE.specifications[1] })
|
|
160
|
+
|
|
161
|
+
expect(changes).toMatchObject({ category: 'scaffold', format: 'json', body: '{}', bodyChars: 2, revision: 1, parents: ['p1'] })
|
|
162
|
+
})
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
describe('assertMutable', () => {
|
|
166
|
+
test('status moves only through transit, a fixed code stays fixed, derived fields are never supplied', () => {
|
|
167
|
+
expect(() => assertMutable({ card: 'c1', action: TransitionAction.Update, changes: { status: 'completed' } }, STORY_TYPE))
|
|
168
|
+
.toThrow('immutable:status')
|
|
169
|
+
expect(() => assertMutable({ card: 'c1', action: TransitionAction.Update, changes: { code: 'US-X' } }, STORY_TYPE))
|
|
170
|
+
.toThrow('immutable:code')
|
|
171
|
+
expect(() => assertMutable({ card: 'c1', action: TransitionAction.Update, unset: ['title'] }, STORY_TYPE))
|
|
172
|
+
.toThrow('immutable:title')
|
|
173
|
+
expect(() => assertMutable({ card: 'c1', action: TransitionAction.Update, changes: { code: 'free' } }, TASK_TYPE))
|
|
174
|
+
.not.toThrow()
|
|
175
|
+
})
|
|
176
|
+
})
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { CodeScope, CodeStyle } from '../src/consts.js'
|
|
3
|
+
import { CodeTaken } from '../src/errors.js'
|
|
4
|
+
import { codeScopeOf, mintCode } from '../src/helpers/code.js'
|
|
5
|
+
import { STORY_TYPE } from './fixtures.js'
|
|
6
|
+
|
|
7
|
+
const free = () => false
|
|
8
|
+
|
|
9
|
+
describe('mintCode', () => {
|
|
10
|
+
test('random: prefix plus an upper-cased Base58 part of the policy length', async () => {
|
|
11
|
+
const code = await mintCode(STORY_TYPE.code!, free)
|
|
12
|
+
|
|
13
|
+
expect(code).toMatch(/^US-[1-9A-Z]{5}$/)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test('slug: derived from the seed, then suffixed while taken', async () => {
|
|
17
|
+
const policy = { style: CodeStyle.Slug, uniqueWithin: CodeScope.Entity }
|
|
18
|
+
const taken = new Set(['shop-dashboard', 'shop-dashboard-2'])
|
|
19
|
+
|
|
20
|
+
expect(await mintCode(policy, free, 8, { seed: 'Shop Dashboard!' })).toBe('shop-dashboard')
|
|
21
|
+
expect(await mintCode(policy, code => taken.has(code), 8, { seed: 'Shop Dashboard!' })).toBe('shop-dashboard-3')
|
|
22
|
+
expect(await mintCode(policy, free)).toMatch(/^[a-z]+-[a-z]+$/)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('sequential: counts on from the scope, zero-padded to the length', async () => {
|
|
26
|
+
const policy = { prefix: 'REQ-', style: CodeStyle.Sequential, length: 3, uniqueWithin: CodeScope.Parent }
|
|
27
|
+
|
|
28
|
+
expect(await mintCode(policy, free, 8, { count: 41 })).toBe('REQ-042')
|
|
29
|
+
expect(await mintCode(policy, async code => code === 'REQ-042', 8, { count: 41 })).toBe('REQ-043')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('refuses with CodeTaken after the attempt cap, asking exactly that many times', async () => {
|
|
33
|
+
let asked = 0
|
|
34
|
+
const always = async () => { asked++; return true }
|
|
35
|
+
|
|
36
|
+
await expect(mintCode(STORY_TYPE.code!, always, 3)).rejects.toThrow(CodeTaken)
|
|
37
|
+
expect(asked).toBe(3)
|
|
38
|
+
expect(codeScopeOf(STORY_TYPE.code!, { parent: 'p1' })).toEqual({ parent: 'p1' })
|
|
39
|
+
})
|
|
40
|
+
})
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { protocols } from '@owlmeans/entrypoint'
|
|
3
|
+
import { RouteMethod, RouteProtocols } from '@owlmeans/route'
|
|
4
|
+
import { makePlanningProtocols } from '../src/entrypoints.js'
|
|
5
|
+
import { PLANNING_PATH, planningAliases } from '../src/consts.js'
|
|
6
|
+
|
|
7
|
+
const BASE = 'app:api:planning'
|
|
8
|
+
|
|
9
|
+
describe('makePlanningProtocols', () => {
|
|
10
|
+
test('declares every leaf once, each alias derived from the base alias', () => {
|
|
11
|
+
const tree = makePlanningProtocols({ base: { alias: BASE }, guards: 'guard:default' })
|
|
12
|
+
const aliases = planningAliases(BASE)
|
|
13
|
+
const declared = protocols(tree as never).map(protocol => protocol.alias)
|
|
14
|
+
|
|
15
|
+
expect(new Set(declared).size).toBe(declared.length)
|
|
16
|
+
expect(declared.sort()).toEqual([
|
|
17
|
+
aliases.base, aliases.schema.list, aliases.card.list, aliases.card.summary, aliases.card.get,
|
|
18
|
+
aliases.card.transitions, aliases.card.specifications, aliases.spec.get, aliases.spec.revisions,
|
|
19
|
+
aliases.link.list, aliases.transition.get, aliases.execute, aliases.commit.get, aliases.commit.events,
|
|
20
|
+
].sort())
|
|
21
|
+
expect(declared.every(alias => alias.startsWith(`${BASE}`))).toBe(true)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test('the guard, gate, parent and service sit on the base alone; leaves hang under it', () => {
|
|
25
|
+
const gate = { alias: 'gate:owner', params: ['vib-project-{entity}'] }
|
|
26
|
+
const tree = makePlanningProtocols({
|
|
27
|
+
base: { alias: BASE, parent: 'app:api', service: 'api' }, guards: ['guard:default'], gate,
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
expect(tree.base.guards).toEqual(['guard:default'])
|
|
31
|
+
expect(tree.base.gate).toEqual(gate)
|
|
32
|
+
expect(tree.base.route.route).toMatchObject({ path: PLANNING_PATH, parent: 'app:api', service: 'api' })
|
|
33
|
+
expect(tree.card.get.guards).toEqual([])
|
|
34
|
+
expect(tree.card.get.route.route.parent).toBe(BASE)
|
|
35
|
+
expect(tree.execute.route.route.method).toBe(RouteMethod.POST)
|
|
36
|
+
expect(tree.card.list.route.route.method).toBe(RouteMethod.GET)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('static /cards/summary is declared before parametric /cards/:id', () => {
|
|
40
|
+
const tree = makePlanningProtocols({ base: { alias: BASE }, guards: 'guard:default' })
|
|
41
|
+
const order = Object.keys(tree.card)
|
|
42
|
+
|
|
43
|
+
expect(tree.card.summary.route.route.path).toBe('/cards/summary')
|
|
44
|
+
expect(tree.card.get.route.route.path).toBe('/cards/:id')
|
|
45
|
+
expect(order.indexOf('summary')).toBeLessThan(order.indexOf('get'))
|
|
46
|
+
expect(tree.card.list.contract?.requestSchemas.query).toBeDefined()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('the commit feed is a socket under socketBase when given, under the planning base otherwise', () => {
|
|
50
|
+
const own = makePlanningProtocols({ base: { alias: BASE }, guards: 'guard:default' })
|
|
51
|
+
const shared = makePlanningProtocols({ base: { alias: BASE }, guards: 'guard:default', socketBase: 'app:update' })
|
|
52
|
+
|
|
53
|
+
expect(own.commit.events.route.route).toMatchObject({ protocol: RouteProtocols.SOCKET, parent: BASE, path: '/commits' })
|
|
54
|
+
expect(shared.commit.events.route.route.parent).toBe('app:update')
|
|
55
|
+
expect(Object.isFrozen(shared) && Object.isFrozen(shared.commit.events)).toBe(true)
|
|
56
|
+
})
|
|
57
|
+
})
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CodeScope, CodeStyle, CommitState, IntrinsicPolicy, IntrinsicStatus, SpecificationFormat,
|
|
3
|
+
TransitionAction, WorkcardKind,
|
|
4
|
+
} from '../src/consts.js'
|
|
5
|
+
import { makeSchemaRegistry } from '../src/registry.js'
|
|
6
|
+
import type {
|
|
7
|
+
PlanningSchemaRegistry, ProjectTypeSchema, StatusFlowSchema, Transition, WorkcardTypeSchema,
|
|
8
|
+
} from '../src/types.js'
|
|
9
|
+
|
|
10
|
+
export const ENTITY = 'entity-1'
|
|
11
|
+
export const AT = '2026-09-16T10:00:00.000Z'
|
|
12
|
+
export const LATER = '2026-09-16T11:00:00.000Z'
|
|
13
|
+
|
|
14
|
+
/** The story shape: `start` declared twice (from planned, from failed), `reset` from anywhere. */
|
|
15
|
+
export const STORY_FLOW: StatusFlowSchema = {
|
|
16
|
+
id: 'test:story',
|
|
17
|
+
version: 1,
|
|
18
|
+
statuses: [
|
|
19
|
+
{ key: 'planned', intrinsic: IntrinsicStatus.Planned, initial: true },
|
|
20
|
+
{ key: 'in-progress', intrinsic: IntrinsicStatus.InProgress },
|
|
21
|
+
{ key: 'completed', intrinsic: IntrinsicStatus.Closed, terminal: true },
|
|
22
|
+
{ key: 'failed', intrinsic: IntrinsicStatus.Planned },
|
|
23
|
+
],
|
|
24
|
+
transitions: [
|
|
25
|
+
{ name: 'start', from: ['planned'], to: 'in-progress', explicit: true },
|
|
26
|
+
{ name: 'complete', from: ['in-progress'], to: 'completed' },
|
|
27
|
+
{ name: 'fail', from: ['in-progress'], to: 'failed' },
|
|
28
|
+
{ name: 'reset', from: '*', to: 'planned', explicit: true },
|
|
29
|
+
{ name: 'start', from: ['failed'], to: 'in-progress', explicit: true },
|
|
30
|
+
],
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A second flow: its `reopen` wildcard is declared BEFORE the specific rule it must lose to. */
|
|
34
|
+
export const REVIEW_FLOW: StatusFlowSchema = {
|
|
35
|
+
id: 'test:review',
|
|
36
|
+
version: 1,
|
|
37
|
+
statuses: [
|
|
38
|
+
{ key: 'pending', intrinsic: IntrinsicStatus.Planned, initial: true },
|
|
39
|
+
{ key: 'reviewing', intrinsic: IntrinsicStatus.InProgress },
|
|
40
|
+
{ key: 'approved', intrinsic: IntrinsicStatus.Closed },
|
|
41
|
+
],
|
|
42
|
+
transitions: [
|
|
43
|
+
{ name: 'review', from: ['pending'], to: 'reviewing' },
|
|
44
|
+
{ name: 'approve', from: ['reviewing'], to: 'approved' },
|
|
45
|
+
{ name: 'reopen', from: '*', to: 'pending' },
|
|
46
|
+
{ name: 'reopen', from: ['approved'], to: 'reviewing' },
|
|
47
|
+
],
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const STORY_TYPE: WorkcardTypeSchema = {
|
|
51
|
+
type: 'test:story',
|
|
52
|
+
kind: WorkcardKind.Card,
|
|
53
|
+
version: 1,
|
|
54
|
+
fields: {
|
|
55
|
+
type: 'object',
|
|
56
|
+
properties: {
|
|
57
|
+
area: { type: 'string', enum: ['guest', 'user'] },
|
|
58
|
+
primary: { type: 'boolean' },
|
|
59
|
+
warning: { type: 'string', nullable: true },
|
|
60
|
+
},
|
|
61
|
+
required: ['area', 'primary'],
|
|
62
|
+
additionalProperties: false,
|
|
63
|
+
},
|
|
64
|
+
flows: [STORY_FLOW.id],
|
|
65
|
+
specifications: [
|
|
66
|
+
{ category: 'design', format: SpecificationFormat.Json, revisioned: true, keepRevisions: 3 },
|
|
67
|
+
],
|
|
68
|
+
relationships: [{ name: 'follows', single: true }],
|
|
69
|
+
labels: ['ui', 'api'],
|
|
70
|
+
code: { prefix: 'US-', style: CodeStyle.Random, length: 5, uppercase: true, uniqueWithin: CodeScope.Parent },
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Runs both flows; closed only when both are. */
|
|
74
|
+
export const TASK_TYPE: WorkcardTypeSchema = {
|
|
75
|
+
type: 'test:task',
|
|
76
|
+
kind: WorkcardKind.Card,
|
|
77
|
+
version: 1,
|
|
78
|
+
fields: { type: 'object', additionalProperties: true },
|
|
79
|
+
flows: [STORY_FLOW.id, REVIEW_FLOW.id],
|
|
80
|
+
intrinsic: IntrinsicPolicy.All,
|
|
81
|
+
specifications: [],
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export const PROJECT_TYPE: ProjectTypeSchema = {
|
|
85
|
+
type: 'test:project',
|
|
86
|
+
kind: WorkcardKind.Project,
|
|
87
|
+
version: 1,
|
|
88
|
+
fields: { type: 'object', additionalProperties: true },
|
|
89
|
+
flows: [STORY_FLOW.id],
|
|
90
|
+
specifications: [
|
|
91
|
+
{ category: 'specification', format: SpecificationFormat.Markdown, required: true },
|
|
92
|
+
{ category: 'scaffold', format: SpecificationFormat.Json, revisioned: true, keepRevisions: 3 },
|
|
93
|
+
],
|
|
94
|
+
cardTypes: [STORY_TYPE.type, TASK_TYPE.type],
|
|
95
|
+
code: { style: CodeStyle.Slug, uniqueWithin: CodeScope.Entity },
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export const SPEC_TYPE: WorkcardTypeSchema = {
|
|
99
|
+
type: 'test:spec',
|
|
100
|
+
kind: WorkcardKind.Specification,
|
|
101
|
+
version: 1,
|
|
102
|
+
fields: { type: 'object', additionalProperties: true },
|
|
103
|
+
flows: [STORY_FLOW.id],
|
|
104
|
+
specifications: [],
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export const makeRegistry = (): PlanningSchemaRegistry => makeSchemaRegistry({
|
|
108
|
+
flows: [STORY_FLOW, REVIEW_FLOW],
|
|
109
|
+
types: [STORY_TYPE, TASK_TYPE, PROJECT_TYPE, SPEC_TYPE],
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
/** A transition with everything a fold does not care about filled in. */
|
|
113
|
+
export const transitionOf = (partial: Partial<Transition> & Pick<Transition, 'card' | 'seq' | 'action'>): Transition => ({
|
|
114
|
+
entityId: ENTITY,
|
|
115
|
+
kind: WorkcardKind.Card,
|
|
116
|
+
type: STORY_TYPE.type,
|
|
117
|
+
changes: {},
|
|
118
|
+
actor: {},
|
|
119
|
+
at: AT,
|
|
120
|
+
commit: { state: CommitState.Pending },
|
|
121
|
+
...partial,
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
export { TransitionAction }
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { SUPPORTED_LNGS } from '@owlmeans/i18n'
|
|
3
|
+
import * as errors from '../src/errors.js'
|
|
4
|
+
|
|
5
|
+
const keysOf = (node: unknown, prefix: string = ''): string[] => node != null && typeof node === 'object'
|
|
6
|
+
? Object.entries(node).flatMap(([key, value]) => keysOf(value, `${prefix}${key}.`))
|
|
7
|
+
: [prefix.slice(0, -1)]
|
|
8
|
+
|
|
9
|
+
const load = async (lng: string): Promise<unknown> =>
|
|
10
|
+
(await import(`../src/i18n/${lng}.json`, { with: { type: 'json' } })).default
|
|
11
|
+
|
|
12
|
+
describe('i18n', () => {
|
|
13
|
+
test('all seven languages carry exactly the same keys, none empty', async () => {
|
|
14
|
+
const english = keysOf(await load('en')).sort()
|
|
15
|
+
|
|
16
|
+
expect(SUPPORTED_LNGS).toHaveLength(7)
|
|
17
|
+
for (const lng of SUPPORTED_LNGS) {
|
|
18
|
+
const data = await load(lng)
|
|
19
|
+
expect(keysOf(data).sort()).toEqual(english)
|
|
20
|
+
expect(JSON.stringify(data)).not.toContain('""')
|
|
21
|
+
}
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test('every registered refusal class has an error text', async () => {
|
|
25
|
+
const texts = (await load('en') as { errors: Record<string, string> }).errors
|
|
26
|
+
const types = Object.values(errors).map(errorClass => (errorClass as { typeName: string }).typeName)
|
|
27
|
+
|
|
28
|
+
expect(types.filter(type => texts[type] == null)).toEqual([])
|
|
29
|
+
})
|
|
30
|
+
})
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { applyQuery } from '@owlmeans/resource'
|
|
3
|
+
import { CommitState, TransitionAction, WorkcardKind } from '../src/consts.js'
|
|
4
|
+
import { WorkcardNotFound } from '../src/errors.js'
|
|
5
|
+
import { applyTransition } from '../src/helpers/apply.js'
|
|
6
|
+
import { computeChanges, isEmptyChange } from '../src/helpers/changes.js'
|
|
7
|
+
import { criteriaOf, summaryOf } from '../src/helpers/query.js'
|
|
8
|
+
import { currentSpecification, slotOf } from '../src/helpers/specification.js'
|
|
9
|
+
import { makeProjectModel, makeWorkcardModel, modelOf } from '../src/models/index.js'
|
|
10
|
+
import type {
|
|
11
|
+
PlanningFacade, Project, Specification, TransitionExecution, Workcard, WorkcardDraft,
|
|
12
|
+
} from '../src/types.js'
|
|
13
|
+
import { AT, ENTITY, PROJECT_TYPE, STORY_TYPE, makeRegistry } from './fixtures.js'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The smallest facade the models can run against: maps, folded synchronously with the package's
|
|
17
|
+
* own helpers. It records every execution so a spec can read what a model sent.
|
|
18
|
+
*/
|
|
19
|
+
const makeFacade = () => {
|
|
20
|
+
const schemas = makeRegistry()
|
|
21
|
+
const cards = new Map<string, Workcard>()
|
|
22
|
+
const executed: TransitionExecution[] = []
|
|
23
|
+
let next = 0
|
|
24
|
+
|
|
25
|
+
const facade: PlanningFacade = {
|
|
26
|
+
scope: { entityId: ENTITY },
|
|
27
|
+
schemas,
|
|
28
|
+
cards: {
|
|
29
|
+
get: async id => cards.get(id) ?? Promise.reject(new WorkcardNotFound(id)),
|
|
30
|
+
load: async id => cards.get(id) ?? null,
|
|
31
|
+
list: async query => applyQuery([...cards.values()], criteriaOf(query, facade.scope)),
|
|
32
|
+
count: async query => applyQuery([...cards.values()], criteriaOf(query, facade.scope)).total,
|
|
33
|
+
summary: async (parents, query) =>
|
|
34
|
+
summaryOf(applyQuery([...cards.values()], criteriaOf(query, facade.scope)).items, parents),
|
|
35
|
+
},
|
|
36
|
+
specifications: {
|
|
37
|
+
current: async (parent, category) => currentSpecification([...cards.values()].filter(card => card.parent === parent), category),
|
|
38
|
+
list: async parent => applyQuery([...cards.values()], { parent, kind: WorkcardKind.Specification }) as never,
|
|
39
|
+
get: async id => cards.get(id) as Specification,
|
|
40
|
+
revisions: async () => [],
|
|
41
|
+
},
|
|
42
|
+
relationships: { list: async () => ({ items: [], total: 0 }) },
|
|
43
|
+
transitions: { get: async id => Promise.reject(new WorkcardNotFound(id)), list: async () => ({ items: [], total: 0 }) },
|
|
44
|
+
commits: {
|
|
45
|
+
status: async transition => ({ transition, state: CommitState.Committed }),
|
|
46
|
+
subscribe: () => () => undefined,
|
|
47
|
+
wait: async () => null,
|
|
48
|
+
},
|
|
49
|
+
execute: async exec => {
|
|
50
|
+
executed.push(exec)
|
|
51
|
+
const card = typeof exec.card === 'string' ? cards.get(exec.card) : undefined
|
|
52
|
+
const draft = typeof exec.card === 'string' ? undefined : exec.card as WorkcardDraft
|
|
53
|
+
const type = schemas.type(card?.type ?? draft!.type)
|
|
54
|
+
const parent = cards.get(card?.parent ?? draft?.parent ?? '')
|
|
55
|
+
const category = (card as Specification | undefined)?.category ?? draft?.category
|
|
56
|
+
const slot = parent != null && category != null ? slotOf(schemas.type(parent.type), category) : undefined
|
|
57
|
+
const set = computeChanges(card, exec, type, schemas, AT, { slot })
|
|
58
|
+
const id = card?.id ?? `card-${++next}`
|
|
59
|
+
const transition = {
|
|
60
|
+
id: `t-${++next}`, entityId: ENTITY, card: id, kind: card?.kind ?? draft!.kind, type: type.type,
|
|
61
|
+
seq: (card?.head ?? card?.seq ?? 0) + 1, action: exec.action, changes: set.changes, unset: set.unset,
|
|
62
|
+
actor: {}, at: AT, commit: { state: CommitState.Committed },
|
|
63
|
+
}
|
|
64
|
+
if (exec.action === TransitionAction.Update && isEmptyChange(set)) {
|
|
65
|
+
return { transition, card, committed: async () => card ?? null }
|
|
66
|
+
}
|
|
67
|
+
const folded = applyTransition(card, transition)
|
|
68
|
+
folded == null ? cards.delete(id) : cards.set(id, folded)
|
|
69
|
+
return { transition, card: folded, committed: async () => folded }
|
|
70
|
+
},
|
|
71
|
+
model: async card => modelOf(typeof card === 'string' ? await facade.cards.get(card) as never : card, facade),
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const seed = async (draft: WorkcardDraft): Promise<Workcard> =>
|
|
75
|
+
(await facade.execute({ action: TransitionAction.Create, card: draft })).card!
|
|
76
|
+
|
|
77
|
+
return { facade, executed, cards, seed }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
describe('workcard model', () => {
|
|
81
|
+
test('expectSeq defaults to the head, then the seq; null opts out and a given value wins', async () => {
|
|
82
|
+
const { facade, executed, seed } = makeFacade()
|
|
83
|
+
const card = await seed({ kind: WorkcardKind.Card, type: STORY_TYPE.type, title: 'Story', fields: { area: 'user', primary: false } })
|
|
84
|
+
|
|
85
|
+
await makeWorkcardModel({ ...card, head: 4 }, facade).update({ title: 'A' })
|
|
86
|
+
await makeWorkcardModel(card, facade).transit('start')
|
|
87
|
+
await makeWorkcardModel(card, facade).link('follows', 'x', undefined, { expectSeq: 9 })
|
|
88
|
+
await makeWorkcardModel(card, facade).remove({ expectSeq: null })
|
|
89
|
+
|
|
90
|
+
expect(executed.slice(1).map(exec => exec.expectSeq)).toEqual([4, 1, 9, null])
|
|
91
|
+
expect(executed[2]).toMatchObject({ card: card.id, action: TransitionAction.Transit, transition: 'start' })
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test('can and available answer from the registry with no I/O', async () => {
|
|
95
|
+
const { facade, seed } = makeFacade()
|
|
96
|
+
const model = makeWorkcardModel(await seed({ kind: WorkcardKind.Card, type: STORY_TYPE.type, title: 'Story', fields: { area: 'user', primary: false } }), facade)
|
|
97
|
+
|
|
98
|
+
expect(model.can('start')).toBe(true)
|
|
99
|
+
expect(model.can('complete')).toBe(false)
|
|
100
|
+
expect(model.available().map(rule => rule.name)).toEqual(['start', 'reset'])
|
|
101
|
+
expect(model.pending()).toBe(false)
|
|
102
|
+
})
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
describe('write', () => {
|
|
106
|
+
test('creates the slot\'s document when empty and revises the same record when filled', async () => {
|
|
107
|
+
const { facade, executed, cards, seed } = makeFacade()
|
|
108
|
+
const project = await seed({ kind: WorkcardKind.Project, type: PROJECT_TYPE.type, title: 'Shop' })
|
|
109
|
+
const model = makeProjectModel(project as Project, facade)
|
|
110
|
+
|
|
111
|
+
// The spec type is resolved from the registry: the only specification type registered.
|
|
112
|
+
const first = await model.write('scaffold', '{"a":1}')
|
|
113
|
+
const second = await model.write('scaffold', '{"a":2}')
|
|
114
|
+
const specs = [...cards.values()].filter(card => card.kind === WorkcardKind.Specification) as Specification[]
|
|
115
|
+
|
|
116
|
+
expect(executed.slice(1).map(exec => exec.action)).toEqual([TransitionAction.Create, TransitionAction.Update])
|
|
117
|
+
expect(executed[1].card).toMatchObject({ kind: WorkcardKind.Specification, type: 'test:spec', parent: project.id, category: 'scaffold', format: 'json' })
|
|
118
|
+
expect(executed[2]).toMatchObject({ card: first.card!.id, expectSeq: 1, changes: { body: '{"a":2}' } })
|
|
119
|
+
expect(specs.map(spec => [spec.revision, spec.body])).toEqual([[2, '{"a":2}']])
|
|
120
|
+
expect(second.card!.id).toBe(first.card!.id)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
test('a project model summarises its direct cards', async () => {
|
|
124
|
+
const { facade, seed } = makeFacade()
|
|
125
|
+
const project = await seed({ kind: WorkcardKind.Project, type: PROJECT_TYPE.type, title: 'Shop' })
|
|
126
|
+
await seed({ kind: WorkcardKind.Card, type: STORY_TYPE.type, parent: project.id, title: 'Story', fields: { area: 'user', primary: false } })
|
|
127
|
+
|
|
128
|
+
const model = await facade.model(project) as unknown as ReturnType<typeof makeProjectModel>
|
|
129
|
+
|
|
130
|
+
expect(await model.summary()).toEqual({ total: 1, planned: 1, 'in-progress': 0, closed: 0 })
|
|
131
|
+
expect((await model.cards()).total).toBe(1)
|
|
132
|
+
})
|
|
133
|
+
})
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { applyQuery } from '@owlmeans/resource'
|
|
3
|
+
import { IntrinsicStatus, WorkcardKind } from '../src/consts.js'
|
|
4
|
+
import { PlanningError } from '../src/errors.js'
|
|
5
|
+
import {
|
|
6
|
+
criteriaOf, decodeSummaryQuery, decodeWorkcardQuery, encodeSummaryQuery, encodeWorkcardQuery, listOptionsOf,
|
|
7
|
+
summaryOf,
|
|
8
|
+
} from '../src/helpers/query.js'
|
|
9
|
+
import type { Workcard, WorkcardQuery } from '../src/types.js'
|
|
10
|
+
import { AT, ENTITY } from './fixtures.js'
|
|
11
|
+
|
|
12
|
+
const card = (id: string, extra: Partial<Workcard>): Workcard => ({
|
|
13
|
+
id, kind: WorkcardKind.Card, type: 'test:story', entityId: ENTITY, title: `Card ${id}`, parent: 'p1',
|
|
14
|
+
parents: ['p1'], status: 'planned', intrinsic: IntrinsicStatus.Planned, flows: { 'test:story': 'planned' },
|
|
15
|
+
labels: [], fields: {}, seq: 1, createdAt: AT, ...extra,
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
const CARDS = [
|
|
19
|
+
card('a', { code: 'US-AAA', labels: ['ui'], fields: { area: 'user' }, order: 2 }),
|
|
20
|
+
card('b', { code: 'US-BBB', status: 'completed', intrinsic: IntrinsicStatus.Closed, parents: ['p1', 'p2'], order: 1 }),
|
|
21
|
+
card('c', { entityId: 'other', code: 'US-CCC' }),
|
|
22
|
+
card('d', { title: '50% off_sale', parent: 'p2', parents: ['p2'], fields: { area: 'guest' } }),
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
const ids = (query: WorkcardQuery): string[] =>
|
|
26
|
+
applyQuery(CARDS, criteriaOf(query, { entityId: ENTITY }), listOptionsOf(query)).items.map(item => item.id!)
|
|
27
|
+
|
|
28
|
+
describe('criteriaOf', () => {
|
|
29
|
+
test('translates the table, always scoped to the entity and omitting what is undefined', () => {
|
|
30
|
+
expect(criteriaOf({
|
|
31
|
+
kind: WorkcardKind.Card, type: ['test:story', 'test:task'], parent: 'p1', within: 'p2', status: undefined,
|
|
32
|
+
labels: ['ui'], ids: ['a'], flow: { id: 'test:story', status: ['planned'] }, fields: { area: 'user' },
|
|
33
|
+
code: 'US-AAA', updatedSince: AT,
|
|
34
|
+
}, { entityId: ENTITY }) as Record<string, unknown>).toEqual({
|
|
35
|
+
entityId: ENTITY, kind: WorkcardKind.Card, type: ['test:story', 'test:task'], parent: 'p1',
|
|
36
|
+
parents: { $contains: 'p2' }, labels: { $overlaps: ['ui'] }, id: { $in: ['a'] },
|
|
37
|
+
'flows.test:story': ['planned'], 'fields.area': 'user', code: 'US-AAA', updatedAt: { $gte: AT },
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('means the same thing to the in-memory engine: membership, labels, fields, code and sort', () => {
|
|
42
|
+
expect(ids({})).toEqual(['a', 'b', 'd'])
|
|
43
|
+
expect(ids({ within: 'p2', sort: ['order'] })).toEqual(['b', 'd'])
|
|
44
|
+
expect(ids({ labels: ['ui', 'api'] })).toEqual(['a'])
|
|
45
|
+
expect(ids({ fields: { area: 'guest' } })).toEqual(['d'])
|
|
46
|
+
expect(ids({ code: ['US-BBB', 'US-CCC'], intrinsic: IntrinsicStatus.Closed })).toEqual(['b'])
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('a search matches title text literally and a code prefix', () => {
|
|
50
|
+
expect(ids({ q: '50%' })).toEqual(['d'])
|
|
51
|
+
expect(ids({ q: '%' })).toEqual(['d'])
|
|
52
|
+
expect(ids({ q: 'US-A' })).toEqual(['a'])
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
describe('wire queries', () => {
|
|
57
|
+
test('a rich query survives encode → decode into the same criteria', () => {
|
|
58
|
+
const query: WorkcardQuery = {
|
|
59
|
+
kind: WorkcardKind.Card, type: ['test:story', 'test:task'], parent: 'p1', labels: ['a,b', 'c'], ids: ['x'],
|
|
60
|
+
flow: { id: 'test:story', status: ['planned', 'failed'] }, fields: { area: 'user', primary: true },
|
|
61
|
+
q: 'sign in', page: 1, size: 20, sort: ['order', { field: 'updatedAt', order: 'desc' }],
|
|
62
|
+
}
|
|
63
|
+
const wire = encodeWorkcardQuery(query)
|
|
64
|
+
|
|
65
|
+
expect(Object.values(wire).every(value => typeof value === 'string' || typeof value === 'number')).toBe(true)
|
|
66
|
+
expect(wire.sort).toBe('order,-updatedAt')
|
|
67
|
+
expect(criteriaOf(decodeWorkcardQuery(wire), { entityId: ENTITY })).toEqual(criteriaOf(query, { entityId: ENTITY }))
|
|
68
|
+
expect(listOptionsOf(decodeWorkcardQuery(wire))).toEqual({ page: 1, size: 20, sort: ['order', { field: 'updatedAt', order: 'desc' }] })
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('decode accepts what a transport may have split already, and coerces numbers', () => {
|
|
72
|
+
expect(decodeWorkcardQuery({ status: ['planned', 'failed'], size: '10', labels: 'ui' } as never))
|
|
73
|
+
.toEqual({ status: ['planned', 'failed'], size: 10, labels: ['ui'] })
|
|
74
|
+
expect(decodeSummaryQuery(encodeSummaryQuery({ parents: ['p1', 'p2'], type: 'test:story' })))
|
|
75
|
+
.toEqual({ parents: ['p1', 'p2'], type: 'test:story' })
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('a value that does not decode is a malformed query', () => {
|
|
79
|
+
expect(() => decodeWorkcardQuery({ fields: '{broken' })).toThrow(PlanningError)
|
|
80
|
+
expect(() => decodeWorkcardQuery({ size: 'many' } as never)).toThrow('malformed:query:size')
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
describe('summaryOf', () => {
|
|
85
|
+
test('counts direct children per parent by intrinsic state, with no key for a parent without any', () => {
|
|
86
|
+
expect(summaryOf(CARDS.filter(item => item.entityId === ENTITY), ['p1', 'p3'])).toEqual({
|
|
87
|
+
p1: { total: 2, planned: 1, 'in-progress': 0, closed: 1 },
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
})
|