@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.
- package/README.md +130 -10
- package/app/components/binaryOutput/BinaryOutputReport.vue +186 -0
- package/app/components/initiative/InitiativePlanReview.vue +11 -1
- package/app/components/panels/AgentStepDetail.vue +10 -0
- package/app/components/panels/ResultWindowShell.vue +86 -0
- package/app/components/pipeline/BinaryOutputStepPicker.vue +147 -0
- package/app/components/pipeline/PipelineBuilder.vue +54 -0
- package/app/components/settings/OpenRouterCatalogPanel.vue +6 -3
- package/app/components/tutorial/TutorialCatalogue.logic.spec.ts +103 -0
- package/app/components/tutorial/TutorialCatalogue.logic.ts +102 -0
- package/app/components/tutorial/TutorialCatalogue.vue +150 -0
- package/app/components/tutorial/TutorialOverlay.logic.spec.ts +46 -0
- package/app/components/tutorial/TutorialOverlay.logic.ts +53 -0
- package/app/components/tutorial/TutorialOverlay.vue +296 -40
- package/app/components/tutorial/TutorialPrompt.vue +41 -22
- package/app/composables/useNavContributions.ts +4 -1
- package/app/composables/useTutorialLaunch.ts +50 -0
- package/app/composables/useTutorialTours.ts +37 -9
- package/app/docs/consumer-extensions.md +24 -11
- package/app/modular/agent-kinds.ts +6 -0
- package/app/modular/nav-contributions.spec.ts +7 -0
- package/app/modular/nav-contributions.ts +25 -13
- package/app/modular/slots.ts +5 -2
- package/app/modular/tutorial-tours.spec.ts +189 -53
- package/app/modular/tutorial-tours.ts +57 -8
- package/app/pages/index.vue +7 -2
- package/app/stores/pipelines/draftBinaryOutput.spec.ts +70 -0
- package/app/stores/pipelines/draftStepConfig.ts +38 -2
- package/app/stores/tutorial.spec.ts +167 -0
- package/app/stores/tutorial.ts +140 -4
- package/app/types/domain.ts +9 -0
- package/app/types/execution.ts +5 -0
- package/app/utils/binaryOutput.spec.ts +307 -0
- package/app/utils/binaryOutput.ts +343 -0
- package/app/utils/tutorial.spec.ts +179 -9
- package/app/utils/tutorial.ts +233 -22
- package/i18n/locales/de.json +89 -7
- package/i18n/locales/en.json +101 -7
- package/i18n/locales/es.json +89 -7
- package/i18n/locales/fr.json +89 -7
- package/i18n/locales/he.json +89 -7
- package/i18n/locales/it.json +89 -7
- package/i18n/locales/ja.json +89 -7
- package/i18n/locales/pl.json +89 -7
- package/i18n/locales/tr.json +89 -7
- package/i18n/locales/uk.json +89 -7
- 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
|
|
3
|
-
import
|
|
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
|
-
|
|
37
|
-
|
|
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
|
|
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
|
+
})
|