@cat-factory/app 0.159.3 → 0.160.1
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/layout/IntegrationsHub.vue +9 -0
- package/app/components/panels/StepTestReport.logic.spec.ts +49 -0
- package/app/components/panels/StepTestReport.logic.ts +49 -0
- package/app/components/panels/StepTestReport.vue +44 -1
- package/app/components/providers/AiProviderOnboardingModal.vue +19 -1
- package/app/components/providers/PersonalCredentialModal.vue +3 -1
- package/app/components/providers/VendorCredentialsModal.vue +22 -0
- package/app/components/spec/ServiceSpecWindow.logic.spec.ts +108 -0
- package/app/components/spec/ServiceSpecWindow.logic.ts +71 -0
- package/app/components/spec/ServiceSpecWindow.vue +142 -1
- package/app/types/domain.ts +2 -0
- package/app/types/spec.ts +1 -0
- package/i18n/locales/de.json +35 -3
- package/i18n/locales/en.json +41 -3
- package/i18n/locales/es.json +35 -3
- package/i18n/locales/fr.json +35 -3
- package/i18n/locales/he.json +35 -3
- package/i18n/locales/it.json +35 -3
- package/i18n/locales/ja.json +35 -3
- package/i18n/locales/pl.json +35 -3
- package/i18n/locales/tr.json +35 -3
- package/i18n/locales/uk.json +35 -3
- package/package.json +2 -2
|
@@ -137,6 +137,15 @@ const groups = computed<IntegrationGroup[]>(() => {
|
|
|
137
137
|
onClick: () => go(ui.openVendorCredentials),
|
|
138
138
|
},
|
|
139
139
|
],
|
|
140
|
+
// Personal (individual-usage) subscriptions are per-USER, so they live in My setup —
|
|
141
|
+
// but this hub is where people look first when connecting "their Claude plan". A quiet
|
|
142
|
+
// pointer link keeps them findable without a workspace-scoped row competing here.
|
|
143
|
+
footerLink: {
|
|
144
|
+
key: 'personal-subs',
|
|
145
|
+
icon: 'i-lucide-user',
|
|
146
|
+
label: t('layout.integrationsHub.items.personalSubs.label'),
|
|
147
|
+
onClick: () => go(() => ui.openVendorCredentials('personal')),
|
|
148
|
+
},
|
|
140
149
|
})
|
|
141
150
|
|
|
142
151
|
// --- Source control --------------------------------------------------------
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { resolveVerdictMeta, UNKNOWN_VERDICT_COLOR, VERDICT_COLORS } from './StepTestReport.logic'
|
|
3
|
+
import type { RequirementVerdictStatus } from '~/types/domain'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The tester panel's requirement → evidence dots. The verdict is three-valued precisely so that
|
|
7
|
+
* "we didn't check" and "it's broken" never render the same; these pin that a FOURTH, unknown
|
|
8
|
+
* value cannot quietly join either camp.
|
|
9
|
+
*/
|
|
10
|
+
const LABELS: Record<RequirementVerdictStatus, string> = {
|
|
11
|
+
met: 'Met',
|
|
12
|
+
not_met: 'Not met',
|
|
13
|
+
not_covered: 'Not checked',
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('resolveVerdictMeta', () => {
|
|
17
|
+
it('resolves each known status to its own label and colour', () => {
|
|
18
|
+
expect(resolveVerdictMeta('met', LABELS)).toEqual({ label: 'Met', color: '#22c55e' })
|
|
19
|
+
expect(resolveVerdictMeta('not_met', LABELS)).toEqual({ label: 'Not met', color: '#ef4444' })
|
|
20
|
+
expect(resolveVerdictMeta('not_covered', LABELS)).toEqual({
|
|
21
|
+
label: 'Not checked',
|
|
22
|
+
color: '#64748b',
|
|
23
|
+
})
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('gives the three known statuses three distinct colours', () => {
|
|
27
|
+
const colors = Object.values(VERDICT_COLORS)
|
|
28
|
+
expect(new Set(colors).size).toBe(colors.length)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('falls back to the raw code for an unrecognised status', () => {
|
|
32
|
+
const meta = resolveVerdictMeta('inconclusive' as RequirementVerdictStatus, LABELS)
|
|
33
|
+
expect(meta).toEqual({ label: 'inconclusive', color: UNKNOWN_VERDICT_COLOR })
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('never lets an unrecognised status borrow a known colour', () => {
|
|
37
|
+
// The regression this guards: reusing `not_covered`'s grey made a contract violation read
|
|
38
|
+
// as "not checked", which is the one confusion the three-valued design forbids.
|
|
39
|
+
expect(Object.values(VERDICT_COLORS)).not.toContain(UNKNOWN_VERDICT_COLOR)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('falls back when the status is known but its label is missing', () => {
|
|
43
|
+
const sparse = { met: 'Met' } as Record<RequirementVerdictStatus, string>
|
|
44
|
+
expect(resolveVerdictMeta('not_met', sparse)).toEqual({
|
|
45
|
+
label: 'not_met',
|
|
46
|
+
color: UNKNOWN_VERDICT_COLOR,
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
})
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Pure logic behind the tester panel's REQUIREMENT → EVIDENCE section (the in-app twin of the
|
|
2
|
+
// PR verification report's section of the same name).
|
|
3
|
+
//
|
|
4
|
+
// Kept out of the SFC on the seam `ServiceSpecWindow.logic.ts` / `AppOverlayHost.logic.ts`
|
|
5
|
+
// established, so the one rule worth pinning here — that an unrecognised status can never be
|
|
6
|
+
// mistaken for a known one — is unit-testable without mounting Nuxt.
|
|
7
|
+
import type { RequirementVerdictStatus } from '~/types/domain'
|
|
8
|
+
|
|
9
|
+
/** How one verdict renders: its translated label and the dot colour beside it. */
|
|
10
|
+
export interface VerdictMeta {
|
|
11
|
+
label: string
|
|
12
|
+
color: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The colour an UNRECOGNISED status renders in. Deliberately distinct from all three known
|
|
17
|
+
* colours: an unknown status is a contract violation (version skew against a backend that grew
|
|
18
|
+
* a fourth one), not a fourth state, so borrowing `not_covered`'s grey would render "we have no
|
|
19
|
+
* idea" identically to "we didn't check" — precisely the collapse the three-valued verdict
|
|
20
|
+
* exists to prevent. Amber is the same "attention, but not a failure" tone the panel's severity
|
|
21
|
+
* scale already uses for `medium`.
|
|
22
|
+
*/
|
|
23
|
+
export const UNKNOWN_VERDICT_COLOR = '#f59e0b'
|
|
24
|
+
|
|
25
|
+
/** Dot colour per known verdict. Exhaustive over the closed union (drift guard tier 2). */
|
|
26
|
+
export const VERDICT_COLORS: Record<RequirementVerdictStatus, string> = {
|
|
27
|
+
met: '#22c55e',
|
|
28
|
+
not_met: '#ef4444',
|
|
29
|
+
not_covered: '#64748b',
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Resolve one verdict's presentation. Labels are passed in already translated (this module is
|
|
34
|
+
* pure, so it holds no `t`), which also keeps the catalog keys literal at the call site where
|
|
35
|
+
* the typed-key drift guard can see them.
|
|
36
|
+
*
|
|
37
|
+
* An unrecognised status falls back to the raw code plus {@link UNKNOWN_VERDICT_COLOR}: showing
|
|
38
|
+
* the code is more useful to the person who has to explain the skew than any invented label,
|
|
39
|
+
* and the distinct colour stops it reading as one of the three real answers.
|
|
40
|
+
*/
|
|
41
|
+
export function resolveVerdictMeta(
|
|
42
|
+
status: RequirementVerdictStatus,
|
|
43
|
+
labels: Record<RequirementVerdictStatus, string>,
|
|
44
|
+
): VerdictMeta {
|
|
45
|
+
const color = VERDICT_COLORS[status]
|
|
46
|
+
const label = labels[status]
|
|
47
|
+
if (!color || !label) return { label: status, color: UNKNOWN_VERDICT_COLOR }
|
|
48
|
+
return { label, color }
|
|
49
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import type { TestReport } from '~/types/domain'
|
|
2
|
+
import type { RequirementVerdictStatus, TestReport } from '~/types/domain'
|
|
3
3
|
import type { TesterStepState } from '~/types/execution'
|
|
4
|
+
import { resolveVerdictMeta, type VerdictMeta } from './StepTestReport.logic'
|
|
4
5
|
|
|
5
6
|
// A tester step's latest structured report (what was tested, the per-area outcomes,
|
|
6
7
|
// the concerns it raised and the greenlight verdict) plus the fixer-loop phase.
|
|
@@ -22,6 +23,21 @@ const OUTCOME_COLOR: Record<string, string> = {
|
|
|
22
23
|
failed: '#ef4444',
|
|
23
24
|
skipped: '#64748b',
|
|
24
25
|
}
|
|
26
|
+
|
|
27
|
+
// Per-spec-requirement verdict labels, keyed by the requirement id the service's in-repo `spec/`
|
|
28
|
+
// carries. THREE-VALUED on purpose: "we didn't check" and "it's broken" must never render the
|
|
29
|
+
// same, which is the whole reason the tester reports the list. Exhaustive `Record` over the
|
|
30
|
+
// closed union (drift guard tier 2) with literal `t()` keys, so a new status fails the
|
|
31
|
+
// typecheck rather than rendering a raw enum value. Colours and the unknown-status fallback live
|
|
32
|
+
// in the pure sibling, where the "never borrow a known colour" rule is unit-tested.
|
|
33
|
+
const VERDICT_LABELS: Record<RequirementVerdictStatus, string> = {
|
|
34
|
+
met: t('panels.testReport.requirementVerdicts.met'),
|
|
35
|
+
not_met: t('panels.testReport.requirementVerdicts.notMet'),
|
|
36
|
+
not_covered: t('panels.testReport.requirementVerdicts.notCovered'),
|
|
37
|
+
}
|
|
38
|
+
function verdictMeta(status: RequirementVerdictStatus): VerdictMeta {
|
|
39
|
+
return resolveVerdictMeta(status, VERDICT_LABELS)
|
|
40
|
+
}
|
|
25
41
|
</script>
|
|
26
42
|
|
|
27
43
|
<template>
|
|
@@ -65,6 +81,33 @@ const OUTCOME_COLOR: Record<string, string> = {
|
|
|
65
81
|
</div>
|
|
66
82
|
</div>
|
|
67
83
|
|
|
84
|
+
<!-- requirement → evidence, in-app twin of the PR verification report's section: which of
|
|
85
|
+
the service's written-down requirements this run actually ruled on, and what it saw -->
|
|
86
|
+
<div
|
|
87
|
+
v-if="report.requirementVerdicts?.length"
|
|
88
|
+
class="mb-3 space-y-1"
|
|
89
|
+
data-testid="test-report-requirement-verdicts"
|
|
90
|
+
>
|
|
91
|
+
<div class="text-[11px] text-slate-500">
|
|
92
|
+
{{ t('panels.testReport.requirementVerdicts.title') }}
|
|
93
|
+
</div>
|
|
94
|
+
<div
|
|
95
|
+
v-for="(verdict, i) in report.requirementVerdicts"
|
|
96
|
+
:key="i"
|
|
97
|
+
class="flex items-start gap-2 text-[12px]"
|
|
98
|
+
>
|
|
99
|
+
<span
|
|
100
|
+
class="mt-1 h-2 w-2 shrink-0 rounded-full"
|
|
101
|
+
:style="{ backgroundColor: verdictMeta(verdict.status).color }"
|
|
102
|
+
/>
|
|
103
|
+
<span class="text-slate-300">
|
|
104
|
+
<span class="font-mono text-[11px] text-slate-400">{{ verdict.requirementId }}</span>
|
|
105
|
+
<span class="text-slate-500"> — {{ verdictMeta(verdict.status).label }}</span>
|
|
106
|
+
<span v-if="verdict.detail" class="text-slate-500"> · {{ verdict.detail }}</span>
|
|
107
|
+
</span>
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
|
|
68
111
|
<div v-if="report.concerns.length" class="space-y-1">
|
|
69
112
|
<div class="text-[11px] text-slate-500">{{ t('panels.testReport.concerns') }}</div>
|
|
70
113
|
<div
|
|
@@ -27,10 +27,23 @@ interface Route {
|
|
|
27
27
|
title: string
|
|
28
28
|
body: string
|
|
29
29
|
cta: string
|
|
30
|
+
badge?: string
|
|
30
31
|
onSelect: () => void
|
|
31
32
|
}
|
|
32
33
|
|
|
34
|
+
// The personal-subscription route leads: using an individual coding-plan subscription the
|
|
35
|
+
// developer already pays for (Claude / ChatGPT-Codex / GLM) is the most common setup, and
|
|
36
|
+
// it used to hide behind the generic "LLM vendors" route as the last tab, where new users
|
|
37
|
+
// missed it. It deep-links straight onto the vendor modal's `personal` tab.
|
|
33
38
|
const routes = computed<Route[]>(() => [
|
|
39
|
+
{
|
|
40
|
+
icon: 'i-lucide-user',
|
|
41
|
+
title: t('providers.onboarding.routes.personal.title'),
|
|
42
|
+
body: t('providers.onboarding.routes.personal.body'),
|
|
43
|
+
cta: t('providers.onboarding.routes.personal.cta'),
|
|
44
|
+
badge: t('providers.onboarding.routes.personal.badge'),
|
|
45
|
+
onSelect: () => go(() => ui.openVendorCredentials('personal')),
|
|
46
|
+
},
|
|
34
47
|
{
|
|
35
48
|
icon: 'i-lucide-key-round',
|
|
36
49
|
title: t('providers.onboarding.routes.keys.title'),
|
|
@@ -85,7 +98,12 @@ const routes = computed<Route[]>(() => [
|
|
|
85
98
|
>
|
|
86
99
|
<UIcon :name="r.icon" class="mt-0.5 h-5 w-5 shrink-0 text-indigo-300" />
|
|
87
100
|
<div class="min-w-0 flex-1">
|
|
88
|
-
<
|
|
101
|
+
<div class="flex items-center gap-2">
|
|
102
|
+
<p class="text-sm font-semibold text-slate-100">{{ r.title }}</p>
|
|
103
|
+
<UBadge v-if="r.badge" color="primary" variant="subtle" size="sm">
|
|
104
|
+
{{ r.badge }}
|
|
105
|
+
</UBadge>
|
|
106
|
+
</div>
|
|
89
107
|
<p class="mt-0.5 text-[13px] leading-relaxed text-slate-400">{{ r.body }}</p>
|
|
90
108
|
</div>
|
|
91
109
|
<UButton
|
|
@@ -85,7 +85,9 @@ async function submit() {
|
|
|
85
85
|
|
|
86
86
|
function goConnect() {
|
|
87
87
|
personal.dismissPending()
|
|
88
|
-
|
|
88
|
+
// Deep-link onto the personal tab: this CTA promises the individual-subscription connect
|
|
89
|
+
// form, and the modal's default tab is the (unrelated) workspace pool.
|
|
90
|
+
ui.openVendorCredentials('personal')
|
|
89
91
|
}
|
|
90
92
|
</script>
|
|
91
93
|
|
|
@@ -209,6 +209,28 @@ function vendorLabel(v: SubscriptionVendor): string {
|
|
|
209
209
|
<!-- Workspace pool (commercial coding-plan subscriptions) -->
|
|
210
210
|
<template #pool>
|
|
211
211
|
<div class="space-y-5">
|
|
212
|
+
<!-- The pool is the modal's default tab, but most individual developers arrive
|
|
213
|
+
looking for their OWN Claude / ChatGPT / GLM plan, which lives on the
|
|
214
|
+
`personal` tab — point them there before they read a pool form that can't
|
|
215
|
+
take their credential. -->
|
|
216
|
+
<div
|
|
217
|
+
class="flex items-center gap-3 rounded-lg border border-indigo-500/30 bg-indigo-950/30 px-3 py-2.5"
|
|
218
|
+
>
|
|
219
|
+
<UIcon name="i-lucide-user" class="h-5 w-5 shrink-0 text-indigo-300" />
|
|
220
|
+
<p class="min-w-0 flex-1 text-[13px] text-slate-300">
|
|
221
|
+
{{ t('providers.vendorCredentials.personalCallout.text') }}
|
|
222
|
+
</p>
|
|
223
|
+
<UButton
|
|
224
|
+
size="xs"
|
|
225
|
+
color="primary"
|
|
226
|
+
variant="subtle"
|
|
227
|
+
class="shrink-0"
|
|
228
|
+
@click="activeTab = 'personal'"
|
|
229
|
+
>
|
|
230
|
+
{{ t('providers.vendorCredentials.personalCallout.cta') }}
|
|
231
|
+
</UButton>
|
|
232
|
+
</div>
|
|
233
|
+
|
|
212
234
|
<p class="text-sm text-slate-400">
|
|
213
235
|
{{ t('providers.vendorCredentials.poolIntro') }}
|
|
214
236
|
</p>
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
filterRequirementsByState,
|
|
4
|
+
requirementState,
|
|
5
|
+
summarizeRequirementStates,
|
|
6
|
+
summarizeSpecStates,
|
|
7
|
+
} from './ServiceSpecWindow.logic'
|
|
8
|
+
import type { RequirementItem, SpecModule } from '~/types/spec'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The implementation-state view of the service-spec window (service-acceptance-criteria, slice 4).
|
|
12
|
+
* The axis only earns its keep if "agreed" and "observed to hold" never render the same, so these
|
|
13
|
+
* pin the counting, the defensive read of a missing state, and the filter's identity fast path.
|
|
14
|
+
*/
|
|
15
|
+
const req = (id: string, state?: RequirementItem['state']): RequirementItem =>
|
|
16
|
+
({
|
|
17
|
+
id,
|
|
18
|
+
title: id,
|
|
19
|
+
statement: `The system SHALL ${id}.`,
|
|
20
|
+
kind: 'functional',
|
|
21
|
+
priority: 'must',
|
|
22
|
+
...(state ? { state } : {}),
|
|
23
|
+
}) as RequirementItem
|
|
24
|
+
|
|
25
|
+
describe('requirementState', () => {
|
|
26
|
+
it('reads an explicit established state', () => {
|
|
27
|
+
expect(requirementState(req('a', 'established'))).toBe('established')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('treats an absent or unknown state as aspirational', () => {
|
|
31
|
+
expect(requirementState(req('a'))).toBe('aspirational')
|
|
32
|
+
expect(requirementState({ state: 'nonsense' } as unknown as RequirementItem)).toBe(
|
|
33
|
+
'aspirational',
|
|
34
|
+
)
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
describe('summarizeRequirementStates', () => {
|
|
39
|
+
it('counts both halves and their total', () => {
|
|
40
|
+
expect(
|
|
41
|
+
summarizeRequirementStates([
|
|
42
|
+
req('a', 'established'),
|
|
43
|
+
req('b', 'aspirational'),
|
|
44
|
+
req('c', 'established'),
|
|
45
|
+
]),
|
|
46
|
+
).toEqual({ total: 3, established: 2, aspirational: 1 })
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('counts a requirement with no state as aspirational', () => {
|
|
50
|
+
expect(summarizeRequirementStates([req('a')])).toEqual({
|
|
51
|
+
total: 1,
|
|
52
|
+
established: 0,
|
|
53
|
+
aspirational: 1,
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('returns zeroes for an absent or empty list', () => {
|
|
58
|
+
const zero = { total: 0, established: 0, aspirational: 0 }
|
|
59
|
+
expect(summarizeRequirementStates(undefined)).toEqual(zero)
|
|
60
|
+
expect(summarizeRequirementStates([])).toEqual(zero)
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
describe('summarizeSpecStates', () => {
|
|
65
|
+
// The parsed wire type fills every optional with its default, so the sparse shapes below are
|
|
66
|
+
// cast: they stand for what a hand-written or partially-hydrated tree actually looks like at
|
|
67
|
+
// runtime, which is exactly the case the helpers are defensive about.
|
|
68
|
+
const modules = [
|
|
69
|
+
{
|
|
70
|
+
name: 'auth',
|
|
71
|
+
groups: [
|
|
72
|
+
{ name: 'login', requirements: [req('a', 'established'), req('b')] },
|
|
73
|
+
{ name: 'logout', requirements: [req('c', 'established')] },
|
|
74
|
+
],
|
|
75
|
+
},
|
|
76
|
+
// A module whose only group has no requirements, and a module with no groups at all: both
|
|
77
|
+
// contribute nothing rather than throwing — a spec is legitimately sparse while it is
|
|
78
|
+
// being written.
|
|
79
|
+
{ name: 'billing', groups: [{ name: 'invoices' }] },
|
|
80
|
+
{ name: 'empty' },
|
|
81
|
+
] as unknown as SpecModule[]
|
|
82
|
+
|
|
83
|
+
it('rolls the per-group counts up across the tree', () => {
|
|
84
|
+
expect(summarizeSpecStates(modules)).toEqual({ total: 3, established: 2, aspirational: 1 })
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('returns zeroes for an absent module list', () => {
|
|
88
|
+
expect(summarizeSpecStates(undefined)).toEqual({ total: 0, established: 0, aspirational: 0 })
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
describe('filterRequirementsByState', () => {
|
|
93
|
+
const list = [req('a', 'established'), req('b'), req('c', 'aspirational')]
|
|
94
|
+
|
|
95
|
+
it('returns the same array reference for the default filter', () => {
|
|
96
|
+
expect(filterRequirementsByState(list, 'all')).toBe(list)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('keeps only the requested half', () => {
|
|
100
|
+
expect(filterRequirementsByState(list, 'established').map((r) => r.id)).toEqual(['a'])
|
|
101
|
+
expect(filterRequirementsByState(list, 'aspirational').map((r) => r.id)).toEqual(['b', 'c'])
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('handles an absent list', () => {
|
|
105
|
+
expect(filterRequirementsByState(undefined, 'established')).toEqual([])
|
|
106
|
+
expect(filterRequirementsByState(undefined, 'all')).toEqual([])
|
|
107
|
+
})
|
|
108
|
+
})
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Pure logic behind the service-spec window's IMPLEMENTATION-STATE view.
|
|
2
|
+
//
|
|
3
|
+
// `spec/` is prescriptive — it says what must be TRUE — and `requirementItem.state` is what
|
|
4
|
+
// lets it also say what is true YET (`aspirational` = agreed via the spec diff's PR review,
|
|
5
|
+
// `established` = a tester exercised its acceptance criteria and they passed). The agents
|
|
6
|
+
// already read that split off their checkout; this is the half a HUMAN reads, so the same
|
|
7
|
+
// window that lists a service's behaviour can say how much of it the service actually honours.
|
|
8
|
+
//
|
|
9
|
+
// Kept out of the SFC so the counting and filtering are unit-testable without mounting Nuxt.
|
|
10
|
+
import type { RequirementItem, RequirementState, SpecModule } from '~/types/spec'
|
|
11
|
+
|
|
12
|
+
/** Which half of the spec the reader wants to see. `all` is the default (nothing hidden). */
|
|
13
|
+
export type RequirementStateFilter = 'all' | RequirementState
|
|
14
|
+
|
|
15
|
+
/** Requirement counts for one scope (a group, or a whole spec). */
|
|
16
|
+
export interface RequirementStateSummary {
|
|
17
|
+
total: number
|
|
18
|
+
established: number
|
|
19
|
+
aspirational: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Read a requirement's implementation state defensively. The wire schema defaults an absent
|
|
24
|
+
* value to `aspirational` at the parse boundary, but a requirement nobody has observed to hold
|
|
25
|
+
* is exactly what an unreadable/absent state means — so treating anything that is not literally
|
|
26
|
+
* `established` as aspirational is both the safe answer and the one the backend renders.
|
|
27
|
+
*/
|
|
28
|
+
export function requirementState(item: Pick<RequirementItem, 'state'>): RequirementState {
|
|
29
|
+
return item.state === 'established' ? 'established' : 'aspirational'
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Count a list of requirements by implementation state. */
|
|
33
|
+
export function summarizeRequirementStates(
|
|
34
|
+
requirements: readonly Pick<RequirementItem, 'state'>[] | undefined,
|
|
35
|
+
): RequirementStateSummary {
|
|
36
|
+
const total = requirements?.length ?? 0
|
|
37
|
+
let established = 0
|
|
38
|
+
for (const req of requirements ?? []) {
|
|
39
|
+
if (requirementState(req) === 'established') established += 1
|
|
40
|
+
}
|
|
41
|
+
return { total, established, aspirational: total - established }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Roll the per-group counts up across the whole spec tree (the overview pane's headline). */
|
|
45
|
+
export function summarizeSpecStates(
|
|
46
|
+
modules: readonly SpecModule[] | undefined,
|
|
47
|
+
): RequirementStateSummary {
|
|
48
|
+
const summary: RequirementStateSummary = { total: 0, established: 0, aspirational: 0 }
|
|
49
|
+
for (const module of modules ?? []) {
|
|
50
|
+
for (const group of module.groups ?? []) {
|
|
51
|
+
const groupSummary = summarizeRequirementStates(group.requirements)
|
|
52
|
+
summary.total += groupSummary.total
|
|
53
|
+
summary.established += groupSummary.established
|
|
54
|
+
summary.aspirational += groupSummary.aspirational
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return summary
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Apply the reader's state filter. `all` returns the SAME array reference (not a copy), so the
|
|
62
|
+
* default view costs nothing and a `v-for` key set never churns on an unrelated re-render.
|
|
63
|
+
*/
|
|
64
|
+
export function filterRequirementsByState<T extends Pick<RequirementItem, 'state'>>(
|
|
65
|
+
requirements: readonly T[] | undefined,
|
|
66
|
+
filter: RequirementStateFilter,
|
|
67
|
+
): readonly T[] {
|
|
68
|
+
const list = requirements ?? []
|
|
69
|
+
if (filter === 'all') return list
|
|
70
|
+
return list.filter((req) => requirementState(req) === filter)
|
|
71
|
+
}
|
|
@@ -10,9 +10,17 @@ import type {
|
|
|
10
10
|
RequirementItem,
|
|
11
11
|
RequirementKind,
|
|
12
12
|
RequirementPriority,
|
|
13
|
+
RequirementState,
|
|
13
14
|
SpecModule,
|
|
14
15
|
} from '~/types/spec'
|
|
15
16
|
import ResultWindowShell from '~/components/panels/ResultWindowShell.vue'
|
|
17
|
+
import {
|
|
18
|
+
filterRequirementsByState,
|
|
19
|
+
requirementState,
|
|
20
|
+
summarizeRequirementStates,
|
|
21
|
+
summarizeSpecStates,
|
|
22
|
+
type RequirementStateFilter,
|
|
23
|
+
} from './ServiceSpecWindow.logic'
|
|
16
24
|
|
|
17
25
|
const { t } = useI18n()
|
|
18
26
|
const board = useBoardStore()
|
|
@@ -20,6 +28,16 @@ const serviceSpec = useServiceSpecStore()
|
|
|
20
28
|
|
|
21
29
|
type ViewMode = 'structured' | 'gherkin'
|
|
22
30
|
const mode = ref<ViewMode>('structured')
|
|
31
|
+
// Which half of the spec the reader wants: everything, only what the service is observed to
|
|
32
|
+
// honour, or only what is agreed but not built yet.
|
|
33
|
+
//
|
|
34
|
+
// DELIBERATELY STICKY ACROSS GROUPS, and reset only on open (alongside the view mode and the
|
|
35
|
+
// selection). "Show me what this service has actually proven" is a question about the SERVICE,
|
|
36
|
+
// so re-answering it on every group click would defeat the filter the moment the reader
|
|
37
|
+
// navigates. The cost is that a group with no match renders empty, which is why that case says
|
|
38
|
+
// so and offers a way back rather than leaving the reader stranded in a filter they may have
|
|
39
|
+
// set several groups ago.
|
|
40
|
+
const stateFilter = ref<RequirementStateFilter>('all')
|
|
23
41
|
// Selected feature group, keyed by its module + group index so a name collision can't
|
|
24
42
|
// cross-select. Null = show the service overview.
|
|
25
43
|
const selected = ref<{ m: number; g: number } | null>(null)
|
|
@@ -27,6 +45,7 @@ const selected = ref<{ m: number; g: number } | null>(null)
|
|
|
27
45
|
const { open, blockId, close } = useResultView('service-spec', {
|
|
28
46
|
onOpen: ({ blockId }) => {
|
|
29
47
|
mode.value = 'structured'
|
|
48
|
+
stateFilter.value = 'all'
|
|
30
49
|
selected.value = null
|
|
31
50
|
void serviceSpec.load(blockId)
|
|
32
51
|
},
|
|
@@ -109,9 +128,47 @@ const KIND_LABELS: Record<RequirementKind, string> = {
|
|
|
109
128
|
constraint: t('spec.kind.constraint'),
|
|
110
129
|
}
|
|
111
130
|
|
|
131
|
+
// Exhaustive implementation-state → presentation map. `established` is the only state that
|
|
132
|
+
// means "the service is observed to do this"; everything else is a behaviour that has been
|
|
133
|
+
// agreed and not yet seen to hold, which must never read as standing behaviour.
|
|
134
|
+
const STATE_META: Record<RequirementState, { label: string; chip: string; icon: string }> = {
|
|
135
|
+
established: {
|
|
136
|
+
label: t('spec.state.established'),
|
|
137
|
+
chip: 'success',
|
|
138
|
+
icon: 'i-lucide-circle-check',
|
|
139
|
+
},
|
|
140
|
+
aspirational: {
|
|
141
|
+
label: t('spec.state.aspirational'),
|
|
142
|
+
chip: 'neutral',
|
|
143
|
+
icon: 'i-lucide-circle-dashed',
|
|
144
|
+
},
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// The state filter's three choices. The two state choices REUSE the badge labels rather than
|
|
148
|
+
// carrying their own catalog keys: a chip that reads differently from the badge it filters for
|
|
149
|
+
// is a translation bug waiting to happen, and one key per state cannot drift from itself. Only
|
|
150
|
+
// `all` needs a key of its own, and it is a literal `t()` so the typed-key drift guard stays live.
|
|
151
|
+
const STATE_FILTERS: { value: RequirementStateFilter; label: string }[] = [
|
|
152
|
+
{ value: 'all', label: t('spec.state.filter.all') },
|
|
153
|
+
{ value: 'established', label: STATE_META.established.label },
|
|
154
|
+
{ value: 'aspirational', label: STATE_META.aspirational.label },
|
|
155
|
+
]
|
|
156
|
+
|
|
157
|
+
// Service-wide rollup, shown on the overview pane: how much of the written-down behaviour the
|
|
158
|
+
// service is actually known to honour.
|
|
159
|
+
const specStates = computed(() => summarizeSpecStates(modules.value))
|
|
160
|
+
// The selected group's rollup + the requirements the filter admits.
|
|
161
|
+
const groupStates = computed(() => summarizeRequirementStates(selectedGroup.value?.requirements))
|
|
162
|
+
const visibleRequirements = computed(() =>
|
|
163
|
+
filterRequirementsByState(selectedGroup.value?.requirements, stateFilter.value),
|
|
164
|
+
)
|
|
165
|
+
|
|
112
166
|
function reqCount(group: RequirementGroup): number {
|
|
113
167
|
return group.requirements?.length ?? 0
|
|
114
168
|
}
|
|
169
|
+
function stateMeta(item: RequirementItem) {
|
|
170
|
+
return STATE_META[requirementState(item)]
|
|
171
|
+
}
|
|
115
172
|
function priorityMeta(item: RequirementItem) {
|
|
116
173
|
return PRIORITY_META[item.priority] ?? PRIORITY_META.could
|
|
117
174
|
}
|
|
@@ -265,6 +322,21 @@ function kindLabel(item: RequirementItem): string {
|
|
|
265
322
|
{{ spec.summary }}
|
|
266
323
|
</p>
|
|
267
324
|
<p v-else class="mt-2 text-sm text-slate-500">{{ t('spec.noSummary') }}</p>
|
|
325
|
+
<!-- implementation-state rollup: how much of the written-down behaviour is observed
|
|
326
|
+
to hold, rather than merely agreed -->
|
|
327
|
+
<div
|
|
328
|
+
v-if="specStates.total > 0"
|
|
329
|
+
class="mt-4 flex flex-wrap items-center gap-2 text-xs"
|
|
330
|
+
data-testid="spec-state-rollup"
|
|
331
|
+
>
|
|
332
|
+
<UBadge color="success" variant="subtle" size="sm">
|
|
333
|
+
{{ t('spec.state.establishedCount', { count: specStates.established }) }}
|
|
334
|
+
</UBadge>
|
|
335
|
+
<UBadge color="neutral" variant="subtle" size="sm">
|
|
336
|
+
{{ t('spec.state.aspirationalCount', { count: specStates.aspirational }) }}
|
|
337
|
+
</UBadge>
|
|
338
|
+
<span class="text-slate-500">{{ t('spec.state.rollupHint') }}</span>
|
|
339
|
+
</div>
|
|
268
340
|
<p class="mt-4 text-xs text-slate-500">
|
|
269
341
|
{{
|
|
270
342
|
hasGherkin
|
|
@@ -303,15 +375,84 @@ function kindLabel(item: RequirementItem): string {
|
|
|
303
375
|
<div v-if="reqCount(selectedGroup) === 0" class="mt-4 text-sm text-slate-500">
|
|
304
376
|
{{ t('spec.noRequirements') }}
|
|
305
377
|
</div>
|
|
378
|
+
<!-- per-group implementation-state rollup + the filter over the two halves -->
|
|
379
|
+
<div
|
|
380
|
+
v-else
|
|
381
|
+
class="mt-4 flex flex-wrap items-center justify-between gap-2"
|
|
382
|
+
data-testid="spec-state-filter"
|
|
383
|
+
>
|
|
384
|
+
<div class="flex items-center gap-1.5 text-[11px] text-slate-500">
|
|
385
|
+
<UIcon :name="STATE_META.established.icon" class="h-3.5 w-3.5 text-emerald-400" />
|
|
386
|
+
{{
|
|
387
|
+
t('spec.state.groupRollup', {
|
|
388
|
+
established: groupStates.established,
|
|
389
|
+
total: groupStates.total,
|
|
390
|
+
})
|
|
391
|
+
}}
|
|
392
|
+
</div>
|
|
393
|
+
<div class="flex items-center rounded-lg border border-slate-700 p-0.5">
|
|
394
|
+
<UButton
|
|
395
|
+
v-for="option in STATE_FILTERS"
|
|
396
|
+
:key="option.value"
|
|
397
|
+
:color="stateFilter === option.value ? 'primary' : 'neutral'"
|
|
398
|
+
:variant="stateFilter === option.value ? 'soft' : 'ghost'"
|
|
399
|
+
size="xs"
|
|
400
|
+
:data-testid="`spec-state-filter-${option.value}`"
|
|
401
|
+
@click="
|
|
402
|
+
() => {
|
|
403
|
+
stateFilter = option.value
|
|
404
|
+
}
|
|
405
|
+
"
|
|
406
|
+
>
|
|
407
|
+
{{ option.label }}
|
|
408
|
+
</UButton>
|
|
409
|
+
</div>
|
|
410
|
+
</div>
|
|
411
|
+
<!-- the filter can legitimately empty a non-empty group; say so rather than
|
|
412
|
+
rendering a blank pane that reads like "no requirements". The filter is sticky
|
|
413
|
+
across groups, so the reader may have set it several groups ago — offer the way
|
|
414
|
+
back here rather than making them find the toggle again. -->
|
|
415
|
+
<div
|
|
416
|
+
v-if="reqCount(selectedGroup) > 0 && visibleRequirements.length === 0"
|
|
417
|
+
class="mt-4 flex flex-wrap items-center gap-2 text-sm text-slate-500"
|
|
418
|
+
data-testid="spec-state-filter-empty"
|
|
419
|
+
>
|
|
420
|
+
{{ t('spec.state.noneMatchFilter') }}
|
|
421
|
+
<UButton
|
|
422
|
+
color="primary"
|
|
423
|
+
variant="link"
|
|
424
|
+
size="xs"
|
|
425
|
+
class="p-0"
|
|
426
|
+
data-testid="spec-state-filter-reset"
|
|
427
|
+
@click="
|
|
428
|
+
() => {
|
|
429
|
+
stateFilter = 'all'
|
|
430
|
+
}
|
|
431
|
+
"
|
|
432
|
+
>
|
|
433
|
+
{{ t('spec.state.showAll') }}
|
|
434
|
+
</UButton>
|
|
435
|
+
</div>
|
|
306
436
|
<ul class="mt-4 space-y-4">
|
|
307
437
|
<li
|
|
308
|
-
v-for="req in
|
|
438
|
+
v-for="req in visibleRequirements"
|
|
309
439
|
:key="req.id"
|
|
310
440
|
class="rounded-lg border border-slate-800 bg-slate-900/60 p-4"
|
|
311
441
|
>
|
|
312
442
|
<div class="flex items-start justify-between gap-3">
|
|
313
443
|
<h3 class="text-sm font-semibold text-slate-100">{{ req.title }}</h3>
|
|
314
444
|
<div class="flex shrink-0 items-center gap-1.5">
|
|
445
|
+
<!-- implementation state: agreed vs observed to hold. The distinction the
|
|
446
|
+
build prompt and the tester act on, so a reader must see it too. -->
|
|
447
|
+
<UBadge
|
|
448
|
+
:color="stateMeta(req).chip as any"
|
|
449
|
+
variant="subtle"
|
|
450
|
+
size="sm"
|
|
451
|
+
:icon="stateMeta(req).icon"
|
|
452
|
+
:data-testid="`spec-requirement-state-${requirementState(req)}`"
|
|
453
|
+
>
|
|
454
|
+
{{ stateMeta(req).label }}
|
|
455
|
+
</UBadge>
|
|
315
456
|
<UBadge :color="priorityMeta(req).chip as any" variant="subtle" size="sm">
|
|
316
457
|
{{ priorityMeta(req).label }}
|
|
317
458
|
</UBadge>
|
package/app/types/domain.ts
CHANGED