@owlmeans/planning 0.1.18-rc.8 → 0.1.18
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 +122 -29
- package/agent-meta/manifest.json +131 -4
- package/agent-meta/skills/planning/SKILL.md +78 -171
- package/agent-meta/skills/planning/parts/collaboration.md +67 -0
- package/agent-meta/skills/planning/parts/errors.md +51 -0
- package/agent-meta/skills/planning/parts/fold.md +69 -0
- package/agent-meta/skills/planning/parts/mounting.md +64 -0
- package/agent-meta/skills/planning/parts/types.md +141 -0
- package/build/aliases/types.d.ts +72 -0
- package/build/aliases/types.d.ts.map +1 -0
- package/build/aliases/types.js +2 -0
- package/build/aliases/types.js.map +1 -0
- package/build/aliases.d.ts +4 -0
- package/build/aliases.d.ts.map +1 -0
- package/build/aliases.js +28 -0
- package/build/aliases.js.map +1 -0
- package/build/consts.d.ts +43 -34
- package/build/consts.d.ts.map +1 -1
- package/build/consts.js +49 -25
- package/build/consts.js.map +1 -1
- package/build/entrypoints/types.d.ts +105 -0
- package/build/entrypoints/types.d.ts.map +1 -0
- package/build/entrypoints/types.js +2 -0
- package/build/entrypoints/types.js.map +1 -0
- package/build/entrypoints.d.ts +5 -1
- package/build/entrypoints.d.ts.map +1 -1
- package/build/entrypoints.js +24 -6
- package/build/entrypoints.js.map +1 -1
- package/build/errors.d.ts +41 -0
- package/build/errors.d.ts.map +1 -1
- package/build/errors.js +61 -0
- package/build/errors.js.map +1 -1
- package/build/helpers/app/consts.local.d.ts +5 -0
- package/build/helpers/app/consts.local.d.ts.map +1 -0
- package/build/helpers/app/consts.local.js +5 -0
- package/build/helpers/app/consts.local.js.map +1 -0
- package/build/helpers/app/types.d.ts +53 -0
- package/build/helpers/app/types.d.ts.map +1 -0
- package/build/helpers/app/types.js +2 -0
- package/build/helpers/app/types.js.map +1 -0
- package/build/helpers/app.d.ts +4 -0
- package/build/helpers/app.d.ts.map +1 -0
- package/build/helpers/app.js +139 -0
- package/build/helpers/app.js.map +1 -0
- package/build/helpers/apply/consts.local.d.ts +7 -0
- package/build/helpers/apply/consts.local.d.ts.map +1 -0
- package/build/helpers/apply/consts.local.js +9 -0
- package/build/helpers/apply/consts.local.js.map +1 -0
- package/build/helpers/apply/types.d.ts +32 -0
- package/build/helpers/apply/types.d.ts.map +1 -0
- package/build/helpers/apply/types.js +2 -0
- package/build/helpers/apply/types.js.map +1 -0
- package/build/helpers/apply.d.ts +5 -23
- package/build/helpers/apply.d.ts.map +1 -1
- package/build/helpers/apply.js +127 -144
- package/build/helpers/apply.js.map +1 -1
- package/build/helpers/card/types.d.ts +20 -0
- package/build/helpers/card/types.d.ts.map +1 -0
- package/build/helpers/card/types.js +2 -0
- package/build/helpers/card/types.js.map +1 -0
- package/build/helpers/card.d.ts +11 -7
- package/build/helpers/card.d.ts.map +1 -1
- package/build/helpers/card.js +41 -28
- package/build/helpers/card.js.map +1 -1
- package/build/helpers/changes/consts.local.d.ts +13 -0
- package/build/helpers/changes/consts.local.d.ts.map +1 -0
- package/build/helpers/changes/consts.local.js +13 -0
- package/build/helpers/changes/consts.local.js.map +1 -0
- package/build/helpers/changes/types.d.ts +58 -0
- package/build/helpers/changes/types.d.ts.map +1 -0
- package/build/helpers/changes/types.js +2 -0
- package/build/helpers/changes/types.js.map +1 -0
- package/build/helpers/changes.d.ts +11 -42
- package/build/helpers/changes.d.ts.map +1 -1
- package/build/helpers/changes.js +237 -251
- package/build/helpers/changes.js.map +1 -1
- package/build/helpers/code/types.d.ts +27 -0
- package/build/helpers/code/types.d.ts.map +1 -0
- package/build/helpers/code/types.js +2 -0
- package/build/helpers/code/types.js.map +1 -0
- package/build/helpers/code.d.ts +6 -17
- package/build/helpers/code.d.ts.map +1 -1
- package/build/helpers/code.js +39 -40
- package/build/helpers/code.js.map +1 -1
- package/build/helpers/index.d.ts +16 -0
- package/build/helpers/index.d.ts.map +1 -1
- package/build/helpers/index.js +4 -0
- package/build/helpers/index.js.map +1 -1
- package/build/helpers/query/types.d.ts +23 -0
- package/build/helpers/query/types.d.ts.map +1 -0
- package/build/helpers/query/types.js +2 -0
- package/build/helpers/query/types.js.map +1 -0
- package/build/helpers/query.d.ts +25 -17
- package/build/helpers/query.d.ts.map +1 -1
- package/build/helpers/query.js +108 -280
- package/build/helpers/query.js.map +1 -1
- package/build/helpers/reply/consts.d.ts +2 -0
- package/build/helpers/reply/consts.d.ts.map +1 -0
- package/build/helpers/reply/consts.js +2 -0
- package/build/helpers/reply/consts.js.map +1 -0
- package/build/helpers/reply/types.d.ts +10 -0
- package/build/helpers/reply/types.d.ts.map +1 -0
- package/build/helpers/reply/types.js +2 -0
- package/build/helpers/reply/types.js.map +1 -0
- package/build/helpers/reply.d.ts +5 -0
- package/build/helpers/reply.d.ts.map +1 -0
- package/build/helpers/reply.js +29 -0
- package/build/helpers/reply.js.map +1 -0
- package/build/helpers/scoped/types.d.ts +73 -0
- package/build/helpers/scoped/types.d.ts.map +1 -0
- package/build/helpers/scoped/types.js +2 -0
- package/build/helpers/scoped/types.js.map +1 -0
- package/build/helpers/scoped.d.ts +25 -0
- package/build/helpers/scoped.d.ts.map +1 -0
- package/build/helpers/scoped.js +223 -0
- package/build/helpers/scoped.js.map +1 -0
- package/build/helpers/specification/types.d.ts +24 -0
- package/build/helpers/specification/types.d.ts.map +1 -0
- package/build/helpers/specification/types.js +2 -0
- package/build/helpers/specification/types.js.map +1 -0
- package/build/helpers/specification.d.ts +9 -13
- package/build/helpers/specification.d.ts.map +1 -1
- package/build/helpers/specification.js +47 -44
- package/build/helpers/specification.js.map +1 -1
- package/build/helpers/status/types.d.ts +48 -0
- package/build/helpers/status/types.d.ts.map +1 -0
- package/build/helpers/status/types.js +2 -0
- package/build/helpers/status/types.js.map +1 -0
- package/build/helpers/status.d.ts +16 -29
- package/build/helpers/status.d.ts.map +1 -1
- package/build/helpers/status.js +77 -73
- package/build/helpers/status.js.map +1 -1
- package/build/helpers/types.local.d.ts +17 -0
- package/build/helpers/types.local.d.ts.map +1 -0
- package/build/helpers/types.local.js +2 -0
- package/build/helpers/types.local.js.map +1 -0
- package/build/helpers/validate/types.d.ts +31 -0
- package/build/helpers/validate/types.d.ts.map +1 -0
- package/build/helpers/validate/types.js +2 -0
- package/build/helpers/validate/types.js.map +1 -0
- package/build/helpers/validate.d.ts +8 -19
- package/build/helpers/validate.d.ts.map +1 -1
- package/build/helpers/validate.js +79 -79
- package/build/helpers/validate.js.map +1 -1
- package/build/helpers/wire/types.d.ts +28 -0
- package/build/helpers/wire/types.d.ts.map +1 -0
- package/build/helpers/wire/types.js +2 -0
- package/build/helpers/wire/types.js.map +1 -0
- package/build/helpers/wire.d.ts +4 -0
- package/build/helpers/wire.d.ts.map +1 -0
- package/build/helpers/wire.js +216 -0
- package/build/helpers/wire.js.map +1 -0
- package/build/i18n/be.json +5 -0
- package/build/i18n/de.json +5 -0
- package/build/i18n/en.json +5 -0
- package/build/i18n/es.json +5 -0
- package/build/i18n/fr.json +5 -0
- package/build/i18n/pl.json +5 -0
- package/build/i18n/ru.json +5 -0
- package/build/i18n/uk.json +5 -0
- package/build/i18n.js +3 -3
- package/build/i18n.js.map +1 -1
- package/build/index.d.ts +7 -0
- package/build/index.d.ts.map +1 -1
- package/build/index.js +3 -0
- package/build/index.js.map +1 -1
- package/build/models/index.d.ts +2 -3
- package/build/models/index.d.ts.map +1 -1
- package/build/models/index.js +1 -15
- package/build/models/index.js.map +1 -1
- package/build/models/model.d.ts +5 -0
- package/build/models/model.d.ts.map +1 -0
- package/build/models/model.js +16 -0
- package/build/models/model.js.map +1 -0
- package/build/models/project.d.ts +2 -1
- package/build/models/project.d.ts.map +1 -1
- package/build/models/project.js.map +1 -1
- package/build/models/specification.d.ts +2 -1
- package/build/models/specification.d.ts.map +1 -1
- package/build/models/specification.js.map +1 -1
- package/build/models/types.d.ts +69 -0
- package/build/models/types.d.ts.map +1 -0
- package/build/models/types.js +2 -0
- package/build/models/types.js.map +1 -0
- package/build/models/workcard.d.ts +2 -1
- package/build/models/workcard.d.ts.map +1 -1
- package/build/models/workcard.js +12 -12
- package/build/models/workcard.js.map +1 -1
- package/build/registry.d.ts +3 -2
- package/build/registry.d.ts.map +1 -1
- package/build/registry.js +51 -3
- package/build/registry.js.map +1 -1
- package/build/resources/mentions.d.ts +4 -0
- package/build/resources/mentions.d.ts.map +1 -0
- package/build/resources/mentions.js +15 -0
- package/build/resources/mentions.js.map +1 -0
- package/build/resources/protocol/types.d.ts +30 -0
- package/build/resources/protocol/types.d.ts.map +1 -0
- package/build/resources/protocol/types.js +2 -0
- package/build/resources/protocol/types.js.map +1 -0
- package/build/resources/protocol.d.ts +6 -0
- package/build/resources/protocol.d.ts.map +1 -0
- package/build/resources/protocol.js +31 -0
- package/build/resources/protocol.js.map +1 -0
- package/build/resources/types.d.ts +146 -0
- package/build/resources/types.d.ts.map +1 -0
- package/build/resources/types.js +2 -0
- package/build/resources/types.js.map +1 -0
- package/build/resources/types.local.d.ts +6 -0
- package/build/resources/types.local.d.ts.map +1 -0
- package/build/resources/types.local.js +2 -0
- package/build/resources/types.local.js.map +1 -0
- package/build/schemas.d.ts +21 -2
- package/build/schemas.d.ts.map +1 -1
- package/build/schemas.js +164 -2
- package/build/schemas.js.map +1 -1
- package/build/types.d.ts +330 -151
- package/build/types.d.ts.map +1 -1
- package/build/utils.d.ts +6 -0
- package/build/utils.d.ts.map +1 -0
- package/build/utils.js +6 -0
- package/build/utils.js.map +1 -0
- package/package.json +13 -13
- package/src/aliases/types.ts +41 -0
- package/src/aliases.ts +34 -0
- package/src/consts.ts +50 -25
- package/src/entrypoints/types.ts +77 -0
- package/src/entrypoints.ts +53 -24
- package/src/errors.ts +71 -0
- package/src/helpers/app/consts.local.ts +5 -0
- package/src/helpers/app/types.ts +54 -0
- package/src/helpers/app.ts +158 -0
- package/src/helpers/apply/consts.local.ts +10 -0
- package/src/helpers/apply/types.ts +32 -0
- package/src/helpers/apply.ts +141 -153
- package/src/helpers/card/types.ts +20 -0
- package/src/helpers/card.ts +59 -33
- package/src/helpers/changes/consts.local.ts +17 -0
- package/src/helpers/changes/types.ts +66 -0
- package/src/helpers/changes.ts +270 -280
- package/src/helpers/code/types.ts +31 -0
- package/src/helpers/code.ts +52 -48
- package/src/helpers/index.ts +17 -0
- package/src/helpers/query/types.ts +26 -0
- package/src/helpers/query.ts +144 -314
- package/src/helpers/reply/consts.ts +1 -0
- package/src/helpers/reply/types.ts +13 -0
- package/src/helpers/reply.ts +27 -0
- package/src/helpers/scoped/types.ts +78 -0
- package/src/helpers/scoped.ts +281 -0
- package/src/helpers/specification/types.ts +26 -0
- package/src/helpers/specification.ts +69 -51
- package/src/helpers/status/types.ts +46 -0
- package/src/helpers/status.ts +117 -93
- package/src/helpers/types.local.ts +18 -0
- package/src/helpers/validate/types.ts +31 -0
- package/src/helpers/validate.ts +89 -77
- package/src/helpers/wire/types.ts +31 -0
- package/src/helpers/wire.ts +252 -0
- package/src/i18n/be.json +5 -0
- package/src/i18n/de.json +5 -0
- package/src/i18n/en.json +5 -0
- package/src/i18n/es.json +5 -0
- package/src/i18n/fr.json +5 -0
- package/src/i18n/pl.json +5 -0
- package/src/i18n/ru.json +5 -0
- package/src/i18n/uk.json +5 -0
- package/src/i18n.ts +4 -3
- package/src/index.ts +8 -0
- package/src/models/index.ts +2 -17
- package/src/models/model.ts +18 -0
- package/src/models/project.ts +2 -1
- package/src/models/specification.ts +2 -1
- package/src/models/types.ts +70 -0
- package/src/models/workcard.ts +14 -16
- package/src/registry.ts +55 -4
- package/src/resources/mentions.ts +11 -0
- package/src/resources/protocol/types.ts +21 -0
- package/src/resources/protocol.ts +36 -0
- package/src/resources/types.local.ts +5 -0
- package/src/resources/types.ts +148 -0
- package/src/schemas.ts +179 -4
- package/src/types.ts +334 -130
- package/src/utils.ts +9 -0
- package/tests/app.spec.ts +188 -0
- package/tests/apply.spec.ts +95 -31
- package/tests/code.spec.ts +9 -9
- package/tests/csp.spec.ts +122 -0
- package/tests/entrypoints.spec.ts +18 -2
- package/tests/error-status.spec.ts +6 -1
- package/tests/models.spec.ts +24 -16
- package/tests/query.spec.ts +13 -15
- package/tests/schemas.spec.ts +8 -8
- package/tests/scoped.spec.ts +161 -0
- package/tests/status.spec.ts +31 -22
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { IntrinsicStatus, PlanningSchemaKind, PlanningUnsupported, SchemaInvalid, WorkcardKind } from '../src/index.js'
|
|
3
|
+
import type { PlanningAppDeclarations, ProjectTypeSchema, ScopedSchemaRecord, StatusFlowSchema, WorkcardTypeSchema } from '../src/index.js'
|
|
4
|
+
import { appSchemaHelper } from '../src/helpers/app.js'
|
|
5
|
+
import { scopedSchemaHelper } from '../src/helpers/scoped.js'
|
|
6
|
+
import { makeAjv, validateHelper } from '../src/helpers/validate.js'
|
|
7
|
+
import { makeSchemaRegistry } from '../src/registry.js'
|
|
8
|
+
import { PlanningSchemaBundleSchema, SchemaDefineRequestSchema } from '../src/schemas.js'
|
|
9
|
+
|
|
10
|
+
/** A repair shop: the app gives every type and flow a description and every job card a priority. */
|
|
11
|
+
const APP: PlanningAppDeclarations = {
|
|
12
|
+
type: { properties: { description: { type: 'string', maxLength: 200 } } },
|
|
13
|
+
flow: { type: 'object', properties: { description: { type: 'string' } }, required: ['description'] },
|
|
14
|
+
card: { properties: { priority: { type: 'string', enum: ['low', 'high'] } } },
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const MEND: StatusFlowSchema = {
|
|
18
|
+
id: 'shop:mend', version: 1, overridable: true,
|
|
19
|
+
statuses: [{ key: 'queued', intrinsic: IntrinsicStatus.Planned, initial: true }, { key: 'done', intrinsic: IntrinsicStatus.Closed }],
|
|
20
|
+
transitions: [{ name: 'finish', from: ['queued'], to: 'done' }],
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Closed fields, a local `$defs` reference — the shape a base field must not break. */
|
|
24
|
+
const JOB: WorkcardTypeSchema = {
|
|
25
|
+
type: 'shop:job', kind: WorkcardKind.Card, version: 1, flows: [MEND.id], specifications: [],
|
|
26
|
+
fields: {
|
|
27
|
+
type: 'object',
|
|
28
|
+
properties: { cost: { $ref: '#/$defs/money' } },
|
|
29
|
+
additionalProperties: false,
|
|
30
|
+
$defs: { money: { type: 'number', minimum: 0 } },
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const SHOP: ProjectTypeSchema = {
|
|
35
|
+
type: 'shop:site', kind: WorkcardKind.Project, version: 1, fields: { type: 'object', additionalProperties: false },
|
|
36
|
+
flows: [MEND.id], specifications: [], cardTypes: [JOB.type], scopedCardTypes: true,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('appSchemaHelper — declarations', () => {
|
|
40
|
+
test('merges in the order given: properties united, a later name replacing whole, required united', () => {
|
|
41
|
+
const merged = appSchemaHelper.merge([
|
|
42
|
+
undefined,
|
|
43
|
+
{ card: { properties: { priority: { type: 'string' }, size: { type: 'number' } }, required: ['size'] } },
|
|
44
|
+
{ card: { properties: { priority: { type: 'string', enum: ['low'] } }, required: ['priority'] }, type: { properties: {} } },
|
|
45
|
+
])
|
|
46
|
+
expect(merged).toEqual({
|
|
47
|
+
card: {
|
|
48
|
+
type: 'object',
|
|
49
|
+
properties: { priority: { type: 'string', enum: ['low'] }, size: { type: 'number' } },
|
|
50
|
+
required: ['size', 'priority'],
|
|
51
|
+
},
|
|
52
|
+
type: { type: 'object', properties: {} },
|
|
53
|
+
})
|
|
54
|
+
expect(appSchemaHelper.merge([])).toBeUndefined()
|
|
55
|
+
expect(appSchemaHelper.merge([undefined, {}])).toBeUndefined()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('refuses a declaration a merge by name could not honour', () => {
|
|
59
|
+
const refused = (app: unknown, marker: string) =>
|
|
60
|
+
expect(() => appSchemaHelper.assertDeclarations(app as PlanningAppDeclarations)).toThrow(marker)
|
|
61
|
+
refused({ status: { properties: {} } }, 'app:kind:status')
|
|
62
|
+
refused({ card: { properties: {}, additionalProperties: false } }, 'app:card:keyword:additionalProperties')
|
|
63
|
+
refused({ type: { type: 'string', properties: {} } }, 'app:type:type')
|
|
64
|
+
refused({ flow: { properties: [] } }, 'app:flow:properties')
|
|
65
|
+
refused({ card: { properties: { 'a.b': {} } } }, 'app:card:name:a.b')
|
|
66
|
+
refused({ card: { properties: { $x: {} } } }, 'app:card:name:$x')
|
|
67
|
+
refused({ type: { properties: { a: {} }, required: ['b'] } }, 'app:type:required:b')
|
|
68
|
+
refused({ type: { properties: { a: { type: 'nope' } } } }, 'app:type:')
|
|
69
|
+
expect(() => appSchemaHelper.merge([{ card: { properties: {}, allOf: [] } as never }])).toThrow(SchemaInvalid)
|
|
70
|
+
expect(() => appSchemaHelper.assertDeclarations(APP)).not.toThrow()
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
describe('appSchemaHelper — definition bags', () => {
|
|
75
|
+
test('a bag is closed over the declared kind, and refused when the kind is undeclared', () => {
|
|
76
|
+
const ok = (kind: PlanningSchemaKind.Type | PlanningSchemaKind.Flow, bag: unknown, app = APP) =>
|
|
77
|
+
appSchemaHelper.assertBag(kind, 'k', bag, app)
|
|
78
|
+
|
|
79
|
+
expect(() => ok(PlanningSchemaKind.Type, { description: 'Jobs at the counter' })).not.toThrow()
|
|
80
|
+
expect(() => ok(PlanningSchemaKind.Type, undefined)).not.toThrow()
|
|
81
|
+
expect(() => ok(PlanningSchemaKind.Type, { colour: 'red' })).toThrow('type:k:app:')
|
|
82
|
+
expect(() => ok(PlanningSchemaKind.Type, { description: 5 })).toThrow(SchemaInvalid)
|
|
83
|
+
expect(() => ok(PlanningSchemaKind.Type, { description: 'x', nested: { 'a.b': 1 } })).toThrow('type:k:app:keys:nested.a.b')
|
|
84
|
+
// A required property makes the bag itself mandatory.
|
|
85
|
+
expect(() => ok(PlanningSchemaKind.Flow, undefined)).toThrow("flow:k:app:/ must have required property 'description'")
|
|
86
|
+
expect(() => ok(PlanningSchemaKind.Flow, { description: 'Bench work' })).not.toThrow()
|
|
87
|
+
expect(() => ok(PlanningSchemaKind.Flow, { description: 'x' }, { card: APP.card })).toThrow('flow:k:app:undeclared')
|
|
88
|
+
expect(() => ok(PlanningSchemaKind.Flow, undefined, {})).not.toThrow()
|
|
89
|
+
expect(appSchemaHelper.bagSchemaOf(APP, PlanningSchemaKind.Type)).toEqual({
|
|
90
|
+
type: 'object', properties: { description: { type: 'string', maxLength: 200 } }, additionalProperties: false,
|
|
91
|
+
})
|
|
92
|
+
expect(appSchemaHelper.bagSchemaOf({}, PlanningSchemaKind.Flow)).toBeUndefined()
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('the declaration schemas admit an app bag on the wire and in a bundle', () => {
|
|
96
|
+
const define = makeAjv().compile(SchemaDefineRequestSchema)
|
|
97
|
+
const bundle = makeAjv().compile(PlanningSchemaBundleSchema)
|
|
98
|
+
|
|
99
|
+
expect(define({ types: [{ ...JOB, app: { description: 'x' } }], flows: [{ ...MEND, app: { description: 'y' } }] })).toBe(true)
|
|
100
|
+
expect(define({ types: [{ ...JOB, app: 'x' }] })).toBe(false)
|
|
101
|
+
expect(bundle({ version: 1, types: [JOB], flows: [MEND], app: APP })).toBe(true)
|
|
102
|
+
expect(bundle({ version: 1, types: [JOB], flows: [MEND], app: { status: {} } })).toBe(false)
|
|
103
|
+
})
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
describe('appSchemaHelper — base card fields', () => {
|
|
107
|
+
test('a card type validates its own fields and the base ones; closed fields never refuse a base field', () => {
|
|
108
|
+
const registry = makeSchemaRegistry({ flows: [MEND], types: [JOB, SHOP], app: APP })
|
|
109
|
+
const valid = (fields: Record<string, unknown>) => registry.validator(JOB.type)(fields)
|
|
110
|
+
|
|
111
|
+
expect(valid({ cost: 12, priority: 'high' })).toBe(true)
|
|
112
|
+
expect(valid({ cost: 12 })).toBe(true)
|
|
113
|
+
expect(valid({ cost: -1, priority: 'high' })).toBe(false)
|
|
114
|
+
expect(valid({ cost: 1, priority: 'urgent' })).toBe(false)
|
|
115
|
+
expect(valid({ cost: 1, colour: 'red' })).toBe(false)
|
|
116
|
+
expect(() => validateHelper.validateFields(registry, JOB.type, { priority: 'urgent' })).toThrow('shop:job:')
|
|
117
|
+
// A project type is not a card type: its closed fields stay closed.
|
|
118
|
+
expect(registry.validator(SHOP.type)({ priority: 'high' })).toBe(false)
|
|
119
|
+
expect(registry.fieldsSchema(SHOP.type)).toBe(SHOP.fields)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
test('a type may narrow a base field, and a required base field is required of every card type', () => {
|
|
123
|
+
const narrow: WorkcardTypeSchema = { ...JOB, type: 'shop:rush', fields: { type: 'object', properties: { priority: { const: 'high' } } } }
|
|
124
|
+
const registry = makeSchemaRegistry({
|
|
125
|
+
flows: [MEND], types: [narrow],
|
|
126
|
+
app: { card: { properties: { priority: { type: 'string', enum: ['low', 'high'] } }, required: ['priority'] } },
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
expect(registry.validator(narrow.type)({ priority: 'high' })).toBe(true)
|
|
130
|
+
expect(registry.validator(narrow.type)({ priority: 'low' })).toBe(false)
|
|
131
|
+
expect(registry.validator(narrow.type)({})).toBe(false)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
test('without base fields the type\'s own fields object is what compiles', () => {
|
|
135
|
+
expect(appSchemaHelper.fieldsSchemaOf(JOB)).toBe(JOB.fields)
|
|
136
|
+
expect(appSchemaHelper.fieldsSchemaOf(JOB, { type: APP.type })).toBe(JOB.fields)
|
|
137
|
+
expect(makeSchemaRegistry({ flows: [MEND], types: [JOB] }).fieldsSchema(JOB.type)).toBe(JOB.fields)
|
|
138
|
+
})
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
describe('the schema registry and the scoped layers carry the declarations', () => {
|
|
142
|
+
test('registerApp replaces them and drops the cached validators; a bundle names them only when present', () => {
|
|
143
|
+
const registry = makeSchemaRegistry({ flows: [MEND], types: [JOB] })
|
|
144
|
+
expect('app' in registry.bundle()).toBe(false)
|
|
145
|
+
expect(registry.validator(JOB.type)({ priority: 'high' })).toBe(false)
|
|
146
|
+
|
|
147
|
+
registry.registerApp(APP)
|
|
148
|
+
expect(registry.app()).toEqual(APP)
|
|
149
|
+
expect(registry.bundle().app).toEqual(APP)
|
|
150
|
+
expect(registry.validator(JOB.type)({ priority: 'high' })).toBe(true)
|
|
151
|
+
|
|
152
|
+
registry.load({ version: 1, types: [JOB], flows: [MEND] })
|
|
153
|
+
expect(registry.app()).toBeUndefined()
|
|
154
|
+
expect(registry.validator(JOB.type)({ priority: 'high' })).toBe(false)
|
|
155
|
+
registry.load({ version: 1, types: [JOB], flows: [MEND], app: APP })
|
|
156
|
+
expect(registry.validator(JOB.type)({ priority: 'high' })).toBe(true)
|
|
157
|
+
expect(() => registry.registerApp({ card: { properties: { 'a.b': {} } } })).toThrow(SchemaInvalid)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
test('a resolved layer passes the code declarations through; its registry refuses registerApp', () => {
|
|
161
|
+
const code = makeSchemaRegistry({ flows: [MEND], types: [JOB, SHOP], app: APP })
|
|
162
|
+
const record: ScopedSchemaRecord = {
|
|
163
|
+
entityId: 'org-1', kind: PlanningSchemaKind.Type, key: 'shop:quote', version: 1, createdAt: '2026-10-01T00:00:00.000Z',
|
|
164
|
+
definition: { ...JOB, type: 'shop:quote', fields: { type: 'object', additionalProperties: false }, app: { description: 'Quotes' } },
|
|
165
|
+
}
|
|
166
|
+
const layer = scopedSchemaHelper.scopedRegistryOf(scopedSchemaHelper.resolveScopedBundle(code.bundle(), [record], { entityId: 'org-1' }))
|
|
167
|
+
|
|
168
|
+
expect(layer.bundle().app).toEqual(APP)
|
|
169
|
+
expect((layer.type('shop:quote') as WorkcardTypeSchema).app).toEqual({ description: 'Quotes' })
|
|
170
|
+
expect(layer.validator('shop:quote')({ priority: 'low' })).toBe(true)
|
|
171
|
+
expect(() => layer.registerApp(undefined)).toThrow(PlanningUnsupported)
|
|
172
|
+
const plain = scopedSchemaHelper.resolveScopedBundle(makeSchemaRegistry({ flows: [MEND], types: [JOB] }).bundle(), [], { entityId: 'org-1' })
|
|
173
|
+
expect('app' in plain).toBe(false)
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
test('the closed-form checks of a data-defined type and flow apply the declarations', () => {
|
|
177
|
+
const flows = makeSchemaRegistry({ flows: [MEND] })
|
|
178
|
+
const quote: WorkcardTypeSchema = { ...JOB, type: 'shop:quote' }
|
|
179
|
+
|
|
180
|
+
expect(() => scopedSchemaHelper.assertTypeSchema({ ...quote, app: { description: 'Quotes' } }, flows, APP)).not.toThrow()
|
|
181
|
+
expect(() => scopedSchemaHelper.assertTypeSchema({ ...quote, app: { owner: 'me' } }, flows, APP)).toThrow('type:shop:quote:app:')
|
|
182
|
+
expect(() => scopedSchemaHelper.assertTypeSchema({ ...quote, app: { description: 'Quotes' } }, flows)).toThrow('type:shop:quote:app:undeclared')
|
|
183
|
+
expect(() => scopedSchemaHelper.assertTypeSchema(quote, flows)).not.toThrow()
|
|
184
|
+
expect(() => scopedSchemaHelper.assertFlowSchema({ ...MEND, app: { description: 'Bench' } }, APP)).not.toThrow()
|
|
185
|
+
expect(() => scopedSchemaHelper.assertFlowSchema(MEND, APP)).toThrow('flow:shop:mend:app:')
|
|
186
|
+
expect(() => scopedSchemaHelper.assertFlowSchema(MEND)).not.toThrow()
|
|
187
|
+
})
|
|
188
|
+
})
|
package/tests/apply.spec.ts
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test'
|
|
2
2
|
import { IntrinsicStatus, TransitionAction, WorkcardKind } from '../src/consts.js'
|
|
3
3
|
import { IllegalTransition, PlanningError } from '../src/errors.js'
|
|
4
|
-
import {
|
|
5
|
-
import { assertMutable, computeChanges, isEmptyChange } from '../src/helpers/changes.js'
|
|
6
|
-
import type { Relationship, Specification, Workcard } from '../src/types.js'
|
|
4
|
+
import type { Relationship, Specification, Workcard, WorkcardChanges } from '../src/types.js'
|
|
7
5
|
import {
|
|
8
6
|
AT, ENTITY, LATER, PROJECT_TYPE, SPEC_TYPE, STORY_TYPE, TASK_TYPE, makeRegistry, transitionOf,
|
|
9
7
|
} from './fixtures.js'
|
|
8
|
+
import { applyHelper } from '../src/helpers/apply.js'
|
|
9
|
+
import { changesHelper } from '../src/helpers/changes.js'
|
|
10
10
|
|
|
11
11
|
const registry = makeRegistry()
|
|
12
12
|
|
|
13
13
|
const created = (): Workcard => {
|
|
14
|
-
const { changes } = computeChanges(undefined, {
|
|
14
|
+
const { changes } = changesHelper.computeChanges(undefined, {
|
|
15
15
|
action: TransitionAction.Create,
|
|
16
16
|
card: {
|
|
17
17
|
kind: WorkcardKind.Card, type: STORY_TYPE.type, parent: 'p1', title: 'As a user I sign in',
|
|
@@ -19,7 +19,7 @@ const created = (): Workcard => {
|
|
|
19
19
|
},
|
|
20
20
|
}, STORY_TYPE, registry, AT)
|
|
21
21
|
|
|
22
|
-
return applyTransition(undefined, transitionOf({ card: 'c1', seq: 1, action: TransitionAction.Create, changes }))!
|
|
22
|
+
return applyHelper.applyTransition(undefined, transitionOf({ card: 'c1', seq: 1, action: TransitionAction.Create, changes }))!
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
describe('applyTransition — the fold', () => {
|
|
@@ -38,7 +38,7 @@ describe('applyTransition — the fold', () => {
|
|
|
38
38
|
test('update merges fields, replaces the rest, clears unset — and never mutates its input', () => {
|
|
39
39
|
const card = created()
|
|
40
40
|
const before = structuredClone(card)
|
|
41
|
-
const next = applyTransition(card, transitionOf({
|
|
41
|
+
const next = applyHelper.applyTransition(card, transitionOf({
|
|
42
42
|
card: 'c1', seq: 2, action: TransitionAction.Update, at: LATER,
|
|
43
43
|
changes: { title: 'Renamed', fields: { warning: 'x' }, labels: ['ui'] },
|
|
44
44
|
unset: ['code', 'fields.primary'],
|
|
@@ -55,23 +55,23 @@ describe('applyTransition — the fold', () => {
|
|
|
55
55
|
test('an applied seq returns the card unchanged; a gap or a missing card is refused', () => {
|
|
56
56
|
const card = { ...created(), head: 3 }
|
|
57
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 })))
|
|
58
|
+
expect(applyHelper.applyTransition(card, transitionOf({ card: 'c1', seq: 1, action: TransitionAction.Update }))).toBe(card)
|
|
59
|
+
expect(() => applyHelper.applyTransition(card, transitionOf({ card: 'c1', seq: 3, action: TransitionAction.Update })))
|
|
60
60
|
.toThrow(PlanningError)
|
|
61
|
-
expect(() => applyTransition(undefined, transitionOf({ card: 'c1', seq: 2, action: TransitionAction.Update })))
|
|
61
|
+
expect(() => applyHelper.applyTransition(undefined, transitionOf({ card: 'c1', seq: 2, action: TransitionAction.Update })))
|
|
62
62
|
.toThrow('fold:out-of-order')
|
|
63
|
-
expect(applyTransition({ ...card, seq: 1 }, transitionOf({ card: 'c1', seq: 2, action: TransitionAction.Link, at: LATER })))
|
|
63
|
+
expect(applyHelper.applyTransition({ ...card, seq: 1 }, transitionOf({ card: 'c1', seq: 2, action: TransitionAction.Link, at: LATER })))
|
|
64
64
|
.toMatchObject({ seq: 2, head: 3, updatedAt: LATER, title: card.title })
|
|
65
65
|
})
|
|
66
66
|
|
|
67
67
|
test('delete answers null, and a revised document carries its new revision', () => {
|
|
68
68
|
const card = created()
|
|
69
|
-
expect(applyTransition(card, transitionOf({ card: 'c1', seq: 2, action: TransitionAction.Delete }))).toBeNull()
|
|
69
|
+
expect(applyHelper.applyTransition(card, transitionOf({ card: 'c1', seq: 2, action: TransitionAction.Delete }))).toBeNull()
|
|
70
70
|
|
|
71
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}' } },
|
|
72
|
+
const { changes } = changesHelper.computeChanges(spec, { card: 'c1', action: TransitionAction.Update, changes: { body: '{"a":1}' } },
|
|
73
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
|
|
74
|
+
const revised = applyHelper.applyTransition(spec, transitionOf({ card: 'c1', seq: 2, action: TransitionAction.Update, changes })) as Specification
|
|
75
75
|
|
|
76
76
|
expect([revised.revision, revised.body, revised.bodyChars]).toEqual([2, '{"a":1}', 7])
|
|
77
77
|
})
|
|
@@ -79,7 +79,7 @@ describe('applyTransition — the fold', () => {
|
|
|
79
79
|
|
|
80
80
|
describe('applyRelationship', () => {
|
|
81
81
|
test('a create folds its links, from the card unless a draft names another end', () => {
|
|
82
|
-
const links = applyRelationship([], transitionOf({
|
|
82
|
+
const links = applyHelper.applyRelationship([], transitionOf({
|
|
83
83
|
id: 't1', card: 'c2', seq: 1, action: TransitionAction.Create, project: 'p1',
|
|
84
84
|
links: [{ type: 'follows', to: 'c1' }, { type: 'follows', from: 'c0', to: 'c2' }],
|
|
85
85
|
}))
|
|
@@ -92,12 +92,12 @@ describe('applyRelationship', () => {
|
|
|
92
92
|
|
|
93
93
|
test('link is idempotent and unlink removes only the matching edge', () => {
|
|
94
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' } })
|
|
95
|
+
const once = applyHelper.applyRelationship([], link)
|
|
96
|
+
const twice = applyHelper.applyRelationship(once, link)
|
|
97
|
+
const other = applyHelper.applyRelationship(twice, { ...link, link: { type: 'blocks', to: 'c1' } })
|
|
98
98
|
|
|
99
99
|
expect(twice).toHaveLength(1)
|
|
100
|
-
expect(applyRelationship(other, { ...link, action: TransitionAction.Unlink }).map(edge => edge.type))
|
|
100
|
+
expect(applyHelper.applyRelationship(other, { ...link, action: TransitionAction.Unlink }).map(edge => edge.type))
|
|
101
101
|
.toEqual(['blocks'])
|
|
102
102
|
})
|
|
103
103
|
|
|
@@ -105,7 +105,7 @@ describe('applyRelationship', () => {
|
|
|
105
105
|
const edge = (from: string, to: string): Relationship => ({ entityId: ENTITY, type: 'follows', from, to, createdAt: AT })
|
|
106
106
|
const links = [edge('c1', 'c2'), edge('c3', 'c1'), edge('c3', 'c4')]
|
|
107
107
|
|
|
108
|
-
expect(applyRelationship(links, transitionOf({ card: 'c1', seq: 5, action: TransitionAction.Delete })))
|
|
108
|
+
expect(applyHelper.applyRelationship(links, transitionOf({ card: 'c1', seq: 5, action: TransitionAction.Delete })))
|
|
109
109
|
.toEqual([edge('c3', 'c4')])
|
|
110
110
|
})
|
|
111
111
|
})
|
|
@@ -113,23 +113,23 @@ describe('applyRelationship', () => {
|
|
|
113
113
|
describe('computeChanges', () => {
|
|
114
114
|
test('an update records only what differs, and one that changes nothing is empty', () => {
|
|
115
115
|
const card = created()
|
|
116
|
-
const set = computeChanges(card, {
|
|
116
|
+
const set = changesHelper.computeChanges(card, {
|
|
117
117
|
card: 'c1', action: TransitionAction.Update,
|
|
118
118
|
changes: { title: card.title, description: 'New', fields: { area: 'user', primary: true } },
|
|
119
119
|
}, STORY_TYPE, registry, LATER)
|
|
120
120
|
|
|
121
121
|
expect(set).toEqual({ changes: { description: 'New', fields: { primary: true } }, unset: [] })
|
|
122
|
-
expect(isEmptyChange(computeChanges(card, {
|
|
122
|
+
expect(changesHelper.isEmptyChange(changesHelper.computeChanges(card, {
|
|
123
123
|
card: 'c1', action: TransitionAction.Update, changes: { title: card.title, code: null as never },
|
|
124
124
|
}, STORY_TYPE, registry, LATER))).toBe(false)
|
|
125
|
-
expect(isEmptyChange(computeChanges(card, {
|
|
125
|
+
expect(changesHelper.isEmptyChange(changesHelper.computeChanges(card, {
|
|
126
126
|
card: 'c1', action: TransitionAction.Update, changes: { title: card.title, description: null as never },
|
|
127
127
|
}, STORY_TYPE, registry, LATER))).toBe(true)
|
|
128
128
|
})
|
|
129
129
|
|
|
130
130
|
test('a transit moves the flow, mirrors status and intrinsic, and sets or clears closedAt', () => {
|
|
131
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)
|
|
132
|
+
const completed = changesHelper.computeChanges(card, { card: 'c1', action: TransitionAction.Transit, transition: 'complete' }, STORY_TYPE, registry, LATER)
|
|
133
133
|
|
|
134
134
|
expect(completed).toEqual({
|
|
135
135
|
changes: { flows: { 'test:story': 'completed' }, status: 'completed', intrinsic: IntrinsicStatus.Closed, closedAt: LATER },
|
|
@@ -137,23 +137,23 @@ describe('computeChanges', () => {
|
|
|
137
137
|
})
|
|
138
138
|
|
|
139
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)
|
|
140
|
+
expect(changesHelper.computeChanges(closed, { card: 'c1', action: TransitionAction.Transit, transition: 'reset' }, STORY_TYPE, registry, LATER).unset)
|
|
141
141
|
.toEqual(['closedAt'])
|
|
142
|
-
expect(() => computeChanges(closed, { card: 'c1', action: TransitionAction.Transit, transition: 'start' }, STORY_TYPE, registry, LATER))
|
|
142
|
+
expect(() => changesHelper.computeChanges(closed, { card: 'c1', action: TransitionAction.Transit, transition: 'start' }, STORY_TYPE, registry, LATER))
|
|
143
143
|
.toThrow(IllegalTransition)
|
|
144
144
|
})
|
|
145
145
|
|
|
146
146
|
test('a non-primary transit under IntrinsicPolicy.All keeps the card open until every flow closes', () => {
|
|
147
147
|
const task = { ...created(), type: TASK_TYPE.type, status: 'completed', intrinsic: IntrinsicStatus.Planned,
|
|
148
148
|
flows: { 'test:story': 'completed', 'test:review': 'reviewing' } }
|
|
149
|
-
const set = computeChanges(task, { card: 'c1', action: TransitionAction.Transit, transition: 'approve', flow: 'test:review' },
|
|
149
|
+
const set = changesHelper.computeChanges(task, { card: 'c1', action: TransitionAction.Transit, transition: 'approve', flow: 'test:review' },
|
|
150
150
|
TASK_TYPE, registry, LATER)
|
|
151
151
|
|
|
152
152
|
expect(set.changes).toEqual({ flows: { 'test:review': 'approved' }, intrinsic: IntrinsicStatus.Closed, closedAt: LATER })
|
|
153
153
|
})
|
|
154
154
|
|
|
155
155
|
test('a create of a revisioned document starts at revision 1 with its body length', () => {
|
|
156
|
-
const { changes } = computeChanges(undefined, {
|
|
156
|
+
const { changes } = changesHelper.computeChanges(undefined, {
|
|
157
157
|
action: TransitionAction.Create,
|
|
158
158
|
card: { kind: WorkcardKind.Specification, type: SPEC_TYPE.type, parent: 'p1', title: 'scaffold', category: 'scaffold', body: '{}' },
|
|
159
159
|
}, SPEC_TYPE, registry, AT, { slot: PROJECT_TYPE.specifications[1] })
|
|
@@ -164,13 +164,77 @@ describe('computeChanges', () => {
|
|
|
164
164
|
|
|
165
165
|
describe('assertMutable', () => {
|
|
166
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))
|
|
167
|
+
expect(() => changesHelper.assertMutable({ card: 'c1', action: TransitionAction.Update, changes: { status: 'completed' } }, STORY_TYPE))
|
|
168
168
|
.toThrow('immutable:status')
|
|
169
|
-
expect(() => assertMutable({ card: 'c1', action: TransitionAction.Update, changes: { code: 'US-X' } }, STORY_TYPE))
|
|
169
|
+
expect(() => changesHelper.assertMutable({ card: 'c1', action: TransitionAction.Update, changes: { code: 'US-X' } }, STORY_TYPE))
|
|
170
170
|
.toThrow('immutable:code')
|
|
171
|
-
expect(() => assertMutable({ card: 'c1', action: TransitionAction.Update, unset: ['title'] }, STORY_TYPE))
|
|
171
|
+
expect(() => changesHelper.assertMutable({ card: 'c1', action: TransitionAction.Update, unset: ['title'] }, STORY_TYPE))
|
|
172
172
|
.toThrow('immutable:title')
|
|
173
|
-
expect(() => assertMutable({ card: 'c1', action: TransitionAction.Update, changes: { code: 'free' } }, TASK_TYPE))
|
|
173
|
+
expect(() => changesHelper.assertMutable({ card: 'c1', action: TransitionAction.Update, changes: { code: 'free' } }, TASK_TYPE))
|
|
174
174
|
.not.toThrow()
|
|
175
175
|
})
|
|
176
|
+
|
|
177
|
+
test('provenance is never written after the create: createdBy and createdAt in changes or unset, on any action', () => {
|
|
178
|
+
const draft = { kind: WorkcardKind.Card, type: STORY_TYPE.type, parent: 'p1', title: 'Owned', createdBy: 'owner-1' }
|
|
179
|
+
const refused = [
|
|
180
|
+
{ card: 'c1', action: TransitionAction.Update, changes: { createdBy: 'intruder' } },
|
|
181
|
+
{ card: 'c1', action: TransitionAction.Update, changes: { createdBy: null } },
|
|
182
|
+
{ card: 'c1', action: TransitionAction.Update, unset: ['createdBy'] },
|
|
183
|
+
{ card: 'c1', action: TransitionAction.Transit, transition: 'start', changes: { createdBy: 'intruder' } },
|
|
184
|
+
{ card: 'c1', action: TransitionAction.Link, link: { type: 'follows', to: 'c2' }, unset: ['createdBy'] },
|
|
185
|
+
{ card: draft, action: TransitionAction.Create, changes: { createdBy: 'intruder' } },
|
|
186
|
+
] as Parameters<typeof changesHelper.assertMutable>[0][]
|
|
187
|
+
|
|
188
|
+
for (const exec of refused) {
|
|
189
|
+
expect(() => changesHelper.assertMutable(exec, STORY_TYPE)).toThrow('planning:immutable:createdBy')
|
|
190
|
+
}
|
|
191
|
+
expect(() => changesHelper.assertMutable({ card: 'c1', action: TransitionAction.Update, changes: { createdAt: LATER } as WorkcardChanges }, STORY_TYPE))
|
|
192
|
+
.toThrow('planning:immutable:createdAt')
|
|
193
|
+
expect(() => changesHelper.assertMutable({ card: 'c1', action: TransitionAction.Update, unset: ['createdAt'] }, STORY_TYPE))
|
|
194
|
+
.toThrow('planning:immutable:createdAt')
|
|
195
|
+
expect(() => changesHelper.assertMutable({ card: draft, action: TransitionAction.Create }, STORY_TYPE)).not.toThrow()
|
|
196
|
+
expect(() => changesHelper.assertMutable({ card: 'c1', action: TransitionAction.Update, changes: { createdBy: undefined, title: 'T' } }, STORY_TYPE))
|
|
197
|
+
.not.toThrow()
|
|
198
|
+
})
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
describe('createdBy — written by the create alone', () => {
|
|
202
|
+
const owned = (): Workcard => ({ ...created(), createdBy: 'owner-1' })
|
|
203
|
+
|
|
204
|
+
test('computeChanges takes it from the draft and never from changes or unset', () => {
|
|
205
|
+
const create = changesHelper.computeChanges(undefined, {
|
|
206
|
+
action: TransitionAction.Create,
|
|
207
|
+
card: { kind: WorkcardKind.Card, type: STORY_TYPE.type, parent: 'p1', title: 'Owned', createdBy: 'owner-1' },
|
|
208
|
+
changes: { createdBy: 'intruder' },
|
|
209
|
+
}, STORY_TYPE, registry, AT)
|
|
210
|
+
expect(create.changes.createdBy).toBe('owner-1')
|
|
211
|
+
|
|
212
|
+
const update = changesHelper.computeChanges(owned(), {
|
|
213
|
+
card: 'c1', action: TransitionAction.Update, changes: { createdBy: 'intruder', title: 'Renamed' }, unset: ['createdBy'],
|
|
214
|
+
}, STORY_TYPE, registry, LATER)
|
|
215
|
+
expect(update).toEqual({ changes: { title: 'Renamed' }, unset: [] })
|
|
216
|
+
|
|
217
|
+
const cleared = changesHelper.computeChanges(owned(), { card: 'c1', action: TransitionAction.Update, changes: { createdBy: null } as unknown as WorkcardChanges },
|
|
218
|
+
STORY_TYPE, registry, LATER)
|
|
219
|
+
expect(changesHelper.isEmptyChange(cleared)).toBe(true)
|
|
220
|
+
|
|
221
|
+
const transit = changesHelper.computeChanges(owned(), {
|
|
222
|
+
card: 'c1', action: TransitionAction.Transit, transition: 'start', changes: { createdBy: 'intruder' }, unset: ['createdBy'],
|
|
223
|
+
}, STORY_TYPE, registry, LATER)
|
|
224
|
+
expect(transit.changes.createdBy).toBeUndefined()
|
|
225
|
+
expect(transit.unset).not.toContain('createdBy')
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
test('the fold replays a legacy transition that named it, as written — the refusal is at admission', () => {
|
|
229
|
+
const card = owned()
|
|
230
|
+
const renamed = applyHelper.applyTransition(card, transitionOf({
|
|
231
|
+
card: 'c1', seq: 2, action: TransitionAction.Update, at: LATER, changes: { title: 'Legacy', createdBy: 'intruder' },
|
|
232
|
+
}))!
|
|
233
|
+
const cleared = applyHelper.applyTransition(renamed, transitionOf({ card: 'c1', seq: 3, action: TransitionAction.Update, unset: ['createdBy'] }))!
|
|
234
|
+
|
|
235
|
+
expect([renamed.seq, renamed.title, renamed.createdBy, renamed.createdAt]).toEqual([2, 'Legacy', 'intruder', AT])
|
|
236
|
+
expect([cleared.seq, cleared.title, cleared.createdBy]).toEqual([3, 'Legacy', undefined])
|
|
237
|
+
expect(applyHelper.applyTransition(cleared, transitionOf({ card: 'c1', seq: 4, action: TransitionAction.Update, changes: { title: 'After' } }))!
|
|
238
|
+
.title).toBe('After')
|
|
239
|
+
})
|
|
176
240
|
})
|
package/tests/code.spec.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test'
|
|
2
2
|
import { CodeScope, CodeStyle } from '../src/consts.js'
|
|
3
3
|
import { CodeTaken } from '../src/errors.js'
|
|
4
|
-
import { codeScopeOf, mintCode } from '../src/helpers/code.js'
|
|
5
4
|
import { STORY_TYPE } from './fixtures.js'
|
|
5
|
+
import { codeHelper } from '../src/helpers/code.js'
|
|
6
6
|
|
|
7
7
|
const free = () => false
|
|
8
8
|
|
|
9
9
|
describe('mintCode', () => {
|
|
10
10
|
test('random: prefix plus an upper-cased Base58 part of the policy length', async () => {
|
|
11
|
-
const code = await mintCode(STORY_TYPE.code!, free)
|
|
11
|
+
const code = await codeHelper.mintCode(STORY_TYPE.code!, free)
|
|
12
12
|
|
|
13
13
|
expect(code).toMatch(/^US-[1-9A-Z]{5}$/)
|
|
14
14
|
})
|
|
@@ -17,24 +17,24 @@ describe('mintCode', () => {
|
|
|
17
17
|
const policy = { style: CodeStyle.Slug, uniqueWithin: CodeScope.Entity }
|
|
18
18
|
const taken = new Set(['shop-dashboard', 'shop-dashboard-2'])
|
|
19
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]+$/)
|
|
20
|
+
expect(await codeHelper.mintCode(policy, free, 8, { seed: 'Shop Dashboard!' })).toBe('shop-dashboard')
|
|
21
|
+
expect(await codeHelper.mintCode(policy, code => taken.has(code), 8, { seed: 'Shop Dashboard!' })).toBe('shop-dashboard-3')
|
|
22
|
+
expect(await codeHelper.mintCode(policy, free)).toMatch(/^[a-z]+-[a-z]+$/)
|
|
23
23
|
})
|
|
24
24
|
|
|
25
25
|
test('sequential: counts on from the scope, zero-padded to the length', async () => {
|
|
26
26
|
const policy = { prefix: 'REQ-', style: CodeStyle.Sequential, length: 3, uniqueWithin: CodeScope.Parent }
|
|
27
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')
|
|
28
|
+
expect(await codeHelper.mintCode(policy, free, 8, { count: 41 })).toBe('REQ-042')
|
|
29
|
+
expect(await codeHelper.mintCode(policy, async code => code === 'REQ-042', 8, { count: 41 })).toBe('REQ-043')
|
|
30
30
|
})
|
|
31
31
|
|
|
32
32
|
test('refuses with CodeTaken after the attempt cap, asking exactly that many times', async () => {
|
|
33
33
|
let asked = 0
|
|
34
34
|
const always = async () => { asked++; return true }
|
|
35
35
|
|
|
36
|
-
await expect(mintCode(STORY_TYPE.code!, always, 3)).rejects.toThrow(CodeTaken)
|
|
36
|
+
await expect(codeHelper.mintCode(STORY_TYPE.code!, always, 3)).rejects.toThrow(CodeTaken)
|
|
37
37
|
expect(asked).toBe(3)
|
|
38
|
-
expect(codeScopeOf(STORY_TYPE.code!, { parent: 'p1' })).toEqual({ parent: 'p1' })
|
|
38
|
+
expect(codeHelper.codeScopeOf(STORY_TYPE.code!, { parent: 'p1' })).toEqual({ parent: 'p1' })
|
|
39
39
|
})
|
|
40
40
|
})
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { IntrinsicStatus, WorkcardKind } from '../src/index.js'
|
|
3
|
+
import type {
|
|
4
|
+
PlanningAppDeclarations, PlanningSchemaBundle, ProjectTypeSchema, StatusFlowSchema, WorkcardTypeSchema,
|
|
5
|
+
} from '../src/index.js'
|
|
6
|
+
import { appSchemaHelper } from '../src/helpers/app.js'
|
|
7
|
+
import { scopedSchemaHelper } from '../src/helpers/scoped.js'
|
|
8
|
+
import { makeSchemaRegistry } from '../src/registry.js'
|
|
9
|
+
|
|
10
|
+
/** What a browser says when a page's `script-src` lacks `'unsafe-eval'`. */
|
|
11
|
+
const REFUSAL = "Evaluating a string as JavaScript violates the following Content Security Policy directive because 'unsafe-eval' is not an allowed source of script"
|
|
12
|
+
|
|
13
|
+
/** The work-management seed's declarations: every type and flow may carry a description. */
|
|
14
|
+
const APP: PlanningAppDeclarations = {
|
|
15
|
+
type: { properties: { description: { type: 'string', maxLength: 2000 } } },
|
|
16
|
+
flow: { properties: { description: { type: 'string', maxLength: 2000 } } },
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const WORK: StatusFlowSchema = {
|
|
20
|
+
id: 'wm:work-flow', version: 1, overridable: true, app: { description: 'Day-to-day work' },
|
|
21
|
+
statuses: [{ key: 'open', intrinsic: IntrinsicStatus.Planned, initial: true }, { key: 'done', intrinsic: IntrinsicStatus.Closed }],
|
|
22
|
+
transitions: [{ name: 'finish', from: ['open'], to: 'done' }],
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const TASK: WorkcardTypeSchema = {
|
|
26
|
+
type: 'wm:task', kind: WorkcardKind.Card, version: 1, flows: [WORK.id], specifications: [], overridable: true,
|
|
27
|
+
app: { description: 'Work items' },
|
|
28
|
+
fields: { type: 'object', properties: { priority: { type: 'string', enum: ['low', 'high'] } }, required: ['priority'] },
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const WORKSPACE: ProjectTypeSchema = {
|
|
32
|
+
type: 'wm:workspace', kind: WorkcardKind.Project, version: 1, fields: { type: 'object' },
|
|
33
|
+
flows: [WORK.id], specifications: [], cardTypes: [TASK.type],
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A browser page under the publisher's policy: a `document` exists and every code generation is
|
|
38
|
+
* refused, the way a `script-src` without `'unsafe-eval'` refuses `new Function` and `eval`.
|
|
39
|
+
*/
|
|
40
|
+
const makeStrictPage = () => {
|
|
41
|
+
const scope = globalThis as unknown as Record<string, unknown>
|
|
42
|
+
const saved = { Function: scope.Function as FunctionConstructor, eval: scope.eval as typeof eval, error: console.error }
|
|
43
|
+
const counts = { generated: 0, logged: 0 }
|
|
44
|
+
const refuse = (): never => {
|
|
45
|
+
counts.generated++
|
|
46
|
+
throw new EvalError(REFUSAL)
|
|
47
|
+
}
|
|
48
|
+
const refusing = { Function: new Proxy(saved.Function, { construct: refuse, apply: refuse }), eval: new Proxy(saved.eval, { apply: refuse }) }
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
counts,
|
|
52
|
+
enter: () => {
|
|
53
|
+
scope.document = {}
|
|
54
|
+
scope.Function = refusing.Function
|
|
55
|
+
scope.eval = refusing.eval
|
|
56
|
+
console.error = () => { counts.logged++ }
|
|
57
|
+
},
|
|
58
|
+
leave: () => {
|
|
59
|
+
delete scope.document
|
|
60
|
+
scope.Function = saved.Function
|
|
61
|
+
scope.eval = saved.eval
|
|
62
|
+
console.error = saved.error
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const page = makeStrictPage()
|
|
68
|
+
|
|
69
|
+
/** What a server serves: compiled there, then a JSON body — no object a page could share with it. */
|
|
70
|
+
const served = (): PlanningSchemaBundle => JSON.parse(JSON.stringify(makeSchemaRegistry({
|
|
71
|
+
version: 1, types: [WORKSPACE, TASK], flows: [WORK], app: appSchemaHelper.merge([APP]),
|
|
72
|
+
}).bundle())) as PlanningSchemaBundle
|
|
73
|
+
|
|
74
|
+
afterEach(() => page.leave())
|
|
75
|
+
|
|
76
|
+
describe('planning schemas — a page under a strict script-src', () => {
|
|
77
|
+
test('a registry builds, loads and merges served declarations without generating code', () => {
|
|
78
|
+
const bundle = served()
|
|
79
|
+
const scoped = JSON.parse(JSON.stringify(scopedSchemaHelper.resolveScopedBundle(bundle, [], { entityId: 'org-1' })))
|
|
80
|
+
page.counts.generated = 0
|
|
81
|
+
page.counts.logged = 0
|
|
82
|
+
|
|
83
|
+
page.enter()
|
|
84
|
+
const registry = makeSchemaRegistry(bundle)
|
|
85
|
+
expect(registry.app()).toEqual(bundle.app!)
|
|
86
|
+
registry.load(bundle)
|
|
87
|
+
expect(registry.bundle()).toEqual(bundle)
|
|
88
|
+
expect(appSchemaHelper.merge([bundle.app])).toEqual(bundle.app!)
|
|
89
|
+
appSchemaHelper.assertDeclarations(bundle.app!)
|
|
90
|
+
const layer = scopedSchemaHelper.scopedRegistryOf(scoped)
|
|
91
|
+
expect(layer.app()).toEqual(bundle.app!)
|
|
92
|
+
expect((layer.type(TASK.type) as WorkcardTypeSchema).app).toEqual({ description: 'Work items' })
|
|
93
|
+
expect(layer.fieldsSchema(TASK.type)).toEqual(TASK.fields)
|
|
94
|
+
page.leave()
|
|
95
|
+
|
|
96
|
+
expect(page.counts).toEqual({ generated: 0, logged: 0 })
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
test('the closed form is still refused in a page; only the compile is the server\'s', () => {
|
|
100
|
+
const bundle = served()
|
|
101
|
+
const uncompilable = { type: { properties: { a: { type: 'nope' } } } } as unknown as PlanningAppDeclarations
|
|
102
|
+
expect(() => appSchemaHelper.assertDeclarations(uncompilable)).toThrow('app:type:')
|
|
103
|
+
|
|
104
|
+
page.enter()
|
|
105
|
+
expect(() => appSchemaHelper.assertDeclarations({ card: { properties: { 'a.b': {} } } })).toThrow('app:card:name:a.b')
|
|
106
|
+
expect(() => makeSchemaRegistry({ ...bundle, app: { type: { properties: { a: {} }, required: ['b'] } } }))
|
|
107
|
+
.toThrow('app:type:required:b')
|
|
108
|
+
expect(() => appSchemaHelper.assertDeclarations(uncompilable)).not.toThrow()
|
|
109
|
+
page.leave()
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
test('the page really refuses code generation: a native validator cannot be compiled in it', () => {
|
|
113
|
+
const bundle = served()
|
|
114
|
+
page.counts.generated = 0
|
|
115
|
+
|
|
116
|
+
page.enter()
|
|
117
|
+
expect(() => makeSchemaRegistry(bundle).validator(TASK.type)).toThrow(EvalError)
|
|
118
|
+
page.leave()
|
|
119
|
+
|
|
120
|
+
expect(page.counts.generated).toBeGreaterThan(0)
|
|
121
|
+
})
|
|
122
|
+
})
|
|
@@ -2,14 +2,15 @@ import { describe, expect, test } from 'bun:test'
|
|
|
2
2
|
import { protocols } from '@owlmeans/entrypoint'
|
|
3
3
|
import { RouteMethod, RouteProtocols } from '@owlmeans/route'
|
|
4
4
|
import { makePlanningProtocols } from '../src/entrypoints.js'
|
|
5
|
-
import { PLANNING_PATH
|
|
5
|
+
import { PLANNING_PATH } from '../src/consts.js'
|
|
6
|
+
import { planningAliasHelper } from '../src/aliases.js'
|
|
6
7
|
|
|
7
8
|
const BASE = 'app:api:planning'
|
|
8
9
|
|
|
9
10
|
describe('makePlanningProtocols', () => {
|
|
10
11
|
test('declares every leaf once, each alias derived from the base alias', () => {
|
|
11
12
|
const tree = makePlanningProtocols({ base: { alias: BASE }, guards: 'guard:default' })
|
|
12
|
-
const aliases = planningAliases(BASE)
|
|
13
|
+
const aliases = planningAliasHelper.planningAliases(BASE)
|
|
13
14
|
const declared = protocols(tree as never).map(protocol => protocol.alias)
|
|
14
15
|
|
|
15
16
|
expect(new Set(declared).size).toBe(declared.length)
|
|
@@ -46,6 +47,21 @@ describe('makePlanningProtocols', () => {
|
|
|
46
47
|
expect(tree.card.list.contract?.requestSchemas.query).toBeDefined()
|
|
47
48
|
})
|
|
48
49
|
|
|
50
|
+
test('definitions add the scoped schema list and the schema write; without them neither exists', () => {
|
|
51
|
+
const plain = makePlanningProtocols({ base: { alias: BASE }, guards: 'guard:default' })
|
|
52
|
+
const scoped = makePlanningProtocols({ base: { alias: BASE }, guards: 'guard:default', definitions: true })
|
|
53
|
+
const define = planningAliasHelper.planningDefinitionAliases(BASE).define
|
|
54
|
+
|
|
55
|
+
expect(plain.schema.define).toBeUndefined()
|
|
56
|
+
expect(plain.schema.list.contract?.requestSchemas.query).toBeUndefined()
|
|
57
|
+
expect(protocols(plain as never).some(protocol => protocol.alias === define)).toBe(false)
|
|
58
|
+
|
|
59
|
+
expect(scoped.schema.define?.alias).toBe(define)
|
|
60
|
+
expect(scoped.schema.define?.route.route).toMatchObject({ path: '/schemas', method: RouteMethod.POST, parent: BASE })
|
|
61
|
+
expect(scoped.schema.list.contract?.requestSchemas.query).toBeDefined()
|
|
62
|
+
expect(protocols(scoped as never).filter(protocol => protocol.alias === define)).toHaveLength(1)
|
|
63
|
+
})
|
|
64
|
+
|
|
49
65
|
test('the commit feed is a socket under socketBase when given, under the planning base otherwise', () => {
|
|
50
66
|
const own = makePlanningProtocols({ base: { alias: BASE }, guards: 'guard:default' })
|
|
51
67
|
const shared = makePlanningProtocols({ base: { alias: BASE }, guards: 'guard:default', socketBase: 'app:update' })
|