@owlmeans/viable-common 0.0.18 → 0.0.20

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.
Files changed (120) hide show
  1. package/README.md +3 -3
  2. package/agent-meta/manifest.json +2 -2
  3. package/agent-meta/skills/agent-presentation/SKILL.md +2 -0
  4. package/agent-meta/skills/viable-common/SKILL.md +195 -10
  5. package/build/ba/consts.d.ts +2 -3
  6. package/build/ba/consts.d.ts.map +1 -1
  7. package/build/ba/consts.js +2 -3
  8. package/build/ba/consts.js.map +1 -1
  9. package/build/ba/helpers.d.ts +2 -1
  10. package/build/ba/helpers.d.ts.map +1 -1
  11. package/build/ba/helpers.js +3 -2
  12. package/build/ba/helpers.js.map +1 -1
  13. package/build/ba/types.d.ts +17 -2
  14. package/build/ba/types.d.ts.map +1 -1
  15. package/build/blueprint/case.js +3 -3
  16. package/build/connect/consts.d.ts +5 -8
  17. package/build/connect/consts.d.ts.map +1 -1
  18. package/build/connect/consts.js +5 -8
  19. package/build/connect/consts.js.map +1 -1
  20. package/build/connect/entrypoints.d.ts +1 -43
  21. package/build/connect/entrypoints.d.ts.map +1 -1
  22. package/build/connect/entrypoints.js +1 -18
  23. package/build/connect/entrypoints.js.map +1 -1
  24. package/build/connect/index.d.ts +1 -0
  25. package/build/connect/index.d.ts.map +1 -1
  26. package/build/connect/index.js +1 -0
  27. package/build/connect/index.js.map +1 -1
  28. package/build/connect/jobs.d.ts +25 -0
  29. package/build/connect/jobs.d.ts.map +1 -0
  30. package/build/connect/jobs.js +29 -0
  31. package/build/connect/jobs.js.map +1 -0
  32. package/build/connect/references.d.ts +1 -40
  33. package/build/connect/references.d.ts.map +1 -1
  34. package/build/connect/references.js +0 -8
  35. package/build/connect/references.js.map +1 -1
  36. package/build/connect/schemas.d.ts +1 -7
  37. package/build/connect/schemas.d.ts.map +1 -1
  38. package/build/connect/schemas.js +1 -25
  39. package/build/connect/schemas.js.map +1 -1
  40. package/build/connect/types.d.ts +30 -45
  41. package/build/connect/types.d.ts.map +1 -1
  42. package/build/design/consts.d.ts +0 -2
  43. package/build/design/consts.d.ts.map +1 -1
  44. package/build/design/consts.js +0 -2
  45. package/build/design/consts.js.map +1 -1
  46. package/build/design/helpers.d.ts +6 -2
  47. package/build/design/helpers.d.ts.map +1 -1
  48. package/build/design/helpers.js +6 -2
  49. package/build/design/helpers.js.map +1 -1
  50. package/build/design/types.d.ts +12 -11
  51. package/build/design/types.d.ts.map +1 -1
  52. package/build/execution/types.d.ts +11 -0
  53. package/build/execution/types.d.ts.map +1 -1
  54. package/build/index.d.ts +1 -0
  55. package/build/index.d.ts.map +1 -1
  56. package/build/index.js +1 -0
  57. package/build/index.js.map +1 -1
  58. package/build/planning/consts.d.ts +141 -0
  59. package/build/planning/consts.d.ts.map +1 -0
  60. package/build/planning/consts.js +170 -0
  61. package/build/planning/consts.js.map +1 -0
  62. package/build/planning/errors.d.ts +64 -0
  63. package/build/planning/errors.d.ts.map +1 -0
  64. package/build/planning/errors.js +99 -0
  65. package/build/planning/errors.js.map +1 -0
  66. package/build/planning/flows.d.ts +34 -0
  67. package/build/planning/flows.d.ts.map +1 -0
  68. package/build/planning/flows.js +174 -0
  69. package/build/planning/flows.js.map +1 -0
  70. package/build/planning/helpers.d.ts +54 -0
  71. package/build/planning/helpers.d.ts.map +1 -0
  72. package/build/planning/helpers.js +101 -0
  73. package/build/planning/helpers.js.map +1 -0
  74. package/build/planning/index.d.ts +7 -0
  75. package/build/planning/index.d.ts.map +1 -0
  76. package/build/planning/index.js +6 -0
  77. package/build/planning/index.js.map +1 -0
  78. package/build/planning/schemas.d.ts +23 -0
  79. package/build/planning/schemas.d.ts.map +1 -0
  80. package/build/planning/schemas.js +65 -0
  81. package/build/planning/schemas.js.map +1 -0
  82. package/build/planning/types.d.ts +70 -0
  83. package/build/planning/types.d.ts.map +1 -0
  84. package/build/planning/types.js +2 -0
  85. package/build/planning/types.js.map +1 -0
  86. package/build/scaffold/schemas.d.ts.map +1 -1
  87. package/build/scaffold/schemas.js +7 -2
  88. package/build/scaffold/schemas.js.map +1 -1
  89. package/build/scaffold/types.d.ts +5 -1
  90. package/build/scaffold/types.d.ts.map +1 -1
  91. package/package.json +8 -6
  92. package/src/ba/consts.ts +2 -3
  93. package/src/ba/helpers.ts +3 -2
  94. package/src/ba/types.ts +17 -2
  95. package/src/blueprint/case.ts +3 -3
  96. package/src/connect/consts.ts +5 -8
  97. package/src/connect/entrypoints.ts +2 -41
  98. package/src/connect/index.ts +1 -0
  99. package/src/connect/jobs.ts +40 -0
  100. package/src/connect/references.ts +1 -22
  101. package/src/connect/schemas.ts +2 -29
  102. package/src/connect/types.ts +30 -51
  103. package/src/design/consts.ts +0 -3
  104. package/src/design/helpers.ts +6 -2
  105. package/src/design/types.ts +12 -11
  106. package/src/execution/types.ts +11 -0
  107. package/src/index.ts +1 -0
  108. package/src/planning/consts.ts +182 -0
  109. package/src/planning/errors.ts +117 -0
  110. package/src/planning/flows.ts +204 -0
  111. package/src/planning/helpers.ts +133 -0
  112. package/src/planning/index.ts +6 -0
  113. package/src/planning/schemas.ts +70 -0
  114. package/src/planning/types.ts +75 -0
  115. package/src/scaffold/schemas.ts +7 -2
  116. package/src/scaffold/types.ts +10 -1
  117. package/tests/branding.spec.ts +37 -0
  118. package/tests/connect-entrypoints.spec.ts +35 -3
  119. package/tests/design.spec.ts +3 -3
  120. package/tests/planning.spec.ts +356 -0
