@cat-factory/app 0.201.1 → 0.204.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.
Files changed (47) hide show
  1. package/README.md +130 -10
  2. package/app/components/binaryOutput/BinaryOutputReport.vue +186 -0
  3. package/app/components/initiative/InitiativePlanReview.vue +11 -1
  4. package/app/components/panels/AgentStepDetail.vue +10 -0
  5. package/app/components/panels/ResultWindowShell.vue +86 -0
  6. package/app/components/pipeline/BinaryOutputStepPicker.vue +147 -0
  7. package/app/components/pipeline/PipelineBuilder.vue +54 -0
  8. package/app/components/settings/OpenRouterCatalogPanel.vue +6 -3
  9. package/app/components/tutorial/TutorialCatalogue.logic.spec.ts +103 -0
  10. package/app/components/tutorial/TutorialCatalogue.logic.ts +102 -0
  11. package/app/components/tutorial/TutorialCatalogue.vue +150 -0
  12. package/app/components/tutorial/TutorialOverlay.logic.spec.ts +46 -0
  13. package/app/components/tutorial/TutorialOverlay.logic.ts +53 -0
  14. package/app/components/tutorial/TutorialOverlay.vue +296 -40
  15. package/app/components/tutorial/TutorialPrompt.vue +41 -22
  16. package/app/composables/useNavContributions.ts +4 -1
  17. package/app/composables/useTutorialLaunch.ts +50 -0
  18. package/app/composables/useTutorialTours.ts +37 -9
  19. package/app/docs/consumer-extensions.md +24 -11
  20. package/app/modular/agent-kinds.ts +6 -0
  21. package/app/modular/nav-contributions.spec.ts +7 -0
  22. package/app/modular/nav-contributions.ts +25 -13
  23. package/app/modular/slots.ts +5 -2
  24. package/app/modular/tutorial-tours.spec.ts +189 -53
  25. package/app/modular/tutorial-tours.ts +57 -8
  26. package/app/pages/index.vue +7 -2
  27. package/app/stores/pipelines/draftBinaryOutput.spec.ts +70 -0
  28. package/app/stores/pipelines/draftStepConfig.ts +38 -2
  29. package/app/stores/tutorial.spec.ts +167 -0
  30. package/app/stores/tutorial.ts +140 -4
  31. package/app/types/domain.ts +9 -0
  32. package/app/types/execution.ts +5 -0
  33. package/app/utils/binaryOutput.spec.ts +307 -0
  34. package/app/utils/binaryOutput.ts +343 -0
  35. package/app/utils/tutorial.spec.ts +179 -9
  36. package/app/utils/tutorial.ts +233 -22
  37. package/i18n/locales/de.json +89 -7
  38. package/i18n/locales/en.json +101 -7
  39. package/i18n/locales/es.json +89 -7
  40. package/i18n/locales/fr.json +89 -7
  41. package/i18n/locales/he.json +89 -7
  42. package/i18n/locales/it.json +89 -7
  43. package/i18n/locales/ja.json +89 -7
  44. package/i18n/locales/pl.json +89 -7
  45. package/i18n/locales/tr.json +89 -7
  46. package/i18n/locales/uk.json +89 -7
  47. package/package.json +2 -2
