@cat-factory/app 0.256.3 → 0.258.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/app/components/binaryCandidates/BinaryCandidatesWindow.vue +284 -0
- package/app/components/binaryOutput/BinaryOutputReport.vue +75 -0
- package/app/components/board/AddTaskModal.vue +38 -12
- package/app/components/board/RecurringPipelineModal.vue +24 -12
- package/app/components/board/nodes/TaskCard.vue +34 -7
- package/app/components/forkDecision/ForkDecisionWindow.vue +3 -1
- package/app/components/panels/AgentStepDetail.vue +42 -20
- package/app/components/panels/InspectorPanel.vue +39 -0
- package/app/components/panels/ResultWindowShell.logic.spec.ts +4 -0
- package/app/components/panels/inspector/TaskExecution.vue +34 -34
- package/app/components/pipeline/BinaryOutputStepPicker.logic.spec.ts +119 -1
- package/app/components/pipeline/BinaryOutputStepPicker.logic.ts +115 -1
- package/app/components/pipeline/BinaryOutputStepPicker.vue +463 -1
- package/app/components/pipeline/PipelineBuilder.vue +44 -0
- package/app/components/pipeline/PipelinePreview.vue +20 -1
- package/app/components/pipeline/PipelineProgress.vue +50 -0
- package/app/composables/api/binaryCandidates.ts +36 -0
- package/app/composables/api/execution.ts +19 -0
- package/app/composables/useApi.ts +2 -0
- package/app/composables/usePipelineHealth.spec.ts +42 -5
- package/app/composables/usePipelineHealth.ts +109 -48
- package/app/modular/agent-kinds.ts +5 -0
- package/app/modular/result-views.ts +4 -0
- package/app/stores/binaryCandidates.ts +89 -0
- package/app/stores/environmentWizard/context.ts +0 -2
- package/app/stores/environmentWizard/flow.ts +11 -6
- package/app/stores/environmentWizard.ts +12 -11
- package/app/stores/execution/commands.ts +26 -1
- package/app/stores/pipelines/draftActions.ts +2 -0
- package/app/stores/pipelines/draftStepConfig.ts +4 -161
- package/app/stores/pipelines/draftStepOptions.ts +204 -0
- package/app/stores/ui/resultViews.ts +8 -6
- package/app/stores/ui/runStepOpeners.ts +23 -1
- package/app/types/domain.ts +6 -0
- package/app/types/execution.ts +5 -0
- package/app/utils/agentPalette.spec.ts +26 -0
- package/app/utils/agentPalette.ts +9 -4
- package/app/utils/binaryCandidates.spec.ts +110 -0
- package/app/utils/binaryCandidates.ts +126 -0
- package/app/utils/binaryOutput.spec.ts +122 -1
- package/app/utils/binaryOutput.ts +149 -2
- package/app/utils/catalog.spec.ts +24 -0
- package/app/utils/catalog.ts +21 -4
- package/app/utils/pipeline.spec.ts +35 -3
- package/app/utils/pipeline.ts +54 -3
- package/app/utils/pipelineRender.spec.ts +122 -1
- package/app/utils/pipelineRender.ts +113 -2
- package/i18n/locales/de.json +107 -3
- package/i18n/locales/en.json +107 -3
- package/i18n/locales/es.json +107 -3
- package/i18n/locales/fr.json +107 -3
- package/i18n/locales/he.json +107 -3
- package/i18n/locales/it.json +107 -3
- package/i18n/locales/ja.json +107 -3
- package/i18n/locales/pl.json +107 -3
- package/i18n/locales/tr.json +107 -3
- package/i18n/locales/uk.json +107 -3
- package/i18n/plural-forms.spec.ts +15 -0
- package/package.json +2 -2
|
@@ -44,14 +44,19 @@ export interface NarrowedAgentPalette<T> {
|
|
|
44
44
|
* nothing behaves, and none of them is a reason to drop it from the catalog.
|
|
45
45
|
*/
|
|
46
46
|
export function narrowAgentPalette<
|
|
47
|
-
T extends Pick<AgentArchetype, 'tier' | 'category' | 'purposes'>,
|
|
47
|
+
T extends Pick<AgentArchetype, 'tier' | 'category' | 'purposes' | 'internal'>,
|
|
48
48
|
>(archetypes: readonly T[], purpose: PipelinePurpose, tier: AgentTier): NarrowedAgentPalette<T> {
|
|
49
|
+
// INTERNAL kinds are removed BEFORE either dial, and are counted by neither. They are not
|
|
50
|
+
// hidden, they are not placeable at all: the platform dispatches them for a flow of its own
|
|
51
|
+
// (the environment analyst hands its draft to the setup wizard), so counting one as
|
|
52
|
+
// "3 more at the widest tier" would send a reader to a dial that will never reveal it.
|
|
53
|
+
const placeable = archetypes.filter((a) => !a.internal)
|
|
49
54
|
const relevant = (a: T) => purposeSuggestsAgentKind(purpose, a)
|
|
50
55
|
const inTier = (a: T) => agentTierVisibleAt(a.tier, tier)
|
|
51
56
|
return {
|
|
52
|
-
offered:
|
|
53
|
-
hiddenByPurpose:
|
|
54
|
-
hiddenByTier:
|
|
57
|
+
offered: placeable.filter((a) => relevant(a) && inTier(a)),
|
|
58
|
+
hiddenByPurpose: placeable.filter((a) => inTier(a) && !relevant(a)).length,
|
|
59
|
+
hiddenByTier: placeable.filter((a) => relevant(a) && !inTier(a)).length,
|
|
55
60
|
}
|
|
56
61
|
}
|
|
57
62
|
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import type { PipelineStep } from '~/types/execution'
|
|
3
|
+
import { binaryCandidateHasWarnings, binaryCandidateView } from './binaryCandidates'
|
|
4
|
+
|
|
5
|
+
function step(overrides: Record<string, unknown> = {}): PipelineStep {
|
|
6
|
+
// The candidate override MERGES into the base state rather than replacing it, so a case that
|
|
7
|
+
// only cares about one counter does not have to restate the whole candidate list.
|
|
8
|
+
const { binaryCandidates, ...rest } = overrides
|
|
9
|
+
return {
|
|
10
|
+
agentKind: 'imager',
|
|
11
|
+
state: 'waiting_decision',
|
|
12
|
+
binaryCandidates: {
|
|
13
|
+
status: 'awaiting_choice',
|
|
14
|
+
multiSelect: false,
|
|
15
|
+
invalidEntries: 0,
|
|
16
|
+
omitted: 0,
|
|
17
|
+
unusablePreviews: 0,
|
|
18
|
+
candidates: [
|
|
19
|
+
{ id: 'c1', service: 's', location: 'a.png', subject: 'anvil', generator: 'flux' },
|
|
20
|
+
{ id: 'c2', service: 's', location: 'b.png', subject: 'anvil', generator: 'retro' },
|
|
21
|
+
{ id: 'c3', service: 's', location: 'c.png', subject: 'hammer' },
|
|
22
|
+
],
|
|
23
|
+
...(binaryCandidates as Record<string, unknown> | undefined),
|
|
24
|
+
},
|
|
25
|
+
...rest,
|
|
26
|
+
} as unknown as PipelineStep
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('binaryCandidateView', () => {
|
|
30
|
+
// A step that never compared renders nothing at all, exactly as the binary-output section does
|
|
31
|
+
// for a step that never generated: a row saying "no comparison here" would ride every step.
|
|
32
|
+
it('is absent for a step with no comparison story', () => {
|
|
33
|
+
expect(binaryCandidateView({ agentKind: 'coder' } as PipelineStep)).toBeNull()
|
|
34
|
+
expect(binaryCandidateView(null)).toBeNull()
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
// A person compares one subject at a time; forty subjects is forty comparisons, not one wall of
|
|
38
|
+
// eighty pictures.
|
|
39
|
+
it('groups candidates by subject in first-appearance order', () => {
|
|
40
|
+
const view = binaryCandidateView(step())!
|
|
41
|
+
expect(view.groups.map((g) => g.subject)).toEqual(['anvil', 'hammer'])
|
|
42
|
+
expect(view.groups[0]?.rows.map((r) => r.id)).toEqual(['c1', 'c2'])
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
// An unlabelled candidate is not "the same thing" as any labelled one, and filing it under the
|
|
46
|
+
// first subject would put a picture of something else into a comparison.
|
|
47
|
+
it('keeps unlabelled candidates in their own group rather than merging them', () => {
|
|
48
|
+
const view = binaryCandidateView(
|
|
49
|
+
step({
|
|
50
|
+
binaryCandidates: {
|
|
51
|
+
candidates: [
|
|
52
|
+
{ id: 'c1', service: 's', location: 'a.png', subject: 'anvil' },
|
|
53
|
+
{ id: 'c2', service: 's', location: 'b.png' },
|
|
54
|
+
],
|
|
55
|
+
},
|
|
56
|
+
}),
|
|
57
|
+
)!
|
|
58
|
+
expect(view.groups.map((g) => g.subject)).toEqual(['anvil', null])
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('marks what was kept and the id it was kept under', () => {
|
|
62
|
+
const view = binaryCandidateView(
|
|
63
|
+
step({
|
|
64
|
+
binaryCandidates: {
|
|
65
|
+
status: 'chosen',
|
|
66
|
+
multiSelect: true,
|
|
67
|
+
choice: {
|
|
68
|
+
kept: [{ candidateId: 'c2', storeAs: 'anvil-pixel' }],
|
|
69
|
+
discarded: ['c1', 'c3'],
|
|
70
|
+
at: 1,
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
}),
|
|
74
|
+
)!
|
|
75
|
+
const rows = view.groups.flatMap((g) => g.rows)
|
|
76
|
+
expect(rows.find((r) => r.id === 'c2')).toMatchObject({ kept: true, storeAs: 'anvil-pixel' })
|
|
77
|
+
expect(rows.find((r) => r.id === 'c1')?.kept).toBe(false)
|
|
78
|
+
expect(view.awaiting).toBe(false)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
// An automatic keep is NOT a review. A surface that renders it as a choice tells a reader a
|
|
82
|
+
// person looked at this and approved it, which is the claim the whole feature exists to make
|
|
83
|
+
// true.
|
|
84
|
+
it('reports an automatic keep as its own fact', () => {
|
|
85
|
+
const view = binaryCandidateView(
|
|
86
|
+
step({
|
|
87
|
+
binaryCandidates: {
|
|
88
|
+
status: 'chosen',
|
|
89
|
+
choice: { kept: [{ candidateId: 'c1' }], discarded: [], automatic: true, at: 1 },
|
|
90
|
+
},
|
|
91
|
+
}),
|
|
92
|
+
)!
|
|
93
|
+
expect(view.automatic).toBe(true)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('counts candidates with no renderable preview', () => {
|
|
97
|
+
expect(binaryCandidateView(step())!.withoutPreview).toBe(3)
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
describe('binaryCandidateHasWarnings', () => {
|
|
102
|
+
// A comparison made over three of five candidates must not read as one made over all five.
|
|
103
|
+
it('is raised by any counted loss and by nothing else', () => {
|
|
104
|
+
expect(binaryCandidateHasWarnings(binaryCandidateView(step())!)).toBe(false)
|
|
105
|
+
for (const field of ['invalidEntries', 'omitted', 'unusablePreviews'] as const) {
|
|
106
|
+
const view = binaryCandidateView(step({ binaryCandidates: { [field]: 1 } }))!
|
|
107
|
+
expect(binaryCandidateHasWarnings(view)).toBe(true)
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
})
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import type { BinaryCandidate, BinaryCandidateStepState, PipelineStep } from '~/types/execution'
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// The read model behind the candidate-comparison surface
|
|
5
|
+
// (docs/initiatives/binary-output-foundational-storage.md).
|
|
6
|
+
//
|
|
7
|
+
// A step whose selection declares a `comparison` generates a candidate from each of its selected
|
|
8
|
+
// integrations, stages them, and parks. This module turns that record into what the window
|
|
9
|
+
// renders: the candidates GROUPED BY SUBJECT (which is what a person actually compares), the
|
|
10
|
+
// preview each one does or does not have, and every loss the parse counted.
|
|
11
|
+
//
|
|
12
|
+
// Pure, and reads only the step's own record, which is the rule the sibling binary-output read
|
|
13
|
+
// model follows and for the same reason: the join a human wants is answerable from the step alone, so
|
|
14
|
+
// the surface needs no fetch and reads identically for a finished run.
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
/** One candidate as the window renders it, with the two facts the raw record does not carry. */
|
|
18
|
+
export interface BinaryCandidateRow extends BinaryCandidate {
|
|
19
|
+
/**
|
|
20
|
+
* The human kept this one. Meaningful only once a choice exists, which is what lets the window
|
|
21
|
+
* double as the RECORD of a settled comparison rather than only its control surface.
|
|
22
|
+
*/
|
|
23
|
+
kept: boolean
|
|
24
|
+
/** The id it is to be stored under, when the person who kept it assigned one. */
|
|
25
|
+
storeAs?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The candidates for one subject, which is the unit a person compares. */
|
|
29
|
+
export interface BinaryCandidateGroup {
|
|
30
|
+
/**
|
|
31
|
+
* What these candidates depict, or null when the agent declared no subject. Null is its own
|
|
32
|
+
* group rather than being merged into another: an unlabelled candidate is not "the same thing"
|
|
33
|
+
* as any labelled one, and quietly filing it under the first subject would put a picture of
|
|
34
|
+
* something else into a comparison.
|
|
35
|
+
*/
|
|
36
|
+
subject: string | null
|
|
37
|
+
rows: BinaryCandidateRow[]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The whole surface's read model. */
|
|
41
|
+
export interface BinaryCandidateView {
|
|
42
|
+
state: BinaryCandidateStepState
|
|
43
|
+
/** Candidates grouped by subject, in first-appearance order. */
|
|
44
|
+
groups: BinaryCandidateGroup[]
|
|
45
|
+
/** Whether the run is parked on this decision right now (as opposed to showing the record). */
|
|
46
|
+
awaiting: boolean
|
|
47
|
+
/** Whether more than one candidate may be kept. */
|
|
48
|
+
multiSelect: boolean
|
|
49
|
+
/**
|
|
50
|
+
* True when the engine kept the only candidate without asking. Its own flag rather than an
|
|
51
|
+
* absent decider, because a surface that renders it as a choice claims a person looked at this.
|
|
52
|
+
*/
|
|
53
|
+
automatic: boolean
|
|
54
|
+
/** How many candidates carry no renderable preview, so the window can say so once. */
|
|
55
|
+
withoutPreview: number
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The step's candidate read model, or null when the step has no comparison story at all.
|
|
60
|
+
*
|
|
61
|
+
* A step that never compared renders nothing, exactly as the binary-output section does for a step
|
|
62
|
+
* that never generated: a row saying "no comparison was configured here" would ride every step of
|
|
63
|
+
* every run.
|
|
64
|
+
*/
|
|
65
|
+
export function binaryCandidateView(
|
|
66
|
+
step: PipelineStep | null | undefined,
|
|
67
|
+
): BinaryCandidateView | null {
|
|
68
|
+
const state = step?.binaryCandidates
|
|
69
|
+
if (!state) return null
|
|
70
|
+
const kept = new Map((state.choice?.kept ?? []).map((entry) => [entry.candidateId, entry]))
|
|
71
|
+
const groups: BinaryCandidateGroup[] = []
|
|
72
|
+
const bySubject = new Map<string | null, BinaryCandidateGroup>()
|
|
73
|
+
for (const candidate of state.candidates) {
|
|
74
|
+
const subject = candidate.subject ?? null
|
|
75
|
+
let group = bySubject.get(subject)
|
|
76
|
+
if (!group) {
|
|
77
|
+
group = { subject, rows: [] }
|
|
78
|
+
bySubject.set(subject, group)
|
|
79
|
+
groups.push(group)
|
|
80
|
+
}
|
|
81
|
+
const choice = kept.get(candidate.id)
|
|
82
|
+
group.rows.push({
|
|
83
|
+
...candidate,
|
|
84
|
+
kept: choice !== undefined,
|
|
85
|
+
...(choice?.storeAs ? { storeAs: choice.storeAs } : {}),
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
state,
|
|
90
|
+
groups,
|
|
91
|
+
awaiting: state.status === 'awaiting_choice',
|
|
92
|
+
multiSelect: state.multiSelect === true,
|
|
93
|
+
automatic: state.choice?.automatic === true,
|
|
94
|
+
withoutPreview: state.candidates.filter((candidate) => !candidate.previewUrl).length,
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Whether anything about this comparison needs saying beside the candidates: entries the parse
|
|
100
|
+
* dropped, a truncated list, or preview links it refused.
|
|
101
|
+
*
|
|
102
|
+
* Drives the window's warning strip, so a comparison made over three of five candidates cannot
|
|
103
|
+
* read as one made over all of them. A missing PREVIEW is deliberately not one of these: it is
|
|
104
|
+
* ordinary (a private asset store issues no public link) and it is stated per candidate, where
|
|
105
|
+
* the reader can see exactly which one they are judging blind.
|
|
106
|
+
*/
|
|
107
|
+
export function binaryCandidateHasWarnings(view: BinaryCandidateView): boolean {
|
|
108
|
+
const { invalidEntries = 0, omitted = 0, unusablePreviews = 0 } = view.state
|
|
109
|
+
return invalidEntries > 0 || omitted > 0 || unusablePreviews > 0
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* State → the i18n key for the line explaining why no choice was offered.
|
|
114
|
+
*
|
|
115
|
+
* An exhaustive `Record` over the reason vocabulary, so a fourth reason fails the typecheck here
|
|
116
|
+
* rather than rendering a missing key. Every member is a different fault with a different fix,
|
|
117
|
+
* which is exactly why the engine records the reason instead of leaving the step blank.
|
|
118
|
+
*/
|
|
119
|
+
export const BINARY_CANDIDATE_NO_CHOICE_KEYS: Record<
|
|
120
|
+
NonNullable<BinaryCandidateStepState['noChoiceReason']>,
|
|
121
|
+
string
|
|
122
|
+
> = {
|
|
123
|
+
undeclared: 'binaryCandidates.noChoice.undeclared',
|
|
124
|
+
parse_failed: 'binaryCandidates.noChoice.parseFailed',
|
|
125
|
+
no_candidates: 'binaryCandidates.noChoice.noCandidates',
|
|
126
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
|
-
import type { BinaryOutputReport, PipelineStep } from '~/types/execution'
|
|
2
|
+
import type { BinaryOutputArtifact, BinaryOutputReport, PipelineStep } from '~/types/execution'
|
|
3
3
|
import {
|
|
4
4
|
BINARY_OUTPUT_STATE_KEYS,
|
|
5
5
|
binaryOutputHasWarnings,
|
|
@@ -371,6 +371,95 @@ describe('the delivered-format check', () => {
|
|
|
371
371
|
})
|
|
372
372
|
})
|
|
373
373
|
|
|
374
|
+
// The size axis's delivery-side half. Admission checked what the selected integrations can be
|
|
375
|
+
// ASKED for; only this checks what came back, which is the failure the requirement exists for:
|
|
376
|
+
// an asset stored at the wrong size passes every other check on this panel.
|
|
377
|
+
describe('binaryOutputView, the delivered size', () => {
|
|
378
|
+
const sized = (
|
|
379
|
+
outputSize: { width: number; height: number } | undefined,
|
|
380
|
+
stored: {
|
|
381
|
+
location: string
|
|
382
|
+
dimensions?: { width: number; height: number }
|
|
383
|
+
modality?: BinaryOutputArtifact['modality']
|
|
384
|
+
}[],
|
|
385
|
+
) =>
|
|
386
|
+
binaryOutputView(
|
|
387
|
+
step({
|
|
388
|
+
stepOptions: {
|
|
389
|
+
binaryOutput: {
|
|
390
|
+
storageServiceId: 'files',
|
|
391
|
+
...(outputSize ? { generation: { outputSize } } : {}),
|
|
392
|
+
},
|
|
393
|
+
},
|
|
394
|
+
binaryOutputs: report({
|
|
395
|
+
stored: stored.map((entry) => ({ service: 'files', ...entry })),
|
|
396
|
+
}),
|
|
397
|
+
}),
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
it('counts an artifact delivered at another size, and marks its row', () => {
|
|
401
|
+
const view = sized({ width: 96, height: 96 }, [
|
|
402
|
+
{ location: 'a.png', dimensions: { width: 96, height: 96 } },
|
|
403
|
+
{ location: 'b.png', dimensions: { width: 1024, height: 1024 } },
|
|
404
|
+
])
|
|
405
|
+
expect(view?.requiredSize).toEqual({ width: 96, height: 96 })
|
|
406
|
+
expect(view?.missized).toBe(1)
|
|
407
|
+
expect(view?.rows.map((row) => row.missized)).toEqual([false, true])
|
|
408
|
+
expect(binaryOutputHasWarnings(view!)).toBe(true)
|
|
409
|
+
})
|
|
410
|
+
|
|
411
|
+
it('counts an UNMEASURED artifact apart, never as a delivered one', () => {
|
|
412
|
+
// The rule this whole feature runs on: "we were not told" and "it came back wrong" are the
|
|
413
|
+
// same value and opposite facts. Folded together, a run that reported nothing would read as
|
|
414
|
+
// one that delivered everything wrong; dropped, it would read as a clean one.
|
|
415
|
+
const view = sized({ width: 96, height: 96 }, [{ location: 'a.png' }])
|
|
416
|
+
expect(view?.missized).toBe(0)
|
|
417
|
+
expect(view?.sizeUnreported).toBe(1)
|
|
418
|
+
expect(binaryOutputHasWarnings(view!)).toBe(true)
|
|
419
|
+
})
|
|
420
|
+
|
|
421
|
+
it('judges nothing when the step asked for no size', () => {
|
|
422
|
+
const view = sized(undefined, [
|
|
423
|
+
{ location: 'a.png', dimensions: { width: 1024, height: 1024 } },
|
|
424
|
+
{ location: 'b.png' },
|
|
425
|
+
])
|
|
426
|
+
expect(view?.requiredSize).toBeNull()
|
|
427
|
+
expect(view?.missized).toBe(0)
|
|
428
|
+
expect(view?.sizeUnreported).toBe(0)
|
|
429
|
+
expect(binaryOutputHasWarnings(view!)).toBe(false)
|
|
430
|
+
})
|
|
431
|
+
|
|
432
|
+
// A size is a statement about what is MEASURED in pixels. A step that generates the icon and
|
|
433
|
+
// its pickup sound delivered exactly what was asked, and counting the audio as unmeasured
|
|
434
|
+
// would warn about it forever, on a panel whose whole discipline is that a warning means
|
|
435
|
+
// something went wrong.
|
|
436
|
+
it('says nothing about an artifact a size cannot describe', () => {
|
|
437
|
+
const view = sized({ width: 96, height: 96 }, [
|
|
438
|
+
{ location: 'a.png', modality: 'image', dimensions: { width: 96, height: 96 } },
|
|
439
|
+
{ location: 'a.mp3', modality: 'audio' },
|
|
440
|
+
{ location: 'a.glb', modality: '3d-model' },
|
|
441
|
+
])
|
|
442
|
+
expect(view?.missized).toBe(0)
|
|
443
|
+
expect(view?.sizeUnreported).toBe(0)
|
|
444
|
+
expect(binaryOutputHasWarnings(view!)).toBe(false)
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
// The other direction, and the one that must not go quiet: an artifact the platform could not
|
|
448
|
+
// CLASSIFY may well be an image, so it stays covered. Excluding it would turn an unreadable
|
|
449
|
+
// content type into a silent pass on the one axis the requirement exists to check, and a
|
|
450
|
+
// RETIRED modality a stale run carries reads the same way.
|
|
451
|
+
it('keeps an unclassified artifact under the requirement', () => {
|
|
452
|
+
const view = sized({ width: 96, height: 96 }, [
|
|
453
|
+
{ location: 'a.bin' },
|
|
454
|
+
// A modality the union no longer has, which a run saved before the 3D split goes on
|
|
455
|
+
// carrying. Cast because the TYPE cannot express it and the DATA can, which is the whole
|
|
456
|
+
// reason the read narrows through `isBinaryModality` before it looks anything up.
|
|
457
|
+
{ location: 'b.bin', modality: '3d' as BinaryOutputArtifact['modality'] },
|
|
458
|
+
])
|
|
459
|
+
expect(view?.sizeUnreported).toBe(2)
|
|
460
|
+
})
|
|
461
|
+
})
|
|
462
|
+
|
|
374
463
|
describe('binaryOutputPickIssues, generative half', () => {
|
|
375
464
|
const catalog = [{ id: 'files', capabilities: ['asset-storage'] }]
|
|
376
465
|
const generators = [
|
|
@@ -400,6 +489,38 @@ describe('binaryOutputPickIssues, generative half', () => {
|
|
|
400
489
|
expect(pick.uncoveredModalities).toEqual(['audio'])
|
|
401
490
|
})
|
|
402
491
|
|
|
492
|
+
// The builder offers a ratio, an exact size and an upscale together, so the conflict it can
|
|
493
|
+
// save is one it must state: reported only by the failed round trip, the fix (delete one of two
|
|
494
|
+
// fields on this form) arrives as backend prose about a step the reader has already left.
|
|
495
|
+
it('states the save refusal for two statements of one delivered size', () => {
|
|
496
|
+
const pick = binaryOutputPickIssues(
|
|
497
|
+
{
|
|
498
|
+
storageServiceId: 'files',
|
|
499
|
+
generatorIds: ['retro'],
|
|
500
|
+
generation: { outputSize: { width: 96, height: 96 }, aspectRatio: '16:9' },
|
|
501
|
+
},
|
|
502
|
+
catalog,
|
|
503
|
+
true,
|
|
504
|
+
generators,
|
|
505
|
+
)
|
|
506
|
+
expect(pick.issues).toContain('output_size_ambiguous')
|
|
507
|
+
expect(pick.conflictingSizeOptions).toEqual(['aspectRatio'])
|
|
508
|
+
})
|
|
509
|
+
|
|
510
|
+
// A step with no storage pick still gets the whole list: the two halves resolve against
|
|
511
|
+
// different registries, and reporting them one round at a time is the retry cycle this
|
|
512
|
+
// function returns every issue together to avoid.
|
|
513
|
+
it('states it on a step whose storage is not picked either', () => {
|
|
514
|
+
const pick = binaryOutputPickIssues(
|
|
515
|
+
{ storageServiceId: '', generation: { outputSize: { width: 96, height: 96 }, upscale: 2 } },
|
|
516
|
+
catalog,
|
|
517
|
+
true,
|
|
518
|
+
generators,
|
|
519
|
+
)
|
|
520
|
+
expect(pick.issues).toEqual(expect.arrayContaining(['not_selected', 'output_size_ambiguous']))
|
|
521
|
+
expect(pick.conflictingSizeOptions).toEqual(['upscale'])
|
|
522
|
+
})
|
|
523
|
+
|
|
403
524
|
it('reports BOTH faults when an unknown id was the one covering a requirement', () => {
|
|
404
525
|
// One edit should clear the step. Naming only the missing id would leave the user to
|
|
405
526
|
// discover the uncovered requirement on the next round trip.
|