@@ -0,0 +1,37 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+
3
+ import { brandingEnv } from '../src/branding.js'
4
+
5
+ /**
6
+ * `BRANDING_CREDIT` is the one key whose ABSENCE means something different from an explicit
7
+ * empty string — the platform side of the bug this pins: `brandingCredit: ''` stored to hide the
8
+ * platform credit must survive as `BRANDING_CREDIT: ''`, not fall back to the "shown" default.
9
+ */
10
+ describe('brandingEnv', () => {
11
+ it('emits an empty BRANDING_CREDIT for an explicitly hidden credit', () => {
12
+ expect(brandingEnv({ brandingCredit: '' }).BRANDING_CREDIT).toBe('')
13
+ })
14
+
15
+ it('emits BRANDING_CREDIT=1 when the credit is shown', () => {
16
+ expect(brandingEnv({ brandingCredit: '1' }).BRANDING_CREDIT).toBe('1')
17
+ })
18
+
19
+ it('defaults to shown (BRANDING_CREDIT=1) when nothing was ever delivered', () => {
20
+ // The safe direction: a delivery that lost the value keeps the credit rather than
21
+ // silently giving away a paid feature.
22
+ expect(brandingEnv({}).BRANDING_CREDIT).toBe('1')
23
+ })
24
+
25
+ it('composes the rest of the branding env from whatever is present, blank otherwise', () => {
26
+ expect(brandingEnv({
27
+ brandingCopyright: '© 2026 Acme', projectName: 'Acme CRM',
28
+ })).toEqual({
29
+ BRANDING_COPYRIGHT: '© 2026 Acme',
30
+ BRANDING_ORGANIZATION: '',
31
+ BRANDING_TERMS_URL: '',
32
+ BRANDING_PRIVACY_URL: '',
33
+ BRANDING_CREDIT: '1',
34
+ BRANDING_PRODUCT: 'Acme CRM',
35
+ })
36
+ })
37
+ })
@@ -2,8 +2,10 @@ import { describe, expect, test } from 'bun:test'
2
2
  import { openProtocol, protocols } from '@owlmeans/entrypoint'
3
3
  import { DEFAULT_GUARD } from '@owlmeans/auth-common'
4
4
  import { route } from '@owlmeans/route'
5
- import { connect } from '../src/connect/consts.js'
5
+ import { connect, ConnectJobKind } from '../src/connect/consts.js'
6
6
  import { connectProtocols } from '../src/connect/entrypoints.js'
7
+ import { JOB_SEPARATOR, jobIdOf, parseJobId } from '../src/connect/jobs.js'
8
+ import { connectRef } from '../src/connect/references.js'
7
9
 
8
10
  describe('@owlmeans/viable-common — connector protocol tree', () => {
9
11
  const tree = connectProtocols({
@@ -19,7 +21,37 @@ describe('@owlmeans/viable-common — connector protocol tree', () => {
19
21
  expect(tree.convert.proceed.alias).toBe(connect.convert.proceed)
20
22
  expect(tree.inquiry.answer.alias).toBe(connect.inquiry.answer)
21
23
  expect(tree.files.list.alias).toBe(connect.files.list)
22
- expect(protocols(tree)).toHaveLength(38)
23
- expect(new Set(protocols(tree).map(protocol => protocol.alias)).size).toBe(38)
24
+ expect(protocols(tree)).toHaveLength(32)
25
+ expect(new Set(protocols(tree).map(protocol => protocol.alias)).size).toBe(32)
26
+ })
27
+
28
+ test('carries no story routes: stories are planning cards served by the planning tree', () => {
29
+ expect((tree as Record<string, unknown>).story).toBeUndefined()
30
+ expect((connect as Record<string, unknown>).story).toBeUndefined()
31
+ expect((connectRef as unknown as Record<string, unknown>).story).toBeUndefined()
32
+ expect(protocols(tree).some(protocol => protocol.alias.includes(':story:'))).toBe(false)
33
+ })
34
+ })
35
+
36
+ describe('@owlmeans/viable-common — connector job ids', () => {
37
+ test('a story job id names the card and splits back into its parts', () => {
38
+ const id = jobIdOf(ConnectJobKind.StoryDevelop, 'project-card', 'story-card')
39
+
40
+ expect(id).toBe(['story-develop', 'project-card', 'story-card'].join(JOB_SEPARATOR))
41
+ expect(parseJobId(id)).toEqual({
42
+ kind: ConnectJobKind.StoryDevelop, projectId: 'project-card', suffix: 'story-card',
43
+ })
44
+ })
45
+
46
+ test('a job with no subject carries no suffix', () => {
47
+ expect(parseJobId(jobIdOf(ConnectJobKind.ProjectInit, 'p1'))).toEqual({
48
+ kind: ConnectJobKind.ProjectInit, projectId: 'p1',
49
+ })
50
+ })
51
+
52
+ test('a suffix that contains the separator is rejoined, never truncated', () => {
53
+ const suffix = `run${JOB_SEPARATOR}tail`
54
+
55
+ expect(parseJobId(jobIdOf(ConnectJobKind.PipelineResume, 'p1', suffix)).suffix).toBe(suffix)
24
56
  })
25
57
  })
@@ -4,7 +4,7 @@ import { ProjectArea } from '../src/areas/consts.js'
4
4
  import { SpecCategory } from '../src/ba/consts.js'
5
5
  import {
6
6
  DesignStaleness, STORY_DESIGN_VERSION, StoryDesignSchema, designHash, designStaleness,
7
- emptyStoryDesign, screenMapOf, userStoryOf,
7
+ emptyStoryDesign, screenMapOf, userStoryOfDesign,
8
8
  } from '../src/design/index.js'
9
9
  import type { StoryDesign } from '../src/design/index.js'
10
10
 
@@ -45,7 +45,7 @@ describe('viable-common - the design aggregate', () => {
45
45
  test('derives the UserStory the coders take, carrying the story code', () => {
46
46
  // The scaffold stamps every placeholder it draws with the code, so a run keyed by anything
47
47
  // else cannot recognise its own reservation.
48
- const story = userStoryOf(withScreens())
48
+ const story = userStoryOfDesign(withScreens())
49
49
 
50
50
  expect(story.code).toBe('US-ABC12')
51
51
  expect(story.area).toBe(ProjectArea.User)
@@ -60,7 +60,7 @@ describe('viable-common - the design aggregate', () => {
60
60
  const broken = withScreens()
61
61
  broken.screens[0].components = ['BookingList', 'Ghost']
62
62
 
63
- expect(userStoryOf(broken).screens[0].components.map(entry => entry.name)).toEqual(['BookingList'])
63
+ expect(userStoryOfDesign(broken).screens[0].components.map(entry => entry.name)).toEqual(['BookingList'])
64
64
  })
65
65
 
66
66
  test('screenMapOf is the shape every UX and UI prompt takes', () => {
@@ -0,0 +1,356 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import Ajv from 'ajv'
3
+ import addFormats from 'ajv-formats'
4
+ import { ResilientError } from '@owlmeans/error'
5
+ import {
6
+ canTransit, CodeScope, CodeStyle, intrinsicOf, IntrinsicStatus, makeSchemaRegistry,
7
+ SpecificationFormat, transitionsFrom, WorkcardKind,
8
+ } from '@owlmeans/planning'
9
+ import type { Specification } from '@owlmeans/planning'
10
+ import { ProjectArea } from '../src/areas/consts.js'
11
+ import { ConnectingStoryKind, SpecCategory, StoryKind } from '../src/ba/consts.js'
12
+ import { mergeConnectingStories } from '../src/ba/helpers.js'
13
+ import { STORY_DESIGN_VERSION } from '../src/design/consts.js'
14
+ import { emptyStoryDesign } from '../src/design/helpers.js'
15
+ import { StoryActor } from '../src/design/runtime.js'
16
+ import type { StoryDesign } from '../src/design/types.js'
17
+ import {
18
+ isUserChannel, isViableProject, isViableStory, ProjectAgentOccupied, ProjectNotFound,
19
+ ProjectStoryMissconfigured, projectBriefOf, storyDraftOf, storyWriteInputOf, userStoryOf,
20
+ VIABLE_FLOW_SCHEMAS, VIABLE_PROJECT_FLOW, VIABLE_PROJECT_FLOW_SCHEMA, VIABLE_PROJECT_TYPE,
21
+ VIABLE_PROJECT_TYPE_SCHEMA, VIABLE_RESERVED_TYPE_SCHEMAS, VIABLE_SPEC_TYPE, VIABLE_STORY_FLOW,
22
+ VIABLE_STORY_FLOW_SCHEMA,
23
+ VIABLE_STORY_TYPE, VIABLE_STORY_TYPE_SCHEMA, VIABLE_TYPE_SCHEMAS, ViableChannel,
24
+ ViableProjectFieldsSchema, ViableProjectStatus, ViableProjectTransition, ViableRelationship,
25
+ ViableSpecCategory, ViableStoryFieldsSchema, ViableStoryStatus, ViableStoryTransition,
26
+ } from '../src/planning/index.js'
27
+ import type { ViableProjectCard, ViableStoryCard } from '../src/planning/index.js'
28
+
29
+ const at = '2026-09-16T10:00:00.000Z'
30
+
31
+ const storyCard = (extra: Partial<ViableStoryCard> = {}): ViableStoryCard => ({
32
+ id: 'story-card',
33
+ kind: WorkcardKind.Card,
34
+ type: VIABLE_STORY_TYPE,
35
+ entityId: 'entity',
36
+ code: 'US-ABC12',
37
+ title: 'A member books a slot.',
38
+ parent: 'project-card',
39
+ parents: ['project-card'],
40
+ status: ViableStoryStatus.Planned,
41
+ intrinsic: IntrinsicStatus.Planned,
42
+ flows: { [VIABLE_STORY_FLOW]: ViableStoryStatus.Planned },
43
+ labels: [],
44
+ order: 1,
45
+ fields: { area: ProjectArea.User, primary: true },
46
+ seq: 1,
47
+ createdAt: at,
48
+ ...extra,
49
+ })
50
+
51
+ const projectCard = (extra: Partial<ViableProjectCard> = {}): ViableProjectCard => ({
52
+ id: 'project-card',
53
+ kind: WorkcardKind.Project,
54
+ type: VIABLE_PROJECT_TYPE,
55
+ entityId: 'entity',
56
+ code: 'slot-booker',
57
+ title: 'Slot Booker',
58
+ description: 'Book a slot.',
59
+ parents: [],
60
+ status: ViableProjectStatus.Draft,
61
+ intrinsic: IntrinsicStatus.Planned,
62
+ flows: { [VIABLE_PROJECT_FLOW]: ViableProjectStatus.Draft },
63
+ labels: [],
64
+ fields: { language: 'en' },
65
+ seq: 1,
66
+ createdAt: at,
67
+ ...extra,
68
+ })
69
+
70
+ const spec = (category: string, body: string, revision?: number): Specification => ({
71
+ id: `${category}-${revision ?? 0}`,
72
+ kind: WorkcardKind.Specification,
73
+ type: VIABLE_SPEC_TYPE,
74
+ entityId: 'entity',
75
+ title: category,
76
+ parent: 'project-card',
77
+ parents: ['project-card'],
78
+ status: 'current',
79
+ intrinsic: IntrinsicStatus.Closed,
80
+ flows: {},
81
+ labels: [],
82
+ fields: {},
83
+ seq: 1,
84
+ createdAt: at,
85
+ category,
86
+ format: SpecificationFormat.Markdown,
87
+ body,
88
+ ...(revision != null ? { revision } : {}),
89
+ })
90
+
91
+ const designed = (): StoryDesign => emptyStoryDesign({
92
+ code: 'US-OLD00',
93
+ narrative: 'The narrative the design was written against.',
94
+ area: ProjectArea.Guest,
95
+ entities: [{ name: 'Booking', description: 'A reservation', dir: 'booking' }],
96
+ screens: [{
97
+ name: 'booking/list/overview',
98
+ definition: 'booking-overview',
99
+ path: 'sources/web/src/screens/booking-overview.tsx',
100
+ alias: 'booking-overview',
101
+ url: '/frontoffice/booking-overview',
102
+ parent: 'area-user',
103
+ adopted: false,
104
+ specs: { ux: 'the ux', ui: 'the ui' },
105
+ components: [],
106
+ }],
107
+ })
108
+
109
+ describe('viable-common - the story flow', () => {
110
+ test('keeps the status keys every stored story, selector and frontmatter already carries', () => {
111
+ expect(Object.values(ViableStoryStatus)).toEqual(['planned', 'in-progress', 'completed', 'failed'])
112
+ expect(VIABLE_STORY_FLOW_SCHEMA.statuses.map(status => status.key))
113
+ .toEqual(['planned', 'in-progress', 'completed', 'failed'])
114
+ expect(VIABLE_STORY_FLOW_SCHEMA.statuses.filter(status => status.initial === true)
115
+ .map(status => status.key)).toEqual(['planned'])
116
+ })
117
+
118
+ test('maps a failed story onto PLANNED, so done is the closed count and a retry is a start', () => {
119
+ const flow = VIABLE_STORY_FLOW_SCHEMA
120
+
121
+ expect(intrinsicOf(flow, ViableStoryStatus.Planned)).toBe(IntrinsicStatus.Planned)
122
+ expect(intrinsicOf(flow, ViableStoryStatus.InProgress)).toBe(IntrinsicStatus.InProgress)
123
+ expect(intrinsicOf(flow, ViableStoryStatus.Completed)).toBe(IntrinsicStatus.Closed)
124
+ expect(intrinsicOf(flow, ViableStoryStatus.Failed)).toBe(IntrinsicStatus.Planned)
125
+ })
126
+
127
+ test('starts from planned or failed only, and resets from anywhere', () => {
128
+ const flow = VIABLE_STORY_FLOW_SCHEMA
129
+ const { Planned, InProgress, Completed, Failed } = ViableStoryStatus
130
+
131
+ expect(canTransit(flow, ViableStoryTransition.Start, Planned)).toBe(true)
132
+ expect(canTransit(flow, ViableStoryTransition.Start, Failed)).toBe(true)
133
+ expect(canTransit(flow, ViableStoryTransition.Start, InProgress)).toBe(false)
134
+ expect(canTransit(flow, ViableStoryTransition.Start, Completed)).toBe(false)
135
+ expect(canTransit(flow, ViableStoryTransition.Complete, InProgress)).toBe(true)
136
+ expect(canTransit(flow, ViableStoryTransition.Fail, InProgress)).toBe(true)
137
+ expect(canTransit(flow, ViableStoryTransition.Fail, Planned)).toBe(false)
138
+ for (const status of Object.values(ViableStoryStatus)) {
139
+ expect(canTransit(flow, ViableStoryTransition.Reset, status)).toBe(true)
140
+ }
141
+ })
142
+
143
+ test('offers a completed story nothing but a reset', () => {
144
+ expect(transitionsFrom(VIABLE_STORY_FLOW_SCHEMA, ViableStoryStatus.Completed).map(rule => rule.name))
145
+ .toEqual([ViableStoryTransition.Reset])
146
+ })
147
+ })
148
+
149
+ describe('viable-common - the project flow', () => {
150
+ test('drafts, confirms, activates, and archives from anywhere', () => {
151
+ const flow = VIABLE_PROJECT_FLOW_SCHEMA
152
+ const { Draft, Confirmed, Active, Archived } = ViableProjectStatus
153
+
154
+ expect(flow.statuses.find(status => status.initial === true)?.key).toBe(Draft)
155
+ expect(intrinsicOf(flow, Draft)).toBe(IntrinsicStatus.Planned)
156
+ expect(intrinsicOf(flow, Confirmed)).toBe(IntrinsicStatus.InProgress)
157
+ expect(intrinsicOf(flow, Active)).toBe(IntrinsicStatus.InProgress)
158
+ expect(intrinsicOf(flow, Archived)).toBe(IntrinsicStatus.Closed)
159
+ expect(canTransit(flow, ViableProjectTransition.Confirm, Draft)).toBe(true)
160
+ expect(canTransit(flow, ViableProjectTransition.Confirm, Active)).toBe(false)
161
+ expect(canTransit(flow, ViableProjectTransition.Activate, Confirmed)).toBe(true)
162
+ expect(canTransit(flow, ViableProjectTransition.Archive, Active)).toBe(true)
163
+ expect(canTransit(flow, ViableProjectTransition.Reopen, Archived)).toBe(true)
164
+ })
165
+ })
166
+
167
+ describe('viable-common - the viable types', () => {
168
+ test('register into a planning schema registry and survive the wire bundle a client boots from', () => {
169
+ const registry = makeSchemaRegistry({ types: VIABLE_TYPE_SCHEMAS, flows: VIABLE_FLOW_SCHEMAS })
170
+ const client = makeSchemaRegistry()
171
+ client.load(JSON.parse(JSON.stringify(registry.bundle())))
172
+
173
+ for (const type of VIABLE_TYPE_SCHEMAS) {
174
+ expect(() => client.validator(type.type), type.type).not.toThrow()
175
+ }
176
+ expect(client.primaryFlow(VIABLE_STORY_TYPE).id).toBe(VIABLE_STORY_FLOW)
177
+ expect(client.validator(VIABLE_STORY_TYPE)({ area: ProjectArea.Guest, primary: false })).toBe(true)
178
+ })
179
+
180
+ test('declares every flow a type runs, and carries a tone for every status', () => {
181
+ const flows = new Set(VIABLE_FLOW_SCHEMAS.map(flow => flow.id))
182
+
183
+ for (const type of VIABLE_TYPE_SCHEMAS) {
184
+ expect(type.flows.length).toBeGreaterThan(0)
185
+ for (const id of type.flows) expect(flows.has(id), `${type.type} → ${id}`).toBe(true)
186
+ }
187
+ for (const flow of VIABLE_FLOW_SCHEMAS) {
188
+ for (const status of flow.statuses) expect(status.tone, `${flow.id}.${status.key}`).toBeDefined()
189
+ }
190
+ })
191
+
192
+ test('slots the brief and the plan under a project, the design under a story, and no co-located spec', () => {
193
+ const project = VIABLE_PROJECT_TYPE_SCHEMA.specifications
194
+ const story = VIABLE_STORY_TYPE_SCHEMA.specifications
195
+
196
+ expect(project.map(slot => slot.category)).toEqual([
197
+ ViableSpecCategory.Specification, ViableSpecCategory.Vision, ViableSpecCategory.DesignSystem,
198
+ ViableSpecCategory.Scaffold,
199
+ ])
200
+ expect(project.find(slot => slot.category === ViableSpecCategory.Specification)?.required).toBe(true)
201
+ expect(project.find(slot => slot.category === ViableSpecCategory.Scaffold))
202
+ .toEqual(expect.objectContaining({ format: SpecificationFormat.Json, revisioned: true, keepRevisions: 3 }))
203
+ expect(story).toEqual([expect.objectContaining({
204
+ category: ViableSpecCategory.Design, format: SpecificationFormat.Json, revisioned: true,
205
+ keepRevisions: 3, version: STORY_DESIGN_VERSION,
206
+ })])
207
+
208
+ const categories = VIABLE_TYPE_SCHEMAS.flatMap(type => type.specifications.map(slot => slot.category))
209
+ for (const coLocated of Object.values(SpecCategory)) expect(categories).not.toContain(coLocated)
210
+ })
211
+
212
+ test('mints US- codes within the project and takes a project code as a slug within the entity', () => {
213
+ expect(VIABLE_STORY_TYPE_SCHEMA.code).toEqual({
214
+ prefix: 'US-', style: CodeStyle.Random, length: 5, uppercase: true, uniqueWithin: CodeScope.Parent,
215
+ })
216
+ expect(VIABLE_PROJECT_TYPE_SCHEMA.code).toEqual({ style: CodeStyle.Slug, uniqueWithin: CodeScope.Entity, mutable: true })
217
+ expect(VIABLE_STORY_TYPE_SCHEMA.code?.mutable).not.toBe(true)
218
+ expect(VIABLE_STORY_TYPE_SCHEMA.relationships?.map(link => link.name)).toEqual([
219
+ ViableRelationship.Follows, ViableRelationship.SharesWidget, ViableRelationship.SharesScreen,
220
+ ])
221
+ })
222
+
223
+ test('reserves bug, improvement and requirement on the story flow without letting a project hold one', () => {
224
+ expect(VIABLE_RESERVED_TYPE_SCHEMAS.map(type => type.code?.prefix)).toEqual(['BUG-', 'IMP-', 'REQ-'])
225
+ for (const type of VIABLE_RESERVED_TYPE_SCHEMAS) {
226
+ expect(type.flows).toEqual([VIABLE_STORY_FLOW])
227
+ expect(VIABLE_PROJECT_TYPE_SCHEMA.cardTypes).not.toContain(type.type)
228
+ }
229
+ expect(VIABLE_PROJECT_TYPE_SCHEMA.cardTypes).toEqual([VIABLE_STORY_TYPE])
230
+ })
231
+ })
232
+
233
+ describe('viable-common - the viable field schemas', () => {
234
+ const ajv = addFormats(new Ajv({ allErrors: true, strict: false }))
235
+ const story = ajv.compile(ViableStoryFieldsSchema)
236
+ const project = ajv.compile(ViableProjectFieldsSchema)
237
+
238
+ test('a story accepts its area and flag, and an explicit null for an optional', () => {
239
+ expect(story({ area: ProjectArea.User, primary: false })).toBe(true)
240
+ expect(story({
241
+ area: ProjectArea.Admin, primary: true, actor: StoryActor.Agent, warning: '', kind: StoryKind.Connective,
242
+ })).toBe(true)
243
+ expect(story({ area: ProjectArea.User, primary: false, kind: null, actor: null })).toBe(true)
244
+ })
245
+
246
+ test('a story refuses an undeclared field, a missing area, and a value outside a closed set', () => {
247
+ expect(story({ area: ProjectArea.User, primary: false, story: 'narrative' })).toBe(false)
248
+ expect(story({ primary: false })).toBe(false)
249
+ expect(story({ area: 'partner', primary: false })).toBe(false)
250
+ expect(story({ area: ProjectArea.User, primary: false, kind: 'queue' })).toBe(false)
251
+ })
252
+
253
+ test('a project accepts none of its fields, all of them, and refuses a stray one', () => {
254
+ expect(project({})).toBe(true)
255
+ expect(project({
256
+ formerAliases: ['old-name'], language: 'en', blueprint: 'owlmeans', blueprintCase: 'web',
257
+ target: null, connectLlmMode: 'local', origin: { kind: 'github', repoUrl: 'https://x/y' },
258
+ })).toBe(true)
259
+ expect(project({ alias: 'slot-booker' })).toBe(false)
260
+ })
261
+ })
262
+
263
+ describe('viable-common - the viable card helpers', () => {
264
+ test('recognises a card by kind AND type', () => {
265
+ expect(isViableStory(storyCard())).toBe(true)
266
+ expect(isViableStory(storyCard({ type: 'notion:task' }))).toBe(false)
267
+ expect(isViableProject(projectCard())).toBe(true)
268
+ expect(isViableProject(storyCard())).toBe(false)
269
+ })
270
+
271
+ test('userStoryOf lets the card win on narrative, code, area and actor, and takes screens from the design', () => {
272
+ const card = storyCard({ fields: { area: ProjectArea.User, primary: true, actor: StoryActor.Worker } })
273
+ const story = userStoryOf(card, designed())
274
+
275
+ expect(story.story).toBe(card.title)
276
+ expect(story.code).toBe('US-ABC12')
277
+ expect(story.area).toBe(ProjectArea.User)
278
+ expect(story.actor).toBe(StoryActor.Worker)
279
+ expect(story.screens.map(screen => screen.name)).toEqual(['booking/list/overview'])
280
+ expect(story.entities.map(entity => entity.name)).toEqual(['Booking'])
281
+ expect(userStoryOf(card).screens).toEqual([])
282
+ expect(storyDraftOf(card)).toEqual({ story: card.title, area: ProjectArea.User })
283
+ })
284
+
285
+ test('projectBriefOf reads the highest revision per part and answers an unwritten part empty', () => {
286
+ const brief = projectBriefOf(projectCard(), [
287
+ spec(ViableSpecCategory.Specification, 'first', 1),
288
+ spec(ViableSpecCategory.Specification, 'second', 2),
289
+ spec(ViableSpecCategory.Vision, 'the vision'),
290
+ ])
291
+
292
+ expect(brief).toEqual({
293
+ name: 'Slot Booker', alias: 'slot-booker', description: 'Book a slot.', specification: 'second',
294
+ vision: 'the vision', designSystem: '', language: 'en',
295
+ })
296
+ })
297
+
298
+ test('storyWriteInputOf takes code, narrative and primary from the card, and refuses a card with no code', () => {
299
+ const content = {
300
+ userStory: userStoryOf(storyCard()), screenPaths: {}, componentPaths: {}, transitions: [],
301
+ section: 'Booking',
302
+ }
303
+
304
+ expect(storyWriteInputOf(storyCard(), content)).toEqual(expect.objectContaining({
305
+ code: 'US-ABC12', narrative: 'A member books a slot.', primary: true,
306
+ status: ViableStoryStatus.Planned, section: 'Booking',
307
+ }))
308
+ expect(storyWriteInputOf(storyCard(), { ...content, status: ViableStoryStatus.Completed }).status)
309
+ .toBe(ViableStoryStatus.Completed)
310
+ expect(() => storyWriteInputOf(storyCard({ code: undefined }), content))
311
+ .toThrow(ProjectStoryMissconfigured)
312
+ })
313
+
314
+ test('only a person writes through the web and connect channels', () => {
315
+ expect(isUserChannel(ViableChannel.Web)).toBe(true)
316
+ expect(isUserChannel(ViableChannel.Connect)).toBe(true)
317
+ expect(isUserChannel(ViableChannel.Pipeline)).toBe(false)
318
+ expect(isUserChannel(undefined)).toBe(false)
319
+ })
320
+ })
321
+
322
+ describe('viable-common - the follows anchor', () => {
323
+ test('mergeConnectingStories carries the clamped anchor on each connective entry', () => {
324
+ const flow = [
325
+ { story: 'One', area: ProjectArea.User },
326
+ { story: 'Two', area: ProjectArea.User },
327
+ { story: 'Three', area: ProjectArea.Admin },
328
+ ]
329
+ const merged = mergeConnectingStories(flow, [
330
+ { story: 'Queue', area: ProjectArea.Admin, kind: ConnectingStoryKind.Queue, after: 9 },
331
+ { story: 'Tracker', area: ProjectArea.User, kind: ConnectingStoryKind.Tracker, after: 1 },
332
+ ])
333
+
334
+ expect(merged.map(({ story, kind, after }) => ({ story, kind, after }))).toEqual([
335
+ { story: 'One', kind: StoryKind.Flow, after: undefined },
336
+ { story: 'Tracker', kind: StoryKind.Connective, after: 1 },
337
+ { story: 'Two', kind: StoryKind.Flow, after: undefined },
338
+ { story: 'Three', kind: StoryKind.Flow, after: undefined },
339
+ { story: 'Queue', kind: StoryKind.Connective, after: 3 },
340
+ ])
341
+ })
342
+ })
343
+
344
+ describe('viable-common - the project refusals', () => {
345
+ test('keep the type names and markers the browser and the connector match on', () => {
346
+ const occupied = ResilientError.ensure(new ProjectAgentOccupied('story').marshal())
347
+ const refused = ResilientError.ensure(new ProjectStoryMissconfigured('wrong-status').marshal())
348
+
349
+ expect(occupied).toBeInstanceOf(ProjectAgentOccupied)
350
+ expect(occupied.type).toBe('OccupiedAgentViableProjectResilientError')
351
+ expect(occupied.message).toContain('viable-project:agent:occupied:story')
352
+ expect(refused).toBeInstanceOf(ProjectStoryMissconfigured)
353
+ expect(refused.message).toContain('viable-project:story:missconfigured:wrong-status')
354
+ expect(new ProjectNotFound('x').type).toBe('NotFoundViableProjectResourceError')
355
+ })
356
+ })