@@ -0,0 +1,343 @@
1
+ import { ASSET_STORAGE_CAPABILITY } from '@cat-factory/contracts'
2
+ import type {
3
+ BinaryOutputArtifact,
4
+ BinaryOutputConfig,
5
+ BinaryOutputReport,
6
+ PipelineStep,
7
+ } from '~/types/execution'
8
+ import type { ResolvedFoundationalService } from '~/types/domain'
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // The read model behind the binary-output surface
12
+ // (docs/initiatives/binary-output-foundational-storage.md).
13
+ //
14
+ // A step whose kind carries the `binary-output` trait delivers BINARY artifacts through a
15
+ // foundational service its step selected, and declares what it stored in a fenced block the
16
+ // engine parses onto `step.binaryOutputs`. That parse deliberately keeps every failure mode
17
+ // apart — `undeclared` ≠ `parseFailed` ≠ an empty `stored`, with `invalidEntries` / `omitted`
18
+ // / `unknownServices` counted or named rather than absorbed — so the whole job of this module
19
+ // is to NOT collapse them again on the way to a renderer.
20
+ //
21
+ // Pure: it reads the step's own recorded report and its own recorded selection, and nothing
22
+ // else. That is deliberate — the join a human actually wants ("did it go where I pointed
23
+ // it?") is answerable from those two alone, so the surface needs no catalog fetch and reads
24
+ // identically for a run whose services were withdrawn afterwards.
25
+ // ---------------------------------------------------------------------------
26
+
27
+ /**
28
+ * What a step's binary-output record says happened, as one discriminant. Every member is a
29
+ * DIFFERENT fact with a different fix, and the renderer keys its copy off an exhaustive map so
30
+ * a sixth member fails the typecheck rather than rendering a missing key.
31
+ *
32
+ * There is deliberately no "was never briefed" member: that is {@link binaryOutputView}
33
+ * returning null and the surface disappearing, exactly as the effort and validation sections
34
+ * do. A row saying "no binary output was expected here" would ride every step of every run.
35
+ */
36
+ export type BinaryOutputState =
37
+ /** The step selected a storage service and has not started yet, so there is nothing to have
38
+ * recorded. Its own fact, for the same reason a SKIPPED step renders nothing: `configured`
39
+ * says "running, or it died", and a queued step is neither. What it does say is worth
40
+ * saying — this is where the artifacts will land — so it renders rather than disappearing. */
41
+ | 'not-started'
42
+ /** The step STARTED, selected a storage service, and no declaration has been recorded — it is
43
+ * still running, or it died before settlement. NOT "stored nothing". */
44
+ | 'configured'
45
+ /** The step settled and its reply carried no declaration block at all. The agent may or may
46
+ * not have stored something; nothing was recorded either way. */
47
+ | 'undeclared'
48
+ /** A declaration block was present and unreadable. Same practical outcome as `undeclared`,
49
+ * different cause — and the only one of the two that is a prompt/model problem. */
50
+ | 'parse-failed'
51
+ /** The agent explicitly declared it stored nothing. A legitimate outcome, not an error. */
52
+ | 'declared-none'
53
+ /** The agent declared artifacts; {@link BinaryOutputView.rows} holds them. */
54
+ | 'stored'
55
+
56
+ /** One declared artifact, with the two judgements the step's own record supports. */
57
+ export interface BinaryOutputRow extends BinaryOutputArtifact {
58
+ /**
59
+ * The artifact was stored through a service OTHER than the one this step selected. Not an
60
+ * error the platform can settle — the agent may have had a reason — but it is the question a
61
+ * human opens this surface to answer, and nothing else records it.
62
+ */
63
+ misdirected: boolean
64
+ /** The named service was not in the resolved catalog when the declaration was parsed. */
65
+ unknown: boolean
66
+ }
67
+
68
+ /** The whole surface's read model: one state, the join, and every loss the report counted. */
69
+ export interface BinaryOutputView {
70
+ state: BinaryOutputState
71
+ /**
72
+ * The storage service the STEP selected (`stepOptions.binaryOutput.storageServiceId`), or
73
+ * null when the step carries no selection. Null is a real state, not a gap to hide: a
74
+ * trait-carrying kind dispatched under an OVERRIDING kind records a declaration against a
75
+ * step that never held the selection, so there is genuinely nothing to compare against and
76
+ * the surface must say so rather than implying the artifacts went astray.
77
+ */
78
+ target: string | null
79
+ /** The context services the step selected, in selection order. */
80
+ contextServices: readonly string[]
81
+ rows: readonly BinaryOutputRow[]
82
+ /**
83
+ * The step's OWN configured target was not in the resolved catalog when the declaration was
84
+ * parsed — the catalog changed under the run, rather than the agent naming a service that
85
+ * never existed. Different causes, different fixes: re-register the service, versus correct
86
+ * the declaration.
87
+ */
88
+ targetUnknown: boolean
89
+ /**
90
+ * Unknown service ids the AGENT named, verbatim and EXCLUDING the step's own target, which
91
+ * {@link targetUnknown} already owns.
92
+ *
93
+ * The exclusion is what makes the two facts DISJOINT, and it lives here rather than in a
94
+ * renderer on purpose: the report's own `unknownServices` mixes them, so a surface reading it
95
+ * raw either reports the lost target twice or — the way this shipped — labels every unknown
96
+ * id as "this step's own storage service" and drops the invented ones entirely. Two fields
97
+ * that cannot overlap is the only shape where naming one cannot mis-state the other.
98
+ */
99
+ unknownDeclaredServices: readonly string[]
100
+ /** Entries dropped because they were not `{ service, location }` objects. */
101
+ invalidEntries: number
102
+ /** Valid entries dropped past the report's cap — so {@link rows} is a PREFIX. */
103
+ omitted: number
104
+ /** How many of {@link rows} went somewhere other than {@link target}. */
105
+ misdirected: number
106
+ }
107
+
108
+ /**
109
+ * The step's binary-output read model, or null when the step has no binary-output story at all
110
+ * (no recorded report AND no storage selection) — which is every step of every stock pipeline,
111
+ * so the surface simply does not render.
112
+ *
113
+ * A step carrying a SELECTION but no report still renders: it was briefed, and "briefed, with
114
+ * nothing recorded" is a fact worth stating on a run that died mid-generation. A step carrying
115
+ * a REPORT but no selection renders too, with a null target (see {@link BinaryOutputView.target}).
116
+ *
117
+ * The one exception is a step SKIPPED by estimate gating: it holds a selection it never ran
118
+ * with, so no state describing a dispatch is true of it, and the panel already marks it as
119
+ * skipped. A skipped step genuinely has no binary-output story, so it takes the same absence as
120
+ * an unbriefed one. A step that has not started YET is the neighbouring case and resolves the
121
+ * other way — it still has a story ahead of it, told by `not-started`. (Either with a REPORT is
122
+ * not reachable — nothing dispatched — but if one ever were, the record wins: a recorded claim
123
+ * is never hidden.)
124
+ */
125
+ export function binaryOutputView(step: PipelineStep | null | undefined): BinaryOutputView | null {
126
+ const report = step?.binaryOutputs ?? null
127
+ const config = step?.stepOptions?.binaryOutput ?? null
128
+ if (!report && (!config || step?.skipped)) return null
129
+
130
+ const target = config?.storageServiceId ?? null
131
+ const contextServices = config?.contextServiceIds ?? []
132
+ if (!report) {
133
+ return {
134
+ // A step still queued has not had the chance to record anything, which is a different
135
+ // fact from having had it and not taken it.
136
+ state: step?.state === 'pending' ? 'not-started' : 'configured',
137
+ target,
138
+ contextServices,
139
+ rows: [],
140
+ targetUnknown: false,
141
+ unknownDeclaredServices: [],
142
+ invalidEntries: 0,
143
+ omitted: 0,
144
+ misdirected: 0,
145
+ }
146
+ }
147
+
148
+ const unknown = new Set(report.unknownServices)
149
+ const rows: BinaryOutputRow[] = report.stored.map((artifact) => ({
150
+ ...artifact,
151
+ // A null target cannot make anything misdirected: there is no place it was supposed to go.
152
+ misdirected: target !== null && artifact.service !== target,
153
+ unknown: unknown.has(artifact.service),
154
+ }))
155
+
156
+ return {
157
+ state: reportState(report),
158
+ target,
159
+ contextServices,
160
+ rows,
161
+ targetUnknown: target !== null && unknown.has(target),
162
+ unknownDeclaredServices: report.unknownServices.filter((id) => id !== target),
163
+ invalidEntries: report.invalidEntries,
164
+ omitted: report.omitted,
165
+ misdirected: rows.filter((row) => row.misdirected).length,
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Which failure the report records, in the order the parser can produce them. `parseFailed`
171
+ * and `undeclared` are checked BEFORE the (always empty in those cases) `stored` list, so a
172
+ * missing or unreadable block is never reported as "the agent said it stored nothing".
173
+ */
174
+ function reportState(report: BinaryOutputReport): BinaryOutputState {
175
+ if (report.parseFailed) return 'parse-failed'
176
+ if (report.undeclared) return 'undeclared'
177
+ return report.stored.length > 0 ? 'stored' : 'declared-none'
178
+ }
179
+
180
+ /**
181
+ * State → i18n keys, exhaustive over the discriminant so a sixth outcome fails the typecheck
182
+ * here rather than rendering a missing key. It lives beside the discriminant, not in a
183
+ * component, because BOTH surfaces read it: the collapsed section row shows `summary` (a few
184
+ * words, the outcome and nothing else) and the expanded panel shows `detail` (what the outcome
185
+ * means and what, if anything, to do). A single map is what keeps the row from claiming an
186
+ * outcome the panel below it then qualifies away.
187
+ *
188
+ * `stored` has an empty detail on purpose: the artifacts themselves are the statement, and a
189
+ * sentence above them would only restate the list's own length.
190
+ */
191
+ export const BINARY_OUTPUT_STATE_KEYS: Record<
192
+ BinaryOutputState,
193
+ { icon: string; tone: string; summary: string; detail: string }
194
+ > = {
195
+ 'not-started': {
196
+ icon: 'i-lucide-clock',
197
+ tone: 'text-slate-400',
198
+ summary: 'binaryOutput.state.notStarted.summary',
199
+ detail: 'binaryOutput.state.notStarted.detail',
200
+ },
201
+ configured: {
202
+ icon: 'i-lucide-hourglass',
203
+ tone: 'text-slate-300',
204
+ summary: 'binaryOutput.state.configured.summary',
205
+ detail: 'binaryOutput.state.configured.detail',
206
+ },
207
+ undeclared: {
208
+ icon: 'i-lucide-circle-help',
209
+ tone: 'text-amber-300',
210
+ summary: 'binaryOutput.state.undeclared.summary',
211
+ detail: 'binaryOutput.state.undeclared.detail',
212
+ },
213
+ 'parse-failed': {
214
+ icon: 'i-lucide-file-warning',
215
+ tone: 'text-amber-300',
216
+ summary: 'binaryOutput.state.parseFailed.summary',
217
+ detail: 'binaryOutput.state.parseFailed.detail',
218
+ },
219
+ 'declared-none': {
220
+ icon: 'i-lucide-circle-slash',
221
+ tone: 'text-slate-300',
222
+ summary: 'binaryOutput.state.declaredNone.summary',
223
+ detail: 'binaryOutput.state.declaredNone.detail',
224
+ },
225
+ stored: {
226
+ icon: 'i-lucide-package-check',
227
+ tone: 'text-emerald-300',
228
+ summary: 'binaryOutput.state.stored.summary',
229
+ detail: '',
230
+ },
231
+ }
232
+
233
+ /**
234
+ * Whether the view carries any qualification a reader must see beside the artifacts —
235
+ * unknown service ids, dropped entries, a truncated list, or a misdirected artifact. Drives
236
+ * the collapsed summary row's tone, so a report with losses can't read as a clean one from
237
+ * the outside of a collapsed section.
238
+ */
239
+ export function binaryOutputHasWarnings(view: BinaryOutputView): boolean {
240
+ return (
241
+ view.state === 'parse-failed' ||
242
+ view.state === 'undeclared' ||
243
+ view.targetUnknown ||
244
+ view.unknownDeclaredServices.length > 0 ||
245
+ view.invalidEntries > 0 ||
246
+ view.omitted > 0 ||
247
+ view.misdirected > 0
248
+ )
249
+ }
250
+
251
+ // ---------------------------------------------------------------------------
252
+ // The pipeline builder's half: what is wrong with a step's SELECTION, before it is saved.
253
+ // ---------------------------------------------------------------------------
254
+
255
+ /**
256
+ * One thing wrong with a binary-generating step's selection, as the builder can see it.
257
+ *
258
+ * The two `*_service` members mirror the kernel's `BinaryOutputConfigIssue.problem` values
259
+ * VERBATIM, because the builder's job here is to surface the run-admission refusal
260
+ * (`binary_output_service_invalid`) BEFORE the round trip rather than to invent a second
261
+ * opinion about the same catalog. They are restated rather than imported: the SPA cannot see
262
+ * kernel, and the wire vocabulary that crosses to it (`@cat-factory/contracts`) carries the
263
+ * error code, not the issue enum. The remaining three are conditions the BUILDER alone can be
264
+ * in — nothing is picked yet, or there is nothing to pick from.
265
+ */
266
+ export type BinaryOutputPickIssue =
267
+ /** The catalog read failed (the feature is unconfigured, or the request 503'd). Not the same
268
+ * as an empty catalog: an empty picker reads as "no services exist", which is a claim. */
269
+ | 'catalog_unavailable'
270
+ /** The catalog resolved, but nothing in it declares the `asset-storage` capability. */
271
+ | 'no_storage_service'
272
+ /** An enabled generator step with no storage selection — refused at save AND at start. */
273
+ | 'not_selected'
274
+ /** The selected storage id is not in the resolved catalog (kernel's `unknown_service`). */
275
+ | 'unknown_service'
276
+ /** The selected storage service dropped its `asset-storage` tag (kernel's own spelling). */
277
+ | 'not_storage_capable'
278
+ /** One or more selected CONTEXT ids are not in the resolved catalog. */
279
+ | 'unknown_context_service'
280
+
281
+ /** What the builder found wrong with one step's selection, and which ids to name. */
282
+ export interface BinaryOutputPickState {
283
+ issues: readonly BinaryOutputPickIssue[]
284
+ /** The unresolved CONTEXT ids, for the message that names them. */
285
+ unknownContextIds: readonly string[]
286
+ }
287
+
288
+ /**
289
+ * Validate a step's selection against the workspace's RESOLVED catalog — the same catalog run
290
+ * admission re-validates against, which is the whole reason the picker offers only resolved
291
+ * services: an id offered from a stale client copy saves clean and fails at run START, one
292
+ * refusal cycle later.
293
+ *
294
+ * `available` is the catalog's own probe state, threaded separately because it distinguishes
295
+ * three things an empty array cannot: NOT PROBED YET (`null`), UNREACHABLE (`false`) and
296
+ * genuinely EMPTY (`true`) — opposite facts with opposite fixes, and only the last is worth a
297
+ * "register a storage service" hint. Every judgement about the CATALOG therefore requires
298
+ * `available === true`; only `not_selected`, which is a fact about the STEP, holds regardless.
299
+ * Without that a step would be flagged for re-pick during the load that is about to resolve it,
300
+ * and again during an outage that changed nothing about it.
301
+ *
302
+ * Returns EVERY issue, not the first, for the same reason `binaryOutputConfigIssues` does:
303
+ * naming one at a time costs a fix-and-retry cycle per lost service. The ONE subsumption is
304
+ * `no_storage_service`, which suppresses the per-selection storage judgements below it: both
305
+ * of those tell the user to pick another service, and there is none to pick — an instruction
306
+ * the surface cannot carry out is worse than silence, and the remedy that IS actionable
307
+ * (register one) is already stated. The CONTEXT half is unaffected: it is a different
308
+ * selection, judged on existence alone, and stays actionable whatever the storage tier looks
309
+ * like.
310
+ */
311
+ export function binaryOutputPickIssues(
312
+ config: BinaryOutputConfig | undefined,
313
+ catalog: readonly Pick<ResolvedFoundationalService, 'id' | 'capabilities'>[],
314
+ available: boolean | null,
315
+ ): BinaryOutputPickState {
316
+ const resolved = available === true
317
+ const issues: BinaryOutputPickIssue[] = []
318
+ const noStorageService =
319
+ resolved && !catalog.some((s) => s.capabilities.includes(ASSET_STORAGE_CAPABILITY))
320
+ if (available === false) issues.push('catalog_unavailable')
321
+ else if (noStorageService) issues.push('no_storage_service')
322
+
323
+ const storageId = config?.storageServiceId?.trim()
324
+ if (!storageId) {
325
+ issues.push('not_selected')
326
+ return { issues, unknownContextIds: [] }
327
+ }
328
+
329
+ if (resolved && !noStorageService) {
330
+ const storage = catalog.find((s) => s.id === storageId)
331
+ if (!storage) issues.push('unknown_service')
332
+ else if (!storage.capabilities.includes(ASSET_STORAGE_CAPABILITY))
333
+ issues.push('not_storage_capable')
334
+ }
335
+
336
+ const known = new Set(catalog.map((s) => s.id))
337
+ const unknownContextIds = resolved
338
+ ? (config?.contextServiceIds ?? []).filter((id) => !known.has(id))
339
+ : []
340
+ if (unknownContextIds.length) issues.push('unknown_context_service')
341
+
342
+ return { issues, unknownContextIds }
343
+ }
@@ -1,6 +1,18 @@
1
1
  import { describe, expect, it } from 'vitest'
2
- import { computeCoachMarkLayout, resolveTours, sortTours } from '~/utils/tutorial'
3
- import type { TutorialStep, TutorialTour } from '~/utils/tutorial'
2
+ import en from '../../i18n/locales/en.json'
3
+ import {
4
+ computeCoachMarkLayout,
5
+ launchActionFor,
6
+ needsReveal,
7
+ resolveTourCatalogue,
8
+ resolveTours,
9
+ sortTours,
10
+ tourState,
11
+ TUTORIAL_ACTION_KEYS,
12
+ TUTORIAL_STATUS_KEYS,
13
+ visibleArea,
14
+ } from '~/utils/tutorial'
15
+ import type { TutorialRequirement, TutorialStep, TutorialTour } from '~/utils/tutorial'
4
16
  import type { NavGates } from '~/modular/nav-contributions'
5
17
 
6
18
  const tour = (id: string, order: number): TutorialTour => ({
@@ -33,15 +45,22 @@ const step = (id: string, when?: TutorialStep['when']): TutorialStep => ({
33
45
  when,
34
46
  })
35
47
 
36
- const withSteps = (id: string, steps: TutorialStep[], when?: TutorialTour['when']): TutorialTour =>
37
- ({ ...tour(id, 10), steps, when }) as TutorialTour
48
+ /** A requirement over the one gate field these cases vary. */
49
+ const needsAdvanced: TutorialRequirement = {
50
+ id: 'advanced',
51
+ labelKey: 'tutorial.requirements.boardWrite',
52
+ met: (g) => g.advancedMode,
53
+ }
54
+
55
+ const withSteps = (
56
+ id: string,
57
+ steps: TutorialStep[],
58
+ requires?: readonly TutorialRequirement[],
59
+ ): TutorialTour => ({ ...tour(id, 10), steps, requires })
38
60
 
39
61
  describe('resolveTours', () => {
40
- it('drops a tour its own `when` rejects', () => {
41
- const tours = [
42
- withSteps('a', [step('one')], (g) => g.advancedMode),
43
- withSteps('b', [step('one')]),
44
- ]
62
+ it('drops a tour whose requirements are unmet', () => {
63
+ const tours = [withSteps('a', [step('one')], [needsAdvanced]), withSteps('b', [step('one')])]
45
64
  expect(resolveTours(tours, gates(false)).map((t) => t.id)).toEqual(['b'])
46
65
  })
47
66
 
@@ -64,6 +83,105 @@ describe('resolveTours', () => {
64
83
  const t = withSteps('a', [step('one')])
65
84
  expect(resolveTours([t], gates(true))[0]).toBe(t)
66
85
  })
86
+
87
+ it('withholds nothing when no gates service is wired', () => {
88
+ // Dev-open parity, and the case a bare install runs in: with nothing to gate against,
89
+ // a required tour is still offered and its branch steps are not silently thinned.
90
+ const t = withSteps('a', [step('one'), step('two', (g) => g.advancedMode)], [needsAdvanced])
91
+ expect(resolveTours([t], null)[0]?.steps.map((s) => s.id)).toEqual(['one', 'two'])
92
+ })
93
+ })
94
+
95
+ describe('resolveTourCatalogue', () => {
96
+ it('keeps an unavailable tour, saying which requirements are unmet', () => {
97
+ // The whole reason the catalogue resolves rather than filters: a tour dropped from the
98
+ // list is indistinguishable from one this deployment never shipped.
99
+ const t = withSteps('a', [step('one')], [needsAdvanced])
100
+ const [entry] = resolveTourCatalogue([t], gates(false))
101
+ expect(entry?.availability).toBe('blocked')
102
+ expect(entry?.unmet.map((r) => r.id)).toEqual(['advanced'])
103
+ })
104
+
105
+ it('reports only the requirements that are actually unmet', () => {
106
+ const met: TutorialRequirement = { id: 'met', labelKey: 'x', met: () => true }
107
+ const t = withSteps('a', [step('one')], [met, needsAdvanced])
108
+ expect(resolveTourCatalogue([t], gates(false))[0]?.unmet.map((r) => r.id)).toEqual(['advanced'])
109
+ })
110
+
111
+ it('separates "requirements unmet" from "no step applies here"', () => {
112
+ // Two different facts needing two different reactions: one names something the reader can
113
+ // go and do, the other names nothing at all — telling them to fix it would send them
114
+ // looking for a control that was never missing.
115
+ const t = withSteps('a', [step('advancedOnly', (g) => g.advancedMode)])
116
+ const [entry] = resolveTourCatalogue([t], gates(false))
117
+ expect(entry?.availability).toBe('not-applicable')
118
+ expect(entry?.unmet).toEqual([])
119
+ })
120
+
121
+ it('reports a tour that is both blocked and stepless as blocked', () => {
122
+ // Precedence, pinned. A step's `when` reads the same gates the requirements do, so with the
123
+ // requirements unmet the step filter is answering a hypothetical — what would apply on a
124
+ // board this one is by construction not. Calling that `not-applicable` would tell the reader
125
+ // nothing can be done about a tour they can in fact unlock.
126
+ const t = withSteps('a', [step('advancedOnly', (g) => g.advancedMode)], [needsAdvanced])
127
+ const [entry] = resolveTourCatalogue([t], gates(false))
128
+ expect(entry?.availability).toBe('blocked')
129
+ expect(entry?.unmet.map((r) => r.id)).toEqual(['advanced'])
130
+ })
131
+
132
+ it('is sorted, and agrees with resolveTours about what is ready', () => {
133
+ const tours = [
134
+ withSteps('c', [step('one')], [needsAdvanced]),
135
+ { ...withSteps('a', [step('one')]), order: 20 },
136
+ { ...withSteps('b', [step('one')]), order: 5 },
137
+ ]
138
+ const catalogue = resolveTourCatalogue(tours, gates(false))
139
+ expect(catalogue.map((e) => e.tour.id)).toEqual(['b', 'c', 'a'])
140
+ expect(resolveTours(tours, gates(false)).map((t) => t.id)).toEqual(
141
+ catalogue.filter((e) => e.availability === 'ready').map((e) => e.tour.id),
142
+ )
143
+ })
144
+ })
145
+
146
+ describe('tourState / launchActionFor', () => {
147
+ const state = (over: Partial<Parameters<typeof tourState>[0]>) =>
148
+ tourState({ active: false, resumable: false, completed: false, ...over })
149
+
150
+ it('reports a running tour as in progress, whatever else is true of it', () => {
151
+ expect(state({ active: true, resumable: true, completed: true })).toBe('inProgress')
152
+ expect(launchActionFor('inProgress')).toBe('continue')
153
+ })
154
+
155
+ it('prefers a broken-off position over a past completion', () => {
156
+ // Resume beats Completed: a tour taken again and broken off is offered where it stopped,
157
+ // rather than described by the badge it earned last time.
158
+ expect(state({ resumable: true, completed: true })).toBe('paused')
159
+ expect(launchActionFor('paused')).toBe('resume')
160
+ })
161
+
162
+ it('falls back to completion, then to untouched', () => {
163
+ expect(state({ completed: true })).toBe('completed')
164
+ expect(launchActionFor('completed')).toBe('restart')
165
+ expect(state({})).toBe('notStarted')
166
+ expect(launchActionFor('notStarted')).toBe('start')
167
+ })
168
+ })
169
+
170
+ describe('the status / action copy tables', () => {
171
+ it('resolves every key against the en catalog', () => {
172
+ // These are the lookups the typed-message-key check cannot see (a key assembled from a
173
+ // state), so a rename would otherwise reach the user as a raw path on a button.
174
+ const lookup = (key: string) =>
175
+ key
176
+ .split('.')
177
+ .reduce<unknown>((node, part) => (node as Record<string, unknown> | undefined)?.[part], en)
178
+ for (const key of [
179
+ ...Object.values(TUTORIAL_STATUS_KEYS),
180
+ ...Object.values(TUTORIAL_ACTION_KEYS),
181
+ ]) {
182
+ expect(typeof lookup(key), key).toBe('string')
183
+ }
184
+ })
67
185
  })
68
186
 
69
187
  const viewport = { width: 1000, height: 800 }
@@ -113,3 +231,55 @@ describe('computeCoachMarkLayout', () => {
113
231
  expect(layout.left).toBe(10)
114
232
  })
115
233
  })
234
+
235
+ describe('needsReveal', () => {
236
+ const viewport = { width: 1000, height: 800 }
237
+
238
+ it('leaves a fully visible anchor alone', () => {
239
+ expect(needsReveal({ top: 100, left: 100, width: 120, height: 40 }, viewport)).toBe(false)
240
+ })
241
+
242
+ it('reveals an anchor scrolled or panned clean off screen', () => {
243
+ // The case the runtime could not see: an element off the viewport still has layout boxes,
244
+ // so it passed the visibility check and the ring was drawn at coordinates nobody can see.
245
+ expect(needsReveal({ top: -400, left: 100, width: 120, height: 40 }, viewport)).toBe(true)
246
+ expect(needsReveal({ top: 100, left: 1400, width: 120, height: 40 }, viewport)).toBe(true)
247
+ })
248
+
249
+ it('reveals a small anchor that is only slightly on screen', () => {
250
+ // 25% of its width inside the right edge: enough to have a rect, not enough to point at.
251
+ expect(needsReveal({ top: 100, left: 970, width: 120, height: 40 }, viewport)).toBe(true)
252
+ })
253
+
254
+ it('leaves an anchor BIGGER than the viewport alone while it fills the screen', () => {
255
+ // `board-canvas` and `sidebar` can never clear a fraction of their own area, so measuring
256
+ // against that would pan the camera on every step that points at one of them.
257
+ expect(needsReveal({ top: -200, left: -200, width: 2000, height: 1600 }, viewport)).toBe(false)
258
+ })
259
+
260
+ it('reveals an oversized anchor that has left the screen anyway', () => {
261
+ expect(needsReveal({ top: -1700, left: 0, width: 2000, height: 1600 }, viewport)).toBe(true)
262
+ })
263
+
264
+ it('never reveals a zero-area anchor', () => {
265
+ // There is no position to bring anywhere, and treating it as off-screen would make every
266
+ // degenerate rect trigger a camera move.
267
+ expect(needsReveal({ top: 0, left: 0, width: 0, height: 0 }, viewport)).toBe(false)
268
+ })
269
+ })
270
+
271
+ describe('visibleArea', () => {
272
+ const viewport = { width: 1000, height: 800 }
273
+
274
+ it('is the full area when the rect is inside', () => {
275
+ expect(visibleArea({ top: 10, left: 10, width: 100, height: 50 }, viewport)).toBe(5000)
276
+ })
277
+
278
+ it('is the clipped area when the rect straddles an edge', () => {
279
+ expect(visibleArea({ top: 10, left: -60, width: 100, height: 50 }, viewport)).toBe(40 * 50)
280
+ })
281
+
282
+ it('is zero for a rect with no overlap at all', () => {
283
+ expect(visibleArea({ top: 10, left: 2000, width: 100, height: 50 }, viewport)).toBe(0)
284
+ })
285
+ })