@cat-factory/app 0.258.2 → 0.259.3

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.
@@ -0,0 +1,74 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { readEnvironmentAgainstClock } from './OutcomeSummaryWindow.logic'
3
+ import type { OutcomeEnvironment } from '~/utils/runOutcome'
4
+
5
+ /**
6
+ * The one thing the outcome card decides for itself: whether the TTL the payload carries has
7
+ * lapsed, and what that changes. The reduction behind the card is clock-free on purpose, so this
8
+ * is the only place a reader is told an environment is past its expiry, and the failure it
9
+ * guards is the section's worst one: a green "Live" badge and a working-looking button on a row
10
+ * whose own expiry date is in the past.
11
+ */
12
+ const NOW = 1_700_000_000_000
13
+
14
+ const env = (overrides: Partial<OutcomeEnvironment> = {}): OutcomeEnvironment => ({
15
+ url: 'https://preview.test',
16
+ state: 'live',
17
+ origin: 'deployer',
18
+ expiresAt: null,
19
+ retained: false,
20
+ frameId: 'frm_own',
21
+ environmentId: 'env_1',
22
+ detail: null,
23
+ ...overrides,
24
+ })
25
+
26
+ describe('readEnvironmentAgainstClock', () => {
27
+ it('offers a live environment whose TTL has not lapsed', () => {
28
+ const row = readEnvironmentAgainstClock(env({ expiresAt: NOW + 60_000 }), NOW)
29
+ expect(row).toMatchObject({ state: 'live', lapsed: false, openable: true })
30
+ })
31
+
32
+ it('withholds the link once the TTL has lapsed, and says the environment expired', () => {
33
+ const row = readEnvironmentAgainstClock(env({ expiresAt: NOW - 1 }), NOW)
34
+ expect(row).toMatchObject({ state: 'expired', lapsed: true, openable: false })
35
+ // The URL survives the lapse: it is what names the environment and what an operator greps.
36
+ expect(row.url).toBe('https://preview.test')
37
+ })
38
+
39
+ // A run with no disposer and no further polls keeps a `provisioning` row forever, and one
40
+ // whose TTL then lapsed never came up and never will.
41
+ it('applies the lapse to an environment still coming up', () => {
42
+ const row = readEnvironmentAgainstClock(env({ state: 'provisioning', expiresAt: NOW - 1 }), NOW)
43
+ expect(row).toMatchObject({ state: 'expired', lapsed: true, openable: false })
44
+ })
45
+
46
+ // The clock may only answer the question the payload left open. Where a producer already said
47
+ // WHERE the environment went, that word is the more specific one and it stands.
48
+ it('never overwrites a state that already names where the environment went', () => {
49
+ for (const state of ['failed', 'reclaimed', 'reclaiming'] as const) {
50
+ const row = readEnvironmentAgainstClock(env({ state, expiresAt: NOW - 1 }), NOW)
51
+ expect(row).toMatchObject({ state, lapsed: false, openable: false })
52
+ }
53
+ })
54
+
55
+ // `useNowTick` reads 0 until the card mounts, and every instant in history is "past" the epoch.
56
+ it('makes no clock-derived claim before the card has a clock', () => {
57
+ const row = readEnvironmentAgainstClock(env({ expiresAt: NOW - 1 }), 0)
58
+ expect(row).toMatchObject({ state: 'live', lapsed: false, openable: true })
59
+ })
60
+
61
+ it('offers nothing to click for a live environment that has no URL yet', () => {
62
+ const row = readEnvironmentAgainstClock(env({ url: null }), NOW)
63
+ expect(row).toMatchObject({ state: 'live', openable: false })
64
+ })
65
+
66
+ // A run that recorded no TTL is not an expired one: absent and lapsed are opposite facts.
67
+ it('leaves a row carrying no TTL exactly as the payload states it', () => {
68
+ expect(readEnvironmentAgainstClock(env(), NOW)).toMatchObject({
69
+ state: 'live',
70
+ lapsed: false,
71
+ openable: true,
72
+ })
73
+ })
74
+ })
@@ -0,0 +1,63 @@
1
+ // What the outcome card's environment rows say once a CLOCK is applied to them, extracted from
2
+ // `OutcomeSummaryWindow.vue` so the rule can be asserted without mounting the card (see
3
+ // `OutcomeSummaryWindow.logic.spec.ts`).
4
+ //
5
+ // The reduction that produces those rows (`composeRunOutcome`) is deliberately clock-free: the
6
+ // SPA composes it live off its own store and `GET /api/v1/runs/:runId/outcome` composes it
7
+ // server-side, and a rule that read a clock would let the two disagree about one run for as long
8
+ // as their clocks differ. What the payload carries instead is the TTL INSTANT.
9
+ //
10
+ // Somebody still has to say what that instant means now, and it has to be the surface with the
11
+ // clock. Left unapplied, a run whose environment the TTL sweep reclaimed hours ago renders a
12
+ // green "Live" badge and an enabled Open button beside an expiry date in the past: three claims
13
+ // on one row, of which the date is the only true one.
14
+
15
+ import type { OutcomeEnvironment, OutcomeEnvironmentState } from '~/utils/runOutcome'
16
+
17
+ /**
18
+ * The states that describe an environment still STANDING, and so the only ones a lapsed TTL
19
+ * changes what they say.
20
+ *
21
+ * `failed`, `reclaimed` and `reclaiming` already name where the environment went or what is
22
+ * happening to it. A clock may not overwrite those with a less specific word: an environment
23
+ * that never came up did not then expire, and saying so would send a reader looking for a TTL
24
+ * where a provisioning failure is the thing to fix.
25
+ */
26
+ const STANDING_ENVIRONMENT_STATES = new Set<OutcomeEnvironmentState>(['live', 'provisioning'])
27
+
28
+ /** One environment row as the card renders it: the payload's own fields, read against a clock. */
29
+ export interface OutcomeEnvironmentRow extends OutcomeEnvironment {
30
+ /** True when the row's TTL has lapsed against `nowMs` while it still claimed to be standing. */
31
+ lapsed: boolean
32
+ /** Whether the card offers the row as something to click. */
33
+ openable: boolean
34
+ }
35
+
36
+ /**
37
+ * Apply the reader's clock to one environment row.
38
+ *
39
+ * `nowMs` of 0 means the card has not ticked yet (`useNowTick` reads 0 until mounted). No clock
40
+ * means no clock-derived claim: the row reads exactly as the payload states it rather than
41
+ * having every TTL lapse against the epoch.
42
+ */
43
+ export function readEnvironmentAgainstClock(
44
+ entry: OutcomeEnvironment,
45
+ nowMs: number,
46
+ ): OutcomeEnvironmentRow {
47
+ const lapsed =
48
+ nowMs > 0 &&
49
+ entry.expiresAt != null &&
50
+ entry.expiresAt <= nowMs &&
51
+ STANDING_ENVIRONMENT_STATES.has(entry.state)
52
+ const state = lapsed ? 'expired' : entry.state
53
+ return {
54
+ ...entry,
55
+ state,
56
+ lapsed,
57
+ // A link is offered ONLY for a `live` row: an environment that has been reclaimed, has
58
+ // expired or never came up still shows its URL (an operator greps for it, and it says which
59
+ // environment the row is about) and must not be something a designer clicks expecting to see
60
+ // the change.
61
+ openable: state === 'live' && Boolean(entry.url),
62
+ }
63
+ }
@@ -16,9 +16,12 @@
16
16
  // judged against, which turns the tester's requirement IDS into the TITLES a reader came for.
17
17
  import { computed, onUnmounted, ref, watch } from 'vue'
18
18
  import type {
19
+ EnvironmentsGap,
19
20
  OutcomeCheckKind,
20
21
  OutcomeCheckState,
21
22
  OutcomeDisposition,
23
+ OutcomeEnvironmentOrigin,
24
+ OutcomeEnvironmentState,
22
25
  OutcomeSource,
23
26
  OutcomeSpecJoin,
24
27
  OutcomeVisual,
@@ -35,6 +38,8 @@ import { REPRODUCTION_STATUS_KEYS } from '~/utils/reproduction'
35
38
  import type { RequirementVerdictStatus, TestConcernSeverity } from '~/types/domain'
36
39
  import type { TestEnvironment } from '@cat-factory/contracts'
37
40
  import { useArtifactBlobs } from '~/composables/useArtifactBlobs'
41
+ import { useNowTick } from '~/composables/useStepTimer'
42
+ import { readEnvironmentAgainstClock } from '~/components/outcome/OutcomeSummaryWindow.logic'
38
43
  import ArtifactLightbox from '~/components/media/ArtifactLightbox.vue'
39
44
  import ResultWindowShell from '~/components/panels/ResultWindowShell.vue'
40
45
  import MarkdownProse from '~/components/common/MarkdownProse.vue'
@@ -46,7 +51,12 @@ const documents = useDocumentsStore()
46
51
  const execution = useExecutionStore()
47
52
  const serviceSpec = useServiceSpecStore()
48
53
  const ui = useUiStore()
49
- const { t } = useI18n()
54
+ const { t, d } = useI18n()
55
+
56
+ // The wall clock this card reads a TTL against. Coarse on purpose: an environment's expiry is
57
+ // the only thing here that moves with time, and a per-second tick would re-render the whole card
58
+ // for a boundary that matters at minute granularity.
59
+ const nowTick = useNowTick(30_000)
50
60
 
51
61
  // Per-window blob cache for the captured views; revoked on unmount so the (large) image bytes
52
62
  // don't outlive the card.
@@ -136,6 +146,38 @@ const SOURCES_GAP_KEYS: Record<SourcesGap, string> = {
136
146
  run_unavailable: RUN_UNAVAILABLE_KEY,
137
147
  none_linked: 'outcome.sources.gap.none_linked',
138
148
  }
149
+ const ENVIRONMENTS_GAP_KEYS: Record<EnvironmentsGap, string> = {
150
+ run_unavailable: RUN_UNAVAILABLE_KEY,
151
+ no_environment_step: 'outcome.environments.gap.no_environment_step',
152
+ not_provisioned: 'outcome.environments.gap.not_provisioned',
153
+ infraless: 'outcome.environments.gap.infraless',
154
+ }
155
+ const ENVIRONMENT_STATE_KEYS: Record<OutcomeEnvironmentState, string> = {
156
+ live: 'outcome.environments.state.live',
157
+ provisioning: 'outcome.environments.state.provisioning',
158
+ failed: 'outcome.environments.state.failed',
159
+ reclaiming: 'outcome.environments.state.reclaiming',
160
+ reclaimed: 'outcome.environments.state.reclaimed',
161
+ expired: 'outcome.environments.state.expired',
162
+ }
163
+ const ENVIRONMENT_STATE_COLOR: Record<OutcomeEnvironmentState, BadgeColor> = {
164
+ live: 'success',
165
+ provisioning: 'info',
166
+ failed: 'error',
167
+ reclaiming: 'neutral',
168
+ reclaimed: 'neutral',
169
+ expired: 'neutral',
170
+ }
171
+ /**
172
+ * Where the row came from, said out loud. `projected` is the one that changes what a reader
173
+ * should conclude (nothing has settled yet, so this row can still move), and the three are
174
+ * mapped exhaustively so a new producer cannot ship as a blank line.
175
+ */
176
+ const ENVIRONMENT_ORIGIN_KEYS: Record<OutcomeEnvironmentOrigin, string> = {
177
+ deployer: 'outcome.environments.origin.deployer',
178
+ human_test: 'outcome.environments.origin.human_test',
179
+ projected: 'outcome.environments.origin.projected',
180
+ }
139
181
  const VISUALS_GAP_KEYS: Record<VisualsGap, string> = {
140
182
  run_unavailable: RUN_UNAVAILABLE_KEY,
141
183
  no_visual_step: 'outcome.visuals.gap.no_visual_step',
@@ -337,6 +379,28 @@ const sourceRows = computed(() => {
337
379
  }))
338
380
  })
339
381
 
382
+ /**
383
+ * The environments the run stood up, with everything the row needs resolved once.
384
+ *
385
+ * The TTL is applied HERE rather than in the reduction, and that division is deliberate: the
386
+ * payload is clock-free so the endpoint's answer and this card's live composition cannot
387
+ * disagree about one run, and this surface is the one with a clock to say what the instant it
388
+ * carries means now. The rule itself lives in `OutcomeSummaryWindow.logic.ts`, where it is
389
+ * asserted without mounting the card.
390
+ *
391
+ * The frame is named by its BLOCK title where the board has it. A frame id says nothing to the
392
+ * person this card is for, so an unresolvable one renders as no label rather than as an id.
393
+ */
394
+ const environmentRows = computed(() => {
395
+ const environments = outcome.value?.environments
396
+ if (!environments || environments.status !== 'reported') return []
397
+ return environments.entries.map((entry, index) => ({
398
+ ...readEnvironmentAgainstClock(entry, nowTick.value),
399
+ key: `${index}:${entry.environmentId ?? entry.url ?? entry.frameId ?? 'env'}`,
400
+ service: entry.frameId ? (board.getBlock(entry.frameId)?.title ?? null) : null,
401
+ }))
402
+ })
403
+
340
404
  /** Drill into the full test report (this card is the summary, never a replacement for it). */
341
405
  function openTestReport() {
342
406
  if (instance.value) ui.openTestEvidence(instance.value.id)
@@ -681,6 +745,78 @@ function openTestReport() {
681
745
  </template>
682
746
  </section>
683
747
 
748
+ <!-- Where to go and look: the running preview, which is the verification a person who does
749
+ not read diffs starts from. Beside the captured views on purpose: the shots are what
750
+ this run saw, this is the thing itself. -->
751
+ <section class="mb-5" data-testid="outcome-environments">
752
+ <h3 class="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
753
+ {{ t('outcome.environments.title') }}
754
+ </h3>
755
+ <template v-if="outcome.environments.status === 'reported'">
756
+ <div
757
+ v-for="row in environmentRows"
758
+ :key="row.key"
759
+ class="mb-2 rounded-md border border-slate-800 bg-slate-950/40 px-2.5 py-2 last:mb-0"
760
+ data-testid="outcome-environment"
761
+ :data-state="row.state"
762
+ >
763
+ <div class="flex flex-wrap items-center gap-2">
764
+ <UBadge :color="ENVIRONMENT_STATE_COLOR[row.state]" variant="subtle" size="sm">
765
+ {{ t(ENVIRONMENT_STATE_KEYS[row.state]) }}
766
+ </UBadge>
767
+ <span v-if="row.service" class="truncate text-[12px] text-slate-300">
768
+ {{ row.service }}
769
+ </span>
770
+ <span class="text-[11px] text-slate-500">
771
+ {{ t(ENVIRONMENT_ORIGIN_KEYS[row.origin]) }}
772
+ </span>
773
+ </div>
774
+ <UButton
775
+ v-if="row.openable"
776
+ :to="row.url ?? undefined"
777
+ target="_blank"
778
+ rel="noopener"
779
+ external
780
+ color="primary"
781
+ variant="soft"
782
+ size="xs"
783
+ class="mt-1.5"
784
+ icon="i-lucide-external-link"
785
+ data-testid="outcome-environment-open"
786
+ >
787
+ {{ t('outcome.environments.open') }}
788
+ </UButton>
789
+ <p
790
+ v-else-if="row.url"
791
+ class="mt-1.5 break-all text-[12px] text-slate-500"
792
+ data-testid="outcome-environment-url"
793
+ >
794
+ {{ row.url }}
795
+ </p>
796
+ <p v-if="row.retained" class="mt-1 text-[11px] text-slate-400">
797
+ {{ t('outcome.environments.retained') }}
798
+ </p>
799
+ <p v-if="row.expiresAt" class="mt-1 text-[11px] text-slate-500">
800
+ {{
801
+ row.lapsed
802
+ ? t('outcome.environments.expired', { date: d(new Date(row.expiresAt), 'long') })
803
+ : t('outcome.environments.expires', { date: d(new Date(row.expiresAt), 'long') })
804
+ }}
805
+ </p>
806
+ <p
807
+ v-if="row.detail"
808
+ class="mt-1 break-words text-[12px] leading-relaxed text-slate-500"
809
+ data-testid="outcome-environment-detail"
810
+ >
811
+ {{ row.detail }}
812
+ </p>
813
+ </div>
814
+ </template>
815
+ <p v-else class="text-[13px] italic leading-relaxed text-slate-500">
816
+ {{ t(ENVIRONMENTS_GAP_KEYS[outcome.environments.gap]) }}
817
+ </p>
818
+ </section>
819
+
684
820
  <!-- The machine checks, listed only where one actually recorded a verdict. -->
685
821
  <section v-if="checkRows.length" data-testid="outcome-checks">
686
822
  <h3 class="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
@@ -32,6 +32,7 @@ export const REASON_KEY: Record<ToolServerUnavailableReason, string> = {
32
32
  oauth_not_connected: 'panels.stepDetail.toolServers.reason.oauthNotConnected',
33
33
  oauth_token_failed: 'panels.stepDetail.toolServers.reason.oauthTokenFailed',
34
34
  over_budget: 'panels.stepDetail.toolServers.reason.overBudget',
35
+ consensus_panel: 'panels.stepDetail.toolServers.reason.consensusPanel',
35
36
  }
36
37
 
37
38
  /**
@@ -64,6 +65,7 @@ export const REMEDY_KEY: Record<ToolServerUnavailableReason, string> = {
64
65
  oauth_not_connected: 'panels.stepDetail.toolServers.remedy.oauthNotConnected',
65
66
  oauth_token_failed: 'panels.stepDetail.toolServers.remedy.oauthTokenFailed',
66
67
  over_budget: 'panels.stepDetail.toolServers.remedy.overBudget',
68
+ consensus_panel: 'panels.stepDetail.toolServers.remedy.consensusPanel',
67
69
  }
68
70
 
69
71
  /** The reason vocabulary as the SCHEMA states it: what a parity assertion grades {@link REASON_KEY} against. */
@@ -29,6 +29,7 @@ import {
29
29
  type BinaryGeneratorCapability,
30
30
  type BinaryModality,
31
31
  type BinaryOutputConfig,
32
+ type BinaryValueOption,
32
33
  type ConflictingOutputSizeOption,
33
34
  } from '@cat-factory/contracts'
34
35
  import { binaryOutputPickIssues, type BinaryOutputPickIssue } from '~/utils/binaryOutput'
@@ -283,6 +284,17 @@ const SIZE_CONFLICT_LABELS: Record<ConflictingOutputSizeOption, () => string> =
283
284
  upscale: () => t('pipeline.builder.binaryUpscale'),
284
285
  }
285
286
 
287
+ /**
288
+ * A value option named by the FIELD LABEL it carries on this form, so a refusal about a value
289
+ * points at the control holding it. Closed and compiled-against on both sides of the wire (unlike
290
+ * a capability, which a newer mothership can name), so the lookup needs no membership guard.
291
+ */
292
+ const VALUE_OPTION_LABELS: Record<BinaryValueOption, () => string> = {
293
+ aspectRatio: () => t('pipeline.builder.binaryAspectRatio'),
294
+ outputSize: () => t('pipeline.builder.binaryOutputSize'),
295
+ upscale: () => t('pipeline.builder.binaryUpscale'),
296
+ }
297
+
286
298
  /**
287
299
  * A capability in the reader's language, INCLUDING one this build does not define.
288
300
  *
@@ -881,6 +893,23 @@ const declaredFormats = computed(() => {
881
893
  })
882
894
  }}
883
895
  </p>
896
+ <!-- A refusal one notch finer than the one above it: the option is supported everywhere and
897
+ the VALUE is on nobody's list. It names what IS accepted, because a refusal that only
898
+ says no leaves the reader guessing at a set the picker is already holding. -->
899
+ <p
900
+ v-for="value in pick.unacceptedValues"
901
+ :key="value.option"
902
+ class="text-[10px] text-amber-400"
903
+ data-testid="binary-output-value-unaccepted"
904
+ >
905
+ {{
906
+ t('pipeline.builder.binaryOptionValueUnaccepted', {
907
+ option: VALUE_OPTION_LABELS[value.option](),
908
+ requested: value.requested,
909
+ accepted: value.accepted.join(', '),
910
+ })
911
+ }}
912
+ </p>
884
913
  <!-- A refusal the SAVE makes on the step's own fields, so it is stated here rather than
885
914
  waited for: all three controls are offered together, and the remedy is deleting one of
886
915
  two values on this form. -->
@@ -910,6 +939,36 @@ const declaredFormats = computed(() => {
910
939
  })
911
940
  }}
912
941
  </p>
942
+ <!-- ADVISORY, and the one of the three the reader can act on precisely: another selected
943
+ integration DOES accept the value, so the step starts, and the ones that will not take it
944
+ are named because dropping or re-routing around them is the whole fix. -->
945
+ <p
946
+ v-for="value in pick.partiallyAcceptedValues"
947
+ :key="value.option"
948
+ class="text-[10px] text-slate-500"
949
+ data-testid="binary-output-value-partial"
950
+ >
951
+ {{
952
+ t('pipeline.builder.binaryOptionValuePartial', {
953
+ option: VALUE_OPTION_LABELS[value.option](),
954
+ requested: value.requested,
955
+ generators: value.refusedBy.join(', '),
956
+ })
957
+ }}
958
+ </p>
959
+ <!-- ADVISORY, grouped with the lines above it: one selected integration refuses the value and
960
+ another has not said what it takes, so the step starts and is served by the second. -->
961
+ <p
962
+ v-if="has('option_value_unverifiable')"
963
+ class="text-[10px] text-slate-500"
964
+ data-testid="binary-output-value-unverifiable"
965
+ >
966
+ {{
967
+ t('pipeline.builder.binaryOptionValueUnverifiable', {
968
+ options: pick.unverifiableValues.map((o) => VALUE_OPTION_LABELS[o]()).join(', '),
969
+ })
970
+ }}
971
+ </p>
913
972
  <p
914
973
  v-if="unusableMediaTypes.length"
915
974
  class="text-[10px] text-amber-400"
@@ -521,6 +521,91 @@ describe('binaryOutputPickIssues, generative half', () => {
521
521
  expect(pick.conflictingSizeOptions).toEqual(['upscale'])
522
522
  })
523
523
 
524
+ // The refusal a value axis adds over the capability one: every selected endpoint takes an
525
+ // aspect ratio and none of them takes THIS ratio. Stated here because the builder is where the
526
+ // fix is (pick a listed ratio, or select an integration that renders this one), and because the
527
+ // set it names is already on the snapshot the picker is holding.
528
+ it('names a value nothing selected accepts, and what they do accept', () => {
529
+ const pick = binaryOutputPickIssues(
530
+ { storageServiceId: 'files', generatorIds: ['bucketed'], generation: { aspectRatio: '7:3' } },
531
+ catalog,
532
+ true,
533
+ [
534
+ {
535
+ id: 'bucketed',
536
+ modalities: ['image' as const],
537
+ capabilities: ['aspect-ratio' as const],
538
+ accepts: { aspectRatios: ['1:1', '16:9'] },
539
+ },
540
+ ],
541
+ )
542
+ expect(pick.issues).toContain('option_value_unaccepted')
543
+ expect(pick.unacceptedValues).toEqual([
544
+ { option: 'aspectRatio', requested: '7:3', accepted: ['1:1', '16:9'] },
545
+ ])
546
+ })
547
+
548
+ // ADVISORY, and the state that keeps the refusal above from firing on a working selection: one
549
+ // integration refuses the ratio and another has not said what it takes.
550
+ it('advises rather than refuses when a silent declarer might still serve the value', () => {
551
+ const pick = binaryOutputPickIssues(
552
+ {
553
+ storageServiceId: 'files',
554
+ generatorIds: ['bucketed', 'open'],
555
+ generation: { aspectRatio: '7:3' },
556
+ },
557
+ catalog,
558
+ true,
559
+ [
560
+ {
561
+ id: 'bucketed',
562
+ modalities: ['image' as const],
563
+ capabilities: ['aspect-ratio' as const],
564
+ accepts: { aspectRatios: ['1:1', '16:9'] },
565
+ },
566
+ { id: 'open', modalities: ['image' as const], capabilities: ['aspect-ratio' as const] },
567
+ ],
568
+ )
569
+ expect(pick.issues).toContain('option_value_unverifiable')
570
+ expect(pick.issues).not.toContain('option_value_unaccepted')
571
+ expect(pick.unverifiableValues).toEqual(['aspectRatio'])
572
+ })
573
+
574
+ // ADVISORY too, and the one the reader can act on precisely: one selected endpoint takes the
575
+ // ratio and another has written down that it does not. Naming the second is the whole remedy,
576
+ // and it is the finding a first-accepting-declarer short-circuit reported as nothing at all.
577
+ it('names the integrations that enumerated a value away when another accepts it', () => {
578
+ const pick = binaryOutputPickIssues(
579
+ {
580
+ storageServiceId: 'files',
581
+ generatorIds: ['wide', 'bucketed'],
582
+ generation: { aspectRatio: '7:3' },
583
+ },
584
+ catalog,
585
+ true,
586
+ [
587
+ {
588
+ id: 'wide',
589
+ modalities: ['image' as const],
590
+ capabilities: ['aspect-ratio' as const],
591
+ accepts: { aspectRatios: ['7:3', '1:1'] },
592
+ },
593
+ {
594
+ id: 'bucketed',
595
+ modalities: ['image' as const],
596
+ capabilities: ['aspect-ratio' as const],
597
+ accepts: { aspectRatios: ['1:1', '16:9'] },
598
+ },
599
+ ],
600
+ )
601
+ expect(pick.issues).toContain('option_value_partial')
602
+ expect(pick.issues).not.toContain('option_value_unaccepted')
603
+ expect(pick.issues).not.toContain('option_value_unverifiable')
604
+ expect(pick.partiallyAcceptedValues).toEqual([
605
+ { option: 'aspectRatio', requested: '7:3', refusedBy: ['bucketed'] },
606
+ ])
607
+ })
608
+
524
609
  it('reports BOTH faults when an unknown id was the one covering a requirement', () => {
525
610
  // One edit should clear the step. Naming only the missing id would leave the user to
526
611
  // discover the uncovered requirement on the next round trip.
@@ -3,6 +3,7 @@ import {
3
3
  binaryCapabilityCoverage,
4
4
  binaryFormatCoverage,
5
5
  binaryModalityOverlaps,
6
+ binaryValueCoverage,
6
7
  conflictingOutputSizeOptions,
7
8
  isBinaryModality,
8
9
  modalityCarriesPixelDimensions,
@@ -13,6 +14,9 @@ import type {
13
14
  BinaryGeneratorCapability,
14
15
  BinaryModality,
15
16
  BinaryModalityOverlap,
17
+ BinaryPartiallyAcceptedValue,
18
+ BinaryUnacceptedValue,
19
+ BinaryValueOption,
16
20
  ConflictingOutputSizeOption,
17
21
  RegisteredBinaryGenerator,
18
22
  } from '@cat-factory/contracts'
@@ -532,6 +536,28 @@ export type BinaryOutputPickIssue =
532
536
  * flag most working selections in the product.
533
537
  */
534
538
  | 'capability_unverifiable'
539
+ /**
540
+ * A generation option every selected integration can be ASKED for and none of them accepts the
541
+ * step's VALUE at: a `7:3` aspect ratio against endpoints whose picklists offer ten others
542
+ * (kernel's `option_value_unaccepted` spelling verbatim, like the members above it). A refusal.
543
+ */
544
+ | 'option_value_unaccepted'
545
+ /**
546
+ * A selected integration ACCEPTS the step's value and another has enumerated it away, so the
547
+ * step is servable by part of what it selected and the rest would quietly deliver something
548
+ * else. ADVISORY, and the reason is the same one that makes a capability covered when a single
549
+ * integration declares it: which endpoint renders which artifact is the agent's call. What is
550
+ * NOT optional is naming the ones that refuse it, since routing around them is the whole remedy.
551
+ */
552
+ | 'option_value_partial'
553
+ /**
554
+ * The step's value is on no stated set, and a selected integration that declares the capability
555
+ * states no set at all, so it may still be served. ADVISORY, for the reason
556
+ * `capability_unverifiable` is, and it is deliberately silent where NOBODY states a set: that is
557
+ * the state every registration is in until an endpoint is audited, and a line that fired there
558
+ * would ride nearly every step carrying an aspect ratio.
559
+ */
560
+ | 'option_value_unverifiable'
535
561
  /**
536
562
  * The step states an exact output size AND another option that restates the delivered
537
563
  * dimensions (`aspectRatio`, `upscale`). A refusal, mirroring `assertUnambiguousOutputSize` at
@@ -567,6 +593,14 @@ export interface BinaryOutputPickState {
567
593
  unsupportedCapabilities: readonly BinaryGeneratorCapability[]
568
594
  /** The ones that could not be judged, kept apart from the refusal above. */
569
595
  unverifiableCapabilities: readonly BinaryGeneratorCapability[]
596
+ /** The requested option values nothing selected accepts, each with what IS accepted, so the
597
+ * message names a value the reader can pick instead of only the one they cannot. */
598
+ unacceptedValues: readonly BinaryUnacceptedValue[]
599
+ /** The requested values a selected integration accepts and another enumerated away, naming the
600
+ * ones that refuse them, since the remedy is dropping or re-routing around those. */
601
+ partiallyAcceptedValues: readonly BinaryPartiallyAcceptedValue[]
602
+ /** The ones a silent declarer left open, kept apart from the refusal above. */
603
+ unverifiableValues: readonly BinaryValueOption[]
570
604
  /** The options restating the delivered dimensions beside an exact size, for the line that names
571
605
  * which field to delete. Computed through contracts' own rule, so this cannot come to a
572
606
  * different answer from the save that refuses it. */
@@ -598,7 +632,7 @@ function generatorPickIssues(
598
632
  config: BinaryOutputConfig | undefined,
599
633
  generators: readonly Pick<
600
634
  RegisteredBinaryGenerator,
601
- 'id' | 'modalities' | 'mediaTypes' | 'capabilities'
635
+ 'id' | 'modalities' | 'mediaTypes' | 'capabilities' | 'accepts'
602
636
  >[],
603
637
  unavailable: boolean,
604
638
  ): {
@@ -610,6 +644,9 @@ function generatorPickIssues(
610
644
  overlaps: BinaryModalityOverlap[]
611
645
  unsupportedCapabilities: BinaryGeneratorCapability[]
612
646
  unverifiableCapabilities: BinaryGeneratorCapability[]
647
+ unacceptedValues: BinaryUnacceptedValue[]
648
+ partiallyAcceptedValues: BinaryPartiallyAcceptedValue[]
649
+ unverifiableValues: BinaryValueOption[]
613
650
  } {
614
651
  const none = {
615
652
  unknownGeneratorIds: [],
@@ -619,6 +656,9 @@ function generatorPickIssues(
619
656
  overlaps: [],
620
657
  unsupportedCapabilities: [],
621
658
  unverifiableCapabilities: [],
659
+ unacceptedValues: [],
660
+ partiallyAcceptedValues: [],
661
+ unverifiableValues: [],
622
662
  }
623
663
  if (unavailable) return { issues: ['generators_unavailable'], ...none }
624
664
  const byId = new Map(generators.map((g) => [g.id, g]))
@@ -646,6 +686,10 @@ function generatorPickIssues(
646
686
  requiredBinaryCapabilities(config?.generation),
647
687
  selected,
648
688
  )
689
+ // One notch finer: the option is supported and the VALUE is not. Imported like every rule
690
+ // beside it, so the line this surface shows and the refusal the backend raises are one
691
+ // judgement rather than two that agree until somebody edits one of them.
692
+ const value = binaryValueCoverage(config?.generation, selected)
649
693
  const issues: BinaryOutputPickIssue[] = []
650
694
  if (unknownGeneratorIds.length) issues.push('unknown_generator')
651
695
  if (uncovered.length) issues.push('modality_uncovered')
@@ -654,6 +698,9 @@ function generatorPickIssues(
654
698
  if (overlaps.length) issues.push('generator_overlap')
655
699
  if (capability.uncovered.length) issues.push('capability_unsupported')
656
700
  if (capability.unverifiable.length) issues.push('capability_unverifiable')
701
+ if (value.unaccepted.length) issues.push('option_value_unaccepted')
702
+ if (value.partial.length) issues.push('option_value_partial')
703
+ if (value.unverifiable.length) issues.push('option_value_unverifiable')
657
704
  return {
658
705
  issues,
659
706
  unknownGeneratorIds,
@@ -663,6 +710,9 @@ function generatorPickIssues(
663
710
  overlaps,
664
711
  unsupportedCapabilities: capability.uncovered,
665
712
  unverifiableCapabilities: capability.unverifiable,
713
+ unacceptedValues: value.unaccepted,
714
+ partiallyAcceptedValues: value.partial,
715
+ unverifiableValues: value.unverifiable,
666
716
  }
667
717
  }
668
718
 
@@ -699,7 +749,7 @@ export function binaryOutputPickIssues(
699
749
  // stays a legitimate value rather than a hole.
700
750
  generators: readonly Pick<
701
751
  RegisteredBinaryGenerator,
702
- 'id' | 'modalities' | 'mediaTypes' | 'capabilities'
752
+ 'id' | 'modalities' | 'mediaTypes' | 'capabilities' | 'accepts'
703
753
  >[] = [],
704
754
  // Whether the deployment's integrations could not be READ. Defaulted to `false` — the honest
705
755
  // default, since every deployment but a mothership-mode node reads them in-process and cannot
@@ -737,6 +787,9 @@ export function binaryOutputPickIssues(
737
787
  generatorOverlaps: generative.overlaps,
738
788
  unsupportedCapabilities: generative.unsupportedCapabilities,
739
789
  unverifiableCapabilities: generative.unverifiableCapabilities,
790
+ unacceptedValues: generative.unacceptedValues,
791
+ partiallyAcceptedValues: generative.partiallyAcceptedValues,
792
+ unverifiableValues: generative.unverifiableValues,
740
793
  conflictingSizeOptions,
741
794
  }
742
795
  }
@@ -764,6 +817,9 @@ export function binaryOutputPickIssues(
764
817
  generatorOverlaps: generative.overlaps,
765
818
  unsupportedCapabilities: generative.unsupportedCapabilities,
766
819
  unverifiableCapabilities: generative.unverifiableCapabilities,
820
+ unacceptedValues: generative.unacceptedValues,
821
+ partiallyAcceptedValues: generative.partiallyAcceptedValues,
822
+ unverifiableValues: generative.unverifiableValues,
767
823
  conflictingSizeOptions,
768
824
  }
769
825
  }