@cat-factory/app 0.280.1 → 0.280.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/app/components/binaryCandidates/BinaryCandidatesWindow.vue +142 -5
  2. package/app/components/board/AddTaskModal.vue +9 -0
  3. package/app/components/board/ReviewFrictionDialog.vue +35 -4
  4. package/app/components/brainstorm/BrainstormWindow.vue +19 -1
  5. package/app/components/common/ConfirmDialog.vue +26 -0
  6. package/app/components/docs/DocInterviewWindow.vue +40 -38
  7. package/app/components/followUp/FollowUpWindow.vue +32 -2
  8. package/app/components/forkDecision/ForkDecisionWindow.vue +44 -5
  9. package/app/components/gates/GateResultView.vue +14 -1
  10. package/app/components/humanTest/HumanTestWindow.vue +13 -1
  11. package/app/components/initiative/InitiativePlanDecision.vue +16 -3
  12. package/app/components/initiative/InitiativePlanReview.vue +16 -1
  13. package/app/components/initiative/InitiativePlanningWindow.vue +50 -52
  14. package/app/components/initiative/InitiativeTrackerWindow.vue +59 -1
  15. package/app/components/judge/JudgeResultView.vue +14 -1
  16. package/app/components/panels/ResultWindowDrafts.logic.spec.ts +233 -0
  17. package/app/components/panels/inspector/ServiceTestSecrets.vue +17 -6
  18. package/app/components/pipeline/PipelineHealthModal.vue +55 -16
  19. package/app/components/prReview/PrReviewWindow.vue +23 -4
  20. package/app/components/visualConfirm/VisualConfirmationWindow.vue +19 -1
  21. package/app/composables/useConfirm.spec.ts +62 -0
  22. package/app/composables/useConfirm.ts +6 -1
  23. package/app/composables/useInterviewDrafts.spec.ts +198 -0
  24. package/app/composables/useInterviewDrafts.ts +184 -0
  25. package/app/stores/binaryCandidates.ts +26 -3
  26. package/app/stores/ui/modals.ts +11 -0
  27. package/app/utils/binaryCandidates.spec.ts +45 -1
  28. package/app/utils/binaryCandidates.ts +33 -0
  29. package/i18n/locales/de.json +20 -2
  30. package/i18n/locales/en.json +20 -2
  31. package/i18n/locales/es.json +20 -2
  32. package/i18n/locales/fr.json +20 -2
  33. package/i18n/locales/he.json +20 -2
  34. package/i18n/locales/it.json +20 -2
  35. package/i18n/locales/ja.json +20 -2
  36. package/i18n/locales/pl.json +20 -2
  37. package/i18n/locales/tr.json +20 -2
  38. package/i18n/locales/uk.json +20 -2
  39. package/package.json +1 -1
@@ -3,6 +3,7 @@ import { computed, reactive, ref, watch } from 'vue'
3
3
  import type { Block } from '~/types/domain'
4
4
  import InspectorSection from '~/components/panels/inspector/InspectorSection.vue'
5
5
  import SecretInput from '~/components/common/SecretInput.vue'
6
+ import { uid } from '~/utils/catalog'
6
7
 
7
8
  // Per-service (frame) SENSITIVE test credentials: a genuinely secret token a Tester needs
8
9
  // to exercise a third-party integration (e.g. a Stripe API key). Unlike the non-sensitive
@@ -25,6 +26,13 @@ const { confirmAction, toastDone } = useConfirmAction()
25
26
  const busy = ref(false)
26
27
 
27
28
  interface DraftRow {
29
+ /**
30
+ * Client-only stable row identity (UX-94, the UX-23 convention). The `v-for` MUST key on this
31
+ * rather than the array index: an index key rebinds a deleted row's inputs onto its neighbour,
32
+ * and because the value field is masked that rebind is invisible — so removing a middle row
33
+ * could save one secret's value under the next row's key.
34
+ */
35
+ uid: string
28
36
  key: string
29
37
  description: string
30
38
  value: string
@@ -34,7 +42,7 @@ const draft = reactive<{ rows: DraftRow[] }>({ rows: [] })
34
42
  const configured = computed(() => store.entriesForBlock(props.block.id))
35
43
  const available = computed(() => store.available !== false)
36
44
 
37
- const blankRow = (): DraftRow => ({ key: '', description: '', value: '' })
45
+ const blankRow = (): DraftRow => ({ uid: uid('sec'), key: '', description: '', value: '' })
38
46
 
39
47
  // Load this frame's configured refs once, then (re)hydrate the editor from them. Runs again
40
48
  // after a save/clear (the store refs change) so the just-typed secret values don't linger in
@@ -46,7 +54,7 @@ watch(
46
54
  configured,
47
55
  (entries) => {
48
56
  draft.rows = entries.length
49
- ? entries.map((e) => ({ key: e.key, description: e.description, value: '' }))
57
+ ? entries.map((e) => ({ uid: uid('sec'), key: e.key, description: e.description, value: '' }))
50
58
  : [blankRow()]
51
59
  },
52
60
  { immediate: true },
@@ -87,8 +95,10 @@ const canSave = computed(
87
95
  function addRow() {
88
96
  draft.rows.push(blankRow())
89
97
  }
90
- function removeRow(index: number) {
91
- draft.rows.splice(index, 1)
98
+ /** Remove by row identity, so it can never be read against a stale index. */
99
+ function removeRow(rowUid: string) {
100
+ const at = draft.rows.findIndex((r) => r.uid === rowUid)
101
+ if (at >= 0) draft.rows.splice(at, 1)
92
102
  }
93
103
 
94
104
  async function save() {
@@ -166,9 +176,10 @@ async function clearAll() {
166
176
  </p>
167
177
 
168
178
  <div class="space-y-3">
179
+ <!-- Keyed by the row's own `uid`, never the index — see DraftRow.uid. -->
169
180
  <div
170
181
  v-for="(row, index) in draft.rows"
171
- :key="index"
182
+ :key="row.uid"
172
183
  class="space-y-2 rounded-md border border-slate-800 p-2.5"
173
184
  :data-testid="`test-secret-row-${index}`"
174
185
  >
@@ -198,7 +209,7 @@ async function clearAll() {
198
209
  class="mt-5 shrink-0"
199
210
  :aria-label="t('inspector.testSecrets.removeRow')"
200
211
  :data-testid="`test-secret-remove-${index}`"
201
- @click="removeRow(index)"
212
+ @click="removeRow(row.uid)"
202
213
  />
203
214
  </div>
204
215
 
@@ -19,6 +19,9 @@ const { invalid, outdated, newPipelines, retired, hasIssues } = usePipelineHealt
19
19
  // Dumping `error.message` instead would put untranslated backend prose in front of every non-English
20
20
  // user — on the one screen whose whole purpose is telling them what to do next.
21
21
  const { present } = usePipelineErrorToast()
22
+ // Deleting is the one irreversible action on this screen (see the note above `reseedAll`), so it
23
+ // routes through the shared destructive confirm rather than firing on first click.
24
+ const { confirmAction, toastDone } = useConfirmAction()
22
25
 
23
26
  const open = computed({
24
27
  get: () => ui.pipelineHealthOpen,
@@ -31,15 +34,29 @@ const open = computed({
31
34
  const busy = ref<Set<string>>(new Set())
32
35
  const isBusy = (id: string) => busy.value.has(id)
33
36
  const anyBusy = computed(() => busy.value.size > 0)
37
+ /**
38
+ * The pipeline whose confirm prompt is open, tracked apart from `busy` because a confirm is not work
39
+ * in flight: the row must not spin while the human reads the prompt.
40
+ *
41
+ * It still LOCKS every other control. `useConfirm` is a singleton, so a second Delete click
42
+ * supersedes the pending request and settles it `false`: the first pipeline was then silently not
43
+ * deleted, and nothing on screen said so.
44
+ */
45
+ const confirmingId = ref<string | null>(null)
46
+ /** True while any row is mid-action OR holding an open confirm. Every control here reads this. */
47
+ const locked = computed(() => anyBusy.value || confirmingId.value !== null)
34
48
 
35
49
  /** `failTitleKey` is an i18n KEY (not resolved copy) — `present` uses it only when the failure has
36
- * no mapped conflict reason of its own. */
50
+ * no mapped conflict reason of its own. Resolves `true` only when the action actually settled, so
51
+ * a caller can withhold its success toast on a refusal. */
37
52
  async function run(id: string, action: () => Promise<unknown>, failTitleKey: string) {
38
53
  busy.value = new Set(busy.value).add(id)
39
54
  try {
40
55
  await action()
56
+ return true
41
57
  } catch (e) {
42
58
  present(e, failTitleKey)
59
+ return false
43
60
  } finally {
44
61
  const next = new Set(busy.value)
45
62
  next.delete(id)
@@ -49,18 +66,39 @@ async function run(id: string, action: () => Promise<unknown>, failTitleKey: str
49
66
 
50
67
  const reseed = (id: string) =>
51
68
  run(id, () => pipelines.reseed(id), 'pipeline.health.toast.reseedFailed')
52
- const remove = (id: string) =>
53
- run(id, () => pipelines.removePipeline(id), 'pipeline.health.toast.deleteFailed')
69
+
70
+ /**
71
+ * Confirm, then delete. Both removal buttons land here (UX-93): a reseed restores what the catalog
72
+ * says, but a delete is the one irreversible action on this screen — a built-in the catalog no
73
+ * longer defines cannot be reseeded back — and it used to fire on first click, one stray Enter away
74
+ * from destroying a workspace's pipeline. The confirm NAMES the pipeline, because the two sections
75
+ * render several rows of near-identical buttons and "which one did I just delete" is unanswerable
76
+ * afterwards.
77
+ */
78
+ async function confirmRemove(pipeline: { id: string; name: string }, failTitleKey: string) {
79
+ // The entry guard is the authoritative half of the lock the buttons show: it holds for any future
80
+ // caller, and it is what makes "one confirmed click per pipeline" true rather than aspirational.
81
+ if (locked.value) return
82
+ confirmingId.value = pipeline.id
83
+ const confirmed = await confirmAction('remove', pipeline.name).finally(() => {
84
+ confirmingId.value = null
85
+ })
86
+ if (!confirmed) return
87
+ if (await run(pipeline.id, () => pipelines.removePipeline(pipeline.id), failTitleKey))
88
+ toastDone('remove', pipeline.name)
89
+ }
90
+
91
+ const remove = (pipeline: { id: string; name: string }) =>
92
+ confirmRemove(pipeline, 'pipeline.health.toast.deleteFailed')
54
93
  // Same call as `remove`, different failure copy: the retired section says "Remove" (the pipeline is
55
94
  // gone from the catalog), so a failure toast reading "could not DELETE" would name an action the
56
95
  // user was never offered. This is only the FALLBACK title — the likely failure here is a recurring
57
96
  // schedule still pointing at the pipeline, which arrives as a 409 the presenter words itself.
58
- const removeRetired = (id: string) =>
59
- run(id, () => pipelines.removePipeline(id), 'pipeline.health.toast.removeFailed')
97
+ const removeRetired = (pipeline: { id: string; name: string }) =>
98
+ confirmRemove(pipeline, 'pipeline.health.toast.removeFailed')
60
99
 
61
- // Removals are deliberately per-row with no bulk twin, unlike the reseeds below: a reseed restores
62
- // what the catalog says, while a delete is the one irreversible action on this screen (a built-in
63
- // the catalog no longer defines cannot be reseeded back). One click per pipeline is the point.
100
+ // Removals are deliberately per-row with no bulk twin, unlike the reseeds below: one confirmed
101
+ // click per pipeline is the point.
64
102
 
65
103
  /** Reseed every reseedable pipeline (new + outdated built-ins + invalid built-ins) in one go. */
66
104
  async function reseedAll() {
@@ -117,7 +155,7 @@ const reseedableCount = computed(
117
155
  variant="subtle"
118
156
  icon="i-lucide-plus"
119
157
  :loading="isBusy(p.id)"
120
- :disabled="anyBusy"
158
+ :disabled="locked"
121
159
  @click="reseed(p.id)"
122
160
  >
123
161
  {{ t('pipeline.health.add') }}
@@ -171,7 +209,7 @@ const reseedableCount = computed(
171
209
  variant="subtle"
172
210
  icon="i-lucide-rotate-ccw"
173
211
  :loading="isBusy(h.pipeline.id)"
174
- :disabled="anyBusy"
212
+ :disabled="locked"
175
213
  @click="reseed(h.pipeline.id)"
176
214
  >
177
215
  {{ t('pipeline.health.reseed') }}
@@ -183,8 +221,8 @@ const reseedableCount = computed(
183
221
  variant="subtle"
184
222
  icon="i-lucide-trash-2"
185
223
  :loading="isBusy(h.pipeline.id)"
186
- :disabled="anyBusy"
187
- @click="remove(h.pipeline.id)"
224
+ :disabled="locked"
225
+ @click="remove(h.pipeline)"
188
226
  >
189
227
  {{ t('pipeline.health.delete') }}
190
228
  </UButton>
@@ -226,8 +264,8 @@ const reseedableCount = computed(
226
264
  variant="subtle"
227
265
  icon="i-lucide-trash-2"
228
266
  :loading="isBusy(r.pipeline.id)"
229
- :disabled="anyBusy"
230
- @click="removeRetired(r.pipeline.id)"
267
+ :disabled="locked"
268
+ @click="removeRetired(r.pipeline)"
231
269
  >
232
270
  {{ t('pipeline.health.remove') }}
233
271
  </UButton>
@@ -264,7 +302,7 @@ const reseedableCount = computed(
264
302
  variant="subtle"
265
303
  icon="i-lucide-rotate-ccw"
266
304
  :loading="isBusy(h.pipeline.id)"
267
- :disabled="anyBusy"
305
+ :disabled="locked"
268
306
  @click="reseed(h.pipeline.id)"
269
307
  >
270
308
  {{ t('pipeline.health.reseed') }}
@@ -283,6 +321,7 @@ const reseedableCount = computed(
283
321
  variant="ghost"
284
322
  icon="i-lucide-rotate-ccw"
285
323
  :loading="anyBusy"
324
+ :disabled="locked"
286
325
  @click="reseedAll"
287
326
  >
288
327
  {{ t('pipeline.health.reseedAll', { count: reseedableCount }) }}
@@ -291,7 +330,7 @@ const reseedableCount = computed(
291
330
  <UButton
292
331
  color="neutral"
293
332
  variant="ghost"
294
- :disabled="anyBusy"
333
+ :disabled="locked"
295
334
  @click="ui.closePipelineHealth()"
296
335
  >
297
336
  {{ hasIssues ? t('pipeline.health.dismiss') : t('pipeline.health.done') }}
@@ -248,9 +248,16 @@ async function submitChallenge(id: string): Promise<void> {
248
248
  const inst = instanceId.value
249
249
  if (!inst || !canResolve.value) return
250
250
  const question = challengeText.value.trim()
251
- challengeForId.value = null
252
- challengeText.value = ''
253
- await prReview.challenge(inst, id, question || undefined).catch(() => {})
251
+ // Close the box only once the turn is actually recorded (UX-83): clearing first made a failed
252
+ // dispatch cost the typed concern, and left the guard below with nothing to protect.
253
+ await prReview
254
+ .challenge(inst, id, question || undefined)
255
+ .then(() => {
256
+ challengeForId.value = null
257
+ challengeText.value = ''
258
+ })
259
+ // The store records the message; the inline error strip renders it.
260
+ .catch(() => {})
254
261
  }
255
262
  async function onDismiss(id: string): Promise<void> {
256
263
  const inst = instanceId.value
@@ -258,6 +265,18 @@ async function onDismiss(id: string): Promise<void> {
258
265
  if (challengeForId.value === id) cancelChallenge()
259
266
  await prReview.dismiss(inst, id).catch(() => {})
260
267
  }
268
+
269
+ /**
270
+ * Confirm before discarding a drafted challenge (UX-79). The concern box is open against exactly
271
+ * one finding, its text is held here until Send, and this window closes on Escape and on a backdrop
272
+ * click. Auto-sending it instead would spend a reviewer turn on the user's behalf.
273
+ */
274
+ const { requestClose } = useUnsavedGuard({
275
+ open,
276
+ close: () => close(),
277
+ saving: () => working.value,
278
+ snapshot: () => challengeText.value.trim(),
279
+ })
261
280
  </script>
262
281
 
263
282
  <template>
@@ -269,7 +288,7 @@ async function onDismiss(id: string): Promise<void> {
269
288
  :subtitle="t('prReview.subtitle')"
270
289
  width="full"
271
290
  testid="pr-review-window"
272
- @close="close"
291
+ @close="requestClose"
273
292
  >
274
293
  <template v-if="state?.prUrl" #header-extras>
275
294
  <a
@@ -128,6 +128,24 @@ const hasFindings = computed(
128
128
  () => globalFindings.value.trim() !== '' || pairs.value.some((p) => perViewNotes[p.view]?.trim()),
129
129
  )
130
130
 
131
+ /**
132
+ * Confirm before discarding the drafted findings (UX-79). Both halves count: the per-view notes are
133
+ * anchored to a specific screenshot and cannot be reconstructed from memory, and the freeform box is
134
+ * the overall verdict. They are composed into one findings string only when Request fix is pressed,
135
+ * which resolves the gate and dispatches a fixer, so a stray Escape may not send them.
136
+ *
137
+ * The snapshot is exactly what Request fix WOULD send, read off `buildFindings` rather than off the
138
+ * note map. A recapture returns a different pair set and never prunes `perViewNotes`, so a note left
139
+ * behind against a view that is gone is unsendable, and reporting it here would prompt to discard
140
+ * something the button could not have submitted anyway.
141
+ */
142
+ const { requestClose } = useUnsavedGuard({
143
+ open,
144
+ close: () => close(),
145
+ saving: () => busy.value,
146
+ snapshot: () => buildFindings().structured,
147
+ })
148
+
131
149
  /** Compose the per-view notes + freeform text into the fixer's findings (and a structured
132
150
  * mirror, so a future structured-findings contract is a one-line swap). */
133
151
  function buildFindings(): { text: string; structured: { view?: string; note: string }[] } {
@@ -210,7 +228,7 @@ async function onFilePicked(e: Event) {
210
228
  :title="headerTitle"
211
229
  :subtitle="phase ? PHASE_LABEL[phase] : t('visualConfirm.subtitle')"
212
230
  width="5xl"
213
- @close="close"
231
+ @close="requestClose"
214
232
  >
215
233
  <div class="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-5 py-4">
216
234
  <div
@@ -0,0 +1,62 @@
1
+ import { beforeEach, describe, expect, it } from 'vitest'
2
+ import { useConfirm } from '~/composables/useConfirm'
3
+
4
+ // The confirm singleton's state lives at module scope so every caller and the one mounted
5
+ // `<ConfirmDialog />` share ONE request queue. That is also why each test starts by cancelling
6
+ // whatever the previous one left pending.
7
+ describe('useConfirm', () => {
8
+ beforeEach(() => {
9
+ useConfirm().cancel()
10
+ })
11
+
12
+ it('opens on request and resolves the choice the user made', async () => {
13
+ const { open, current, confirm, accept } = useConfirm()
14
+
15
+ const pending = confirm({ title: 'Delete it?' })
16
+ expect(open.value).toBe(true)
17
+ expect(current.value?.title).toBe('Delete it?')
18
+
19
+ accept()
20
+ await expect(pending).resolves.toBe(true)
21
+ expect(open.value).toBe(false)
22
+ })
23
+
24
+ // The dismissal path (backdrop, Escape, unmount): the dialog is CONTROLLED, so settling the
25
+ // promise without writing `open` left it on screen with no resolver behind it — a dialog whose
26
+ // buttons resolve nothing, on what is now the primary dismissal path of eleven result windows.
27
+ it('closes the dialog as well as settling the promise when it is dismissed', async () => {
28
+ const { open, confirm, dismissed } = useConfirm()
29
+
30
+ const pending = confirm({ title: 'Discard your changes?' })
31
+ dismissed()
32
+
33
+ await expect(pending).resolves.toBe(false)
34
+ expect(open.value).toBe(false)
35
+ })
36
+
37
+ // A second dismissal with nothing pending must stay a no-op rather than closing a request that
38
+ // arrived in between.
39
+ it('leaves a fresh request alone when a stale dismissal arrives', async () => {
40
+ const { open, confirm, dismissed, accept } = useConfirm()
41
+
42
+ dismissed()
43
+ const pending = confirm({ title: 'Remove the pipeline?' })
44
+ expect(open.value).toBe(true)
45
+
46
+ accept()
47
+ await expect(pending).resolves.toBe(true)
48
+ })
49
+
50
+ // Load-bearing for every surface that can raise two confirms: the superseded awaiter resolves
51
+ // `false`, so its caller treats the choice as declined rather than hanging forever.
52
+ it('settles a superseded request false instead of leaving it pending', async () => {
53
+ const { confirm, accept } = useConfirm()
54
+
55
+ const first = confirm({ title: 'Delete A?' })
56
+ const second = confirm({ title: 'Delete B?' })
57
+
58
+ await expect(first).resolves.toBe(false)
59
+ accept()
60
+ await expect(second).resolves.toBe(true)
61
+ })
62
+ })
@@ -55,8 +55,13 @@ export function useConfirm() {
55
55
 
56
56
  // Called by the dialog when `open` flips to false without an explicit accept/cancel
57
57
  // (backdrop, Escape, unmount). Any still-pending promise resolves `false`.
58
+ //
59
+ // It also has to write `open` itself, exactly as `cancel` does: the dialog is CONTROLLED (its
60
+ // `v-model:open` reads this ref), so a dismissal that only settled the promise left a visible
61
+ // modal behind with no pending resolver, whose buttons then resolve nothing.
58
62
  function dismissed(): void {
59
- if (resolver) settle(false)
63
+ open.value = false
64
+ settle(false)
60
65
  }
61
66
 
62
67
  return { open, current: readonly(current), confirm, accept, cancel, dismissed }
@@ -0,0 +1,198 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { nextTick, ref } from 'vue'
3
+ import { useInterviewDrafts } from '~/composables/useInterviewDrafts'
4
+
5
+ interface Q {
6
+ id?: string
7
+ key: string
8
+ answer?: string
9
+ status?: 'open' | 'dismissed'
10
+ }
11
+
12
+ const TITLES = { one: 'interview.saveFailed', many: 'interview.saveFailedCount' }
13
+
14
+ let present: ReturnType<typeof vi.fn>
15
+
16
+ beforeEach(() => {
17
+ present = vi.fn()
18
+ vi.stubGlobal('usePipelineErrorToast', () => ({ present }))
19
+ })
20
+
21
+ function harness(questions: Q[], write: (id: string, answer: string) => Promise<unknown>) {
22
+ const list = ref<Q[]>(questions)
23
+ const blockId = ref<string | null>('blk_1')
24
+ const seam = useInterviewDrafts<Q>({
25
+ blockId: () => blockId.value,
26
+ questions: () => list.value,
27
+ pending: () => list.value.filter((q) => !(q.answer ?? '').trim()),
28
+ write: (_block, questionId, answer) => write(questionId, answer),
29
+ writable: (q) => q.status !== 'dismissed',
30
+ failureTitleKeys: TITLES,
31
+ })
32
+ return { ...seam, list, blockId }
33
+ }
34
+
35
+ describe('useInterviewDrafts', () => {
36
+ it('seeds each draft from the entity and leaves an edit alone across rounds', async () => {
37
+ const { drafts, list } = harness([{ id: 'q1', key: 'q1', answer: 'recorded' }], async () => {})
38
+ expect(drafts.q1).toBe('recorded')
39
+
40
+ drafts.q1 = 'the human is mid-edit'
41
+ list.value = [
42
+ { id: 'q1', key: 'q1', answer: 'recorded' },
43
+ { id: 'q2', key: 'q2' },
44
+ ]
45
+ await nextTick()
46
+
47
+ expect(drafts.q1).toBe('the human is mid-edit')
48
+ expect(drafts.q2).toBe('')
49
+ })
50
+
51
+ it('writes only the drafts that changed, and never a question set aside', async () => {
52
+ const written: string[] = []
53
+ const { drafts, flushDrafts } = harness(
54
+ [
55
+ { id: 'q1', key: 'q1', answer: 'already this' },
56
+ { id: 'q2', key: 'q2' },
57
+ { id: 'q3', key: 'q3', status: 'dismissed' },
58
+ ],
59
+ async (id) => {
60
+ written.push(id)
61
+ },
62
+ )
63
+
64
+ drafts.q1 = 'already this'
65
+ drafts.q2 = 'a new answer'
66
+ drafts.q3 = 'a stale draft on a not-relevant question'
67
+ flushDrafts()
68
+ await vi.waitFor(() => expect(written).toEqual(['q2']))
69
+ expect(present).not.toHaveBeenCalled()
70
+ })
71
+
72
+ // The defect: a sequential loop that awaited straight through abandoned every answer after the
73
+ // first rejection, with the window already torn down and nothing left on screen to say so.
74
+ it('keeps flushing after a failed write, then reports how many were lost', async () => {
75
+ const attempted: string[] = []
76
+ const { drafts, flushDrafts } = harness(
77
+ [
78
+ { id: 'q1', key: 'q1' },
79
+ { id: 'q2', key: 'q2' },
80
+ { id: 'q3', key: 'q3' },
81
+ ],
82
+ async (id) => {
83
+ attempted.push(id)
84
+ if (id !== 'q2') throw new Error(`boom ${id}`)
85
+ },
86
+ )
87
+
88
+ drafts.q1 = 'one'
89
+ drafts.q2 = 'two'
90
+ drafts.q3 = 'three'
91
+ flushDrafts()
92
+
93
+ await vi.waitFor(() => expect(present).toHaveBeenCalled())
94
+ expect(attempted).toEqual(['q1', 'q2', 'q3'])
95
+ // The plural title, carrying the count, plus the FIRST cause so the toast's detail names a real
96
+ // failure rather than a synthesised summary.
97
+ expect(present).toHaveBeenCalledWith(expect.any(Error), TITLES.many, { count: 2 })
98
+ expect((present.mock.calls[0]![0] as Error).message).toBe('boom q1')
99
+ })
100
+
101
+ it('reports a single lost answer with the singular title', async () => {
102
+ const { drafts, flushDrafts } = harness([{ id: 'q1', key: 'q1' }], async () => {
103
+ throw new Error('nope')
104
+ })
105
+ drafts.q1 = 'one'
106
+ flushDrafts()
107
+
108
+ await vi.waitFor(() => expect(present).toHaveBeenCalled())
109
+ expect(present).toHaveBeenCalledWith(expect.any(Error), TITLES.one, { count: 1 })
110
+ })
111
+
112
+ it('reports a failed single save from the blur path', async () => {
113
+ const { drafts, saveAnswer } = harness([{ id: 'q1', key: 'q1' }], async () => {
114
+ throw new Error('nope')
115
+ })
116
+ drafts.q1 = 'one'
117
+ saveAnswer({ id: 'q1', key: 'q1' })
118
+
119
+ await vi.waitFor(() =>
120
+ expect(present).toHaveBeenCalledWith(expect.any(Error), TITLES.one, {
121
+ count: 1,
122
+ }),
123
+ )
124
+ })
125
+
126
+ // A missing answer may not be submitted as if it were there, and the window has to stay put so the
127
+ // same button is still on screen with the text still in its box.
128
+ it('withholds the action when a draft could not be written', async () => {
129
+ const action = vi.fn().mockResolvedValue(undefined)
130
+ const { drafts, flushThen } = harness([{ id: 'q1', key: 'q1' }], async () => {
131
+ throw new Error('nope')
132
+ })
133
+ drafts.q1 = 'one'
134
+
135
+ await flushThen(action, 'interview.continueFailed')
136
+
137
+ expect(action).not.toHaveBeenCalled()
138
+ expect(present).toHaveBeenCalledWith(expect.any(Error), TITLES.one, { count: 1 })
139
+ })
140
+
141
+ it('runs the action once every draft is written, and reports the action itself failing', async () => {
142
+ const written: string[] = []
143
+ const { drafts, flushThen } = harness([{ id: 'q1', key: 'q1' }], async (id) => {
144
+ written.push(id)
145
+ })
146
+ drafts.q1 = 'one'
147
+
148
+ const ok = vi.fn().mockResolvedValue(undefined)
149
+ await flushThen(ok, 'interview.continueFailed')
150
+ expect(written).toEqual(['q1'])
151
+ expect(ok).toHaveBeenCalledWith('blk_1')
152
+ expect(present).not.toHaveBeenCalled()
153
+
154
+ // The backing stores rethrow and Vue discards a click handler's promise, so the action's own
155
+ // failure is reported here or nowhere.
156
+ await flushThen(
157
+ vi.fn().mockRejectedValue(new Error('resume failed')),
158
+ 'interview.continueFailed',
159
+ )
160
+ expect(present).toHaveBeenCalledWith(expect.any(Error), 'interview.continueFailed')
161
+ })
162
+
163
+ it('writes nothing once the view has torn down and the block id is gone', async () => {
164
+ const written: string[] = []
165
+ const { drafts, flushDrafts, flushThen, blockId } = harness(
166
+ [{ id: 'q1', key: 'q1' }],
167
+ async (id) => {
168
+ written.push(id)
169
+ },
170
+ )
171
+ drafts.q1 = 'one'
172
+ blockId.value = null
173
+
174
+ flushDrafts()
175
+ const action = vi.fn()
176
+ await flushThen(action, 'interview.continueFailed')
177
+ await nextTick()
178
+
179
+ expect(written).toEqual([])
180
+ expect(action).not.toHaveBeenCalled()
181
+ })
182
+
183
+ // An exchange with no id cannot be addressed by the answer write at all. It must not hold the
184
+ // submit button hostage, since nothing the human types would ever clear it.
185
+ it('excludes an unaddressable question from the unanswered count', () => {
186
+ const { drafts, addressable, unanswered } = harness(
187
+ [{ id: 'q1', key: 'q1' }, { key: 'q-1' }],
188
+ async () => {},
189
+ )
190
+
191
+ expect(addressable({ id: 'q1', key: 'q1' })).toBe(true)
192
+ expect(addressable({ key: 'q-1' })).toBe(false)
193
+ expect(unanswered.value).toBe(1)
194
+
195
+ drafts.q1 = 'answered'
196
+ expect(unanswered.value).toBe(0)
197
+ })
198
+ })