@cat-factory/app 0.115.1 → 0.115.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.
@@ -1,6 +1,63 @@
1
1
  import { ref, computed, onMounted, onUnmounted } from 'vue'
2
2
  import type { PipelineStep } from '~/types/execution'
3
3
 
4
+ /**
5
+ * Whether a step is actively executing: it has started, hasn't finished, isn't
6
+ * parked on a human (`pausedAt`), and the run itself hasn't failed. A step in any
7
+ * of those states is not ticking — no spinner, no counting-up clock.
8
+ */
9
+ export function stepIsRunning(step: PipelineStep | null, runFailed: boolean): boolean {
10
+ return !!step?.startedAt && !step?.finishedAt && step?.pausedAt == null && !runFailed
11
+ }
12
+
13
+ /**
14
+ * Elapsed/total execution time in ms for a step at wall-clock `nowMs`, or null until
15
+ * the step has started. The clock freezes once the step stops working: at its finish,
16
+ * else at the run's failure time once the run has failed, else at the moment it parked
17
+ * on a human (`pausedAt`). Otherwise it is live, counting up to `nowMs`.
18
+ */
19
+ export function stepDurationMs(
20
+ step: PipelineStep | null,
21
+ nowMs: number,
22
+ runFailed: boolean,
23
+ failureAt: number | null | undefined,
24
+ ): number | null {
25
+ if (step?.startedAt == null) return null
26
+ const end =
27
+ step.finishedAt ?? (runFailed ? (failureAt ?? step.startedAt) : (step.pausedAt ?? nowMs))
28
+ return Math.max(0, end - step.startedAt)
29
+ }
30
+
31
+ /** Human-friendly elapsed label for a step at `nowMs`, or null until it has started. */
32
+ export function stepDurationLabel(
33
+ step: PipelineStep | null,
34
+ nowMs: number,
35
+ runFailed: boolean,
36
+ failureAt: number | null | undefined,
37
+ ): string | null {
38
+ const ms = stepDurationMs(step, nowMs, runFailed, failureAt)
39
+ return ms == null ? null : formatDuration(ms)
40
+ }
41
+
42
+ /**
43
+ * A shared 1s wall-clock tick for surfaces that render many steps' live durations
44
+ * at once (the pipeline timeline, the inspector run list). One interval drives every
45
+ * step's elapsed label instead of a per-step timer. Stays `0` until mounted so the
46
+ * first paint never reads a stale time.
47
+ */
48
+ export function useNowTick(intervalMs = 1000) {
49
+ const now = ref(0)
50
+ let timer: ReturnType<typeof setInterval> | undefined
51
+ onMounted(() => {
52
+ now.value = Date.now()
53
+ timer = setInterval(() => (now.value = Date.now()), intervalMs)
54
+ })
55
+ onUnmounted(() => {
56
+ if (timer) clearInterval(timer)
57
+ })
58
+ return now
59
+ }
60
+
4
61
  /**
5
62
  * Live elapsed-time clock for a single pipeline step. A 1s tick drives the
6
63
  * counting-up duration while the step is actively running; the clock freezes at
@@ -12,37 +69,16 @@ export function useStepTimer(opts: {
12
69
  runFailed: () => boolean
13
70
  failureAt: () => number | null | undefined
14
71
  }) {
15
- // A 1s tick so a still-running step's elapsed time counts up live while open.
16
- const nowTick = ref(0)
17
- let timer: ReturnType<typeof setInterval> | undefined
18
- onMounted(() => {
19
- nowTick.value = Date.now()
20
- timer = setInterval(() => (nowTick.value = Date.now()), 1000)
21
- })
22
- onUnmounted(() => {
23
- if (timer) clearInterval(timer)
24
- })
72
+ const nowTick = useNowTick()
25
73
 
26
74
  // A step that is finished, failed, or parked on a human is not actively
27
- // executing — no ticking clock or spinner. `pausedAt` is the "waiting on input"
28
- // freeze.
29
- const isRunning = computed(() => {
30
- const s = opts.step()
31
- return !!s?.startedAt && !s?.finishedAt && s?.pausedAt == null && !opts.runFailed()
32
- })
75
+ // executing — no ticking clock or spinner. `pausedAt` is the "waiting on input" freeze.
76
+ const isRunning = computed(() => stepIsRunning(opts.step(), opts.runFailed()))
33
77
 
34
78
  /** Elapsed/total execution time in ms — null until the step has started. */
35
- const durationMs = computed(() => {
36
- const s = opts.step()
37
- if (s?.startedAt == null) return null
38
- // Freeze the clock once the step stops working: at its finish, else at the
39
- // failure time once the run has failed, else at the moment it parked on a
40
- // human (`pausedAt`). Otherwise it is live, so count up to the current tick.
41
- const end =
42
- s.finishedAt ??
43
- (opts.runFailed() ? (opts.failureAt() ?? s.startedAt) : (s.pausedAt ?? nowTick.value))
44
- return Math.max(0, end - s.startedAt)
45
- })
79
+ const durationMs = computed(() =>
80
+ stepDurationMs(opts.step(), nowTick.value, opts.runFailed(), opts.failureAt()),
81
+ )
46
82
 
47
83
  const durationLabel = computed(() =>
48
84
  durationMs.value == null ? null : formatDuration(durationMs.value),
@@ -51,7 +87,7 @@ export function useStepTimer(opts: {
51
87
  return { isRunning, durationMs, durationLabel }
52
88
  }
53
89
 
54
- function formatDuration(ms: number): string {
90
+ export function formatDuration(ms: number): string {
55
91
  const totalSec = Math.round(ms / 1000)
56
92
  if (totalSec < 60) return `${totalSec}s`
57
93
  const m = Math.floor(totalSec / 60)
@@ -0,0 +1,76 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest'
2
+ import { useConsensusStore } from '~/stores/consensus'
3
+ import { useWorkspaceStore } from '~/stores/workspace'
4
+ import type { ConsensusSession } from '~/types/consensus'
5
+
6
+ /** Minimal session factory — only the fields the store reconciles/reads. */
7
+ function session(over: Partial<ConsensusSession> = {}): ConsensusSession {
8
+ return {
9
+ id: 's1',
10
+ blockId: 'blk1',
11
+ executionId: null,
12
+ stepIndex: 0,
13
+ agentKind: 'architect',
14
+ strategy: 'panel',
15
+ status: 'complete',
16
+ participants: [],
17
+ rounds: [],
18
+ synthesis: null,
19
+ createdAt: 1,
20
+ updatedAt: 1,
21
+ ...over,
22
+ } as ConsensusSession
23
+ }
24
+
25
+ describe('consensus store — load vs live-push reconcile', () => {
26
+ beforeEach(() => {
27
+ useWorkspaceStore().workspaceId = 'ws1'
28
+ })
29
+
30
+ it('load stores the fetched session', async () => {
31
+ vi.stubGlobal('useApi', () => ({
32
+ getConsensusSession: () => Promise.resolve({ session: session() }),
33
+ }))
34
+ const store = useConsensusStore()
35
+ await store.load('blk1')
36
+ expect(store.sessionFor('blk1')?.id).toBe('s1')
37
+ })
38
+
39
+ it('a stale load never regresses a fresher live-pushed session', async () => {
40
+ // A live `consensus` push delivers the newest transcript; a `load` that started earlier
41
+ // resolves later with a staler snapshot. It must NOT overwrite the fresher one.
42
+ let resolveFetch!: (r: { session: ConsensusSession | null }) => void
43
+ const pending = new Promise<{ session: ConsensusSession | null }>((res) => {
44
+ resolveFetch = res
45
+ })
46
+ vi.stubGlobal('useApi', () => ({ getConsensusSession: () => pending }))
47
+ const store = useConsensusStore()
48
+
49
+ const load = store.load('blk1')
50
+ store.upsert(session({ updatedAt: 10, synthesis: 'fresh' }))
51
+ resolveFetch({ session: session({ updatedAt: 2, synthesis: 'stale' }) })
52
+ await load
53
+
54
+ expect(store.sessionFor('blk1')?.synthesis).toBe('fresh')
55
+ })
56
+
57
+ it('a load returning "none" never clobbers an existing live session', async () => {
58
+ vi.stubGlobal('useApi', () => ({
59
+ getConsensusSession: () => Promise.resolve({ session: null }),
60
+ }))
61
+ const store = useConsensusStore()
62
+ store.upsert(session({ synthesis: 'live' }))
63
+ await store.load('blk1')
64
+ expect(store.sessionFor('blk1')?.synthesis).toBe('live')
65
+ })
66
+
67
+ it('a load returning "none" records the fetched-empty state when nothing is cached', async () => {
68
+ vi.stubGlobal('useApi', () => ({
69
+ getConsensusSession: () => Promise.resolve({ session: null }),
70
+ }))
71
+ const store = useConsensusStore()
72
+ await store.load('blk1')
73
+ expect('blk1' in store.sessions).toBe(true)
74
+ expect(store.sessionFor('blk1')).toBeNull()
75
+ })
76
+ })
@@ -46,7 +46,17 @@ export const useConsensusStore = defineStore('consensus', () => {
46
46
  loading.value = new Set(loading.value).add(blockId)
47
47
  try {
48
48
  const { session } = await api.getConsensusSession(wsId, blockId)
49
- sessions.value = { ...sessions.value, [blockId]: session }
49
+ // Reconcile rather than blind-replace: a `load` resolving AFTER a fresher live
50
+ // `consensus` push (or after a newer concurrent load) must not regress the transcript —
51
+ // the out-of-order-overwrite hazard the CLAUDE.md live-push rules warn about. Keep
52
+ // whichever session is newer by `updatedAt` (any id), and never overwrite an existing
53
+ // (possibly live-pushed) session with a raced "none".
54
+ const existing = sessions.value[blockId]
55
+ if (session) {
56
+ if (!existing || session.updatedAt >= existing.updatedAt) store(session)
57
+ } else if (existing === undefined) {
58
+ sessions.value = { ...sessions.value, [blockId]: null }
59
+ }
50
60
  } catch {
51
61
  // Consensus off / no session — leave the cache as-is; the window shows its empty state.
52
62
  } finally {
@@ -0,0 +1,64 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest'
2
+ import { useDocInterviewStore } from '~/stores/docInterview'
3
+ import { useWorkspaceStore } from '~/stores/workspace'
4
+ import type { DocInterviewSession } from '~/types/domain'
5
+
6
+ /** Minimal session factory — only the fields the store reconciles/reads. */
7
+ function session(over: Partial<DocInterviewSession> = {}): DocInterviewSession {
8
+ return {
9
+ id: 'd1',
10
+ blockId: 'blk1',
11
+ status: 'awaiting_answers',
12
+ round: 1,
13
+ maxRounds: 3,
14
+ qa: [],
15
+ brief: null,
16
+ model: null,
17
+ createdAt: 1,
18
+ updatedAt: 1,
19
+ ...over,
20
+ } as DocInterviewSession
21
+ }
22
+
23
+ // docInterview already routes its `load` through `upsert`'s newest-wins (`updatedAt`) guard —
24
+ // these specs pin that so a future refactor can't reintroduce a blind-replace clobber.
25
+ describe('docInterview store — load vs live-push reconcile', () => {
26
+ beforeEach(() => {
27
+ useWorkspaceStore().workspaceId = 'ws1'
28
+ })
29
+
30
+ it('load stores the fetched session', async () => {
31
+ vi.stubGlobal('useApi', () => ({
32
+ getDocInterview: () => Promise.resolve(session()),
33
+ }))
34
+ const store = useDocInterviewStore()
35
+ await store.load('blk1')
36
+ expect(store.forBlock('blk1')?.id).toBe('d1')
37
+ })
38
+
39
+ it('a stale load never regresses a fresher live-pushed session', async () => {
40
+ let resolveFetch!: (r: DocInterviewSession | null) => void
41
+ const pending = new Promise<DocInterviewSession | null>((res) => {
42
+ resolveFetch = res
43
+ })
44
+ vi.stubGlobal('useApi', () => ({ getDocInterview: () => pending }))
45
+ const store = useDocInterviewStore()
46
+
47
+ const load = store.load('blk1')
48
+ store.upsert(session({ updatedAt: 10, round: 2 }))
49
+ resolveFetch(session({ updatedAt: 2, round: 1 }))
50
+ await load
51
+
52
+ expect(store.forBlock('blk1')?.round).toBe(2)
53
+ })
54
+
55
+ it('a load returning "none" leaves the cache untouched', async () => {
56
+ vi.stubGlobal('useApi', () => ({
57
+ getDocInterview: () => Promise.resolve(null),
58
+ }))
59
+ const store = useDocInterviewStore()
60
+ store.upsert(session({ round: 2 }))
61
+ await store.load('blk1')
62
+ expect(store.forBlock('blk1')?.round).toBe(2)
63
+ })
64
+ })
@@ -0,0 +1,134 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest'
2
+ import { useKaizenStore } from '~/stores/kaizen'
3
+ import { useWorkspaceStore } from '~/stores/workspace'
4
+ import type { KaizenGrading } from '~/types/domain'
5
+
6
+ /** Minimal grading factory — only the fields the store reconciles/reads. */
7
+ function grading(over: Partial<KaizenGrading> = {}): KaizenGrading {
8
+ return {
9
+ id: 'g1',
10
+ executionId: 'exec1',
11
+ blockId: 'blk1',
12
+ stepIndex: 0,
13
+ agentKind: 'coder',
14
+ model: 'm',
15
+ promptVersion: 1,
16
+ comboKey: 'coder|m|1',
17
+ status: 'complete',
18
+ grade: 5,
19
+ summary: '',
20
+ recommendations: [],
21
+ graderModel: null,
22
+ error: null,
23
+ createdAt: 1,
24
+ updatedAt: 1,
25
+ ...over,
26
+ } as KaizenGrading
27
+ }
28
+
29
+ describe('kaizen store — live-push clobber guards', () => {
30
+ beforeEach(() => {
31
+ useWorkspaceStore().workspaceId = 'ws1'
32
+ })
33
+
34
+ it('loadForExecution stores the fetched gradings', async () => {
35
+ vi.stubGlobal('useApi', () => ({
36
+ getKaizenForExecution: () => Promise.resolve({ gradings: [grading()] }),
37
+ }))
38
+ const store = useKaizenStore()
39
+ await store.loadForExecution('exec1')
40
+ expect(store.byExecution.exec1).toHaveLength(1)
41
+ })
42
+
43
+ it('a slower stale loadForExecution never clobbers a newer one (monotonic guard)', async () => {
44
+ // Two loads race for the same execution: the FIRST-issued resolves LAST with a stale list.
45
+ // Without the ticket guard its REPLACE would overwrite the fresher second result.
46
+ const deferred: Array<(r: { gradings: KaizenGrading[] }) => void> = []
47
+ vi.stubGlobal('useApi', () => ({
48
+ getKaizenForExecution: () =>
49
+ new Promise<{ gradings: KaizenGrading[] }>((res) => deferred.push(res)),
50
+ }))
51
+ const store = useKaizenStore()
52
+ const first = store.loadForExecution('exec1') // issued #1 (stale)
53
+ const second = store.loadForExecution('exec1') // issued #2 (fresh)
54
+
55
+ deferred[1]!({ gradings: [grading({ id: 'fresh' })] })
56
+ deferred[0]!({ gradings: [grading({ id: 'stale' })] })
57
+ await Promise.all([first, second])
58
+
59
+ expect(store.byExecution.exec1).toHaveLength(1)
60
+ expect(store.byExecution.exec1![0]!.id).toBe('fresh')
61
+ })
62
+
63
+ it('a grading pushed live mid-load survives the load (merge, not blind-replace)', async () => {
64
+ // A load is in flight (server response predates the newest grading); a live `upsert` lands
65
+ // its grading; then the load resolves. A blind replace would drop the live-only grading.
66
+ let resolveFetch!: (r: { gradings: KaizenGrading[] }) => void
67
+ const pending = new Promise<{ gradings: KaizenGrading[] }>((res) => {
68
+ resolveFetch = res
69
+ })
70
+ vi.stubGlobal('useApi', () => ({ getKaizenForExecution: () => pending }))
71
+ const store = useKaizenStore()
72
+
73
+ const load = store.loadForExecution('exec1')
74
+ // A live stream event arrives while the fetch is in flight.
75
+ store.upsert(grading({ id: 'live', stepIndex: 1 }))
76
+ // The load's (staler) response comes back with only the earlier grading.
77
+ resolveFetch({ gradings: [grading({ id: 'g1', stepIndex: 0 })] })
78
+ await load
79
+
80
+ const ids = store.byExecution.exec1!.map((g) => g.id).sort()
81
+ expect(ids).toEqual(['g1', 'live'])
82
+ })
83
+
84
+ it('a shared-id load keeps whichever updatedAt is newer', async () => {
85
+ // Stub before the store is created — it captures `useApi()` at instantiation.
86
+ vi.stubGlobal('useApi', () => ({
87
+ getKaizenForExecution: () =>
88
+ Promise.resolve({ gradings: [grading({ id: 'g1', updatedAt: 2, summary: 'stale' })] }),
89
+ }))
90
+ const store = useKaizenStore()
91
+ // Seed a fresher live grading, then a load returns a staler copy of the SAME id.
92
+ store.upsert(grading({ id: 'g1', updatedAt: 5, summary: 'live' }))
93
+ await store.loadForExecution('exec1')
94
+ expect(store.byExecution.exec1![0]!.summary).toBe('live')
95
+ })
96
+
97
+ it('loadOverview preserves a live-pushed grading in history (merge, newest-first)', async () => {
98
+ vi.stubGlobal('useApi', () => ({
99
+ getKaizenOverview: () =>
100
+ Promise.resolve({
101
+ gradings: [grading({ id: 'old', createdAt: 1, updatedAt: 1 })],
102
+ verified: [],
103
+ }),
104
+ }))
105
+ const store = useKaizenStore()
106
+ // A grading arrives live before the overview list is fetched.
107
+ store.upsert(grading({ id: 'live', createdAt: 9, updatedAt: 9 }))
108
+ await store.loadOverview()
109
+
110
+ const ids = store.history.map((g) => g.id)
111
+ expect(ids).toContain('live')
112
+ expect(ids).toContain('old')
113
+ // The live (newest) grading stays at the front of the newest-first list.
114
+ expect(ids[0]).toBe('live')
115
+ })
116
+
117
+ it('a slower stale loadOverview never clobbers a newer one', async () => {
118
+ const deferred: Array<(r: { gradings: KaizenGrading[]; verified: [] }) => void> = []
119
+ vi.stubGlobal('useApi', () => ({
120
+ getKaizenOverview: () =>
121
+ new Promise<{ gradings: KaizenGrading[]; verified: [] }>((res) => deferred.push(res)),
122
+ }))
123
+ const store = useKaizenStore()
124
+ const first = store.loadOverview() // stale
125
+ const second = store.loadOverview() // fresh
126
+
127
+ deferred[1]!({ gradings: [grading({ id: 'fresh' })], verified: [] })
128
+ deferred[0]!({ gradings: [grading({ id: 'stale' })], verified: [] })
129
+ await Promise.all([first, second])
130
+
131
+ expect(store.history).toHaveLength(1)
132
+ expect(store.history[0]!.id).toBe('fresh')
133
+ })
134
+ })
@@ -23,6 +23,36 @@ export const useKaizenStore = defineStore('kaizen', () => {
23
23
  /** 503 ⇒ the Kaizen feature isn't configured on this deployment. */
24
24
  const available = ref<boolean | null>(null)
25
25
 
26
+ // Monotonic load-ordering guard. Both loads REPLACE state that also arrives live over the
27
+ // stream (`upsert`), so a slower/staler fetch resolving AFTER a newer one — or after a live
28
+ // push — would clobber the fresher gradings (the CLAUDE.md live-push out-of-order hazard,
29
+ // the same one `stores/provisioningLogs.ts` guards). Each load takes a ticket; only the
30
+ // newest-issued one commits. NOT reactive — pure bookkeeping the UI never reads.
31
+ let loadTicket = 0
32
+ let latestOverviewLoad = 0
33
+ const latestExecLoad = new Map<string, number>()
34
+
35
+ /**
36
+ * Fold a freshly-loaded grading list into the live cache WITHOUT dropping live-only rows:
37
+ * a grading pushed via `upsert` while the load was in flight may not be in the server's
38
+ * response yet, and a blind replace would silently drop it. Loaded rows are authoritative
39
+ * for the ids they carry (keeping whichever `updatedAt` is greater on a shared id), and any
40
+ * live-only rows the response hasn't caught up to are preserved. Gradings are append/update-
41
+ * only (never deleted), so preserving an unmatched live row can't resurrect stale state.
42
+ * Returns the reconciled loaded rows and the surviving live-only rows separately so each
43
+ * caller can splice them in its own order (execution cache appends; screen history, which is
44
+ * newest-first, prepends).
45
+ */
46
+ function reconcileWithLive(loaded: KaizenGrading[], existing: KaizenGrading[]) {
47
+ const loadedIds = new Set(loaded.map((g) => g.id))
48
+ const reconciled = loaded.map((l) => {
49
+ const live = existing.find((e) => e.id === l.id)
50
+ return live && live.updatedAt > l.updatedAt ? live : l
51
+ })
52
+ const liveOnly = existing.filter((e) => !loadedIds.has(e.id))
53
+ return { reconciled, liveOnly }
54
+ }
55
+
26
56
  function gradingsFor(executionId: string): KaizenGrading[] {
27
57
  return byExecution.value[executionId] ?? []
28
58
  }
@@ -35,11 +65,18 @@ export const useKaizenStore = defineStore('kaizen', () => {
35
65
  async function loadOverview() {
36
66
  const ws = useWorkspaceStore()
37
67
  loadingOverview.value = true
68
+ const seq = ++loadTicket
69
+ latestOverviewLoad = seq
38
70
  try {
39
71
  const overview = await api.getKaizenOverview(ws.requireId())
40
- history.value = overview.gradings
41
- verified.value = overview.verified
42
72
  available.value = true
73
+ // A newer overview load superseded this one while it was in flight — discard the staler
74
+ // result so it can't clobber the fresher history (and any grading live-pushed since).
75
+ if (latestOverviewLoad !== seq) return
76
+ verified.value = overview.verified
77
+ // History is newest-first; live-pushed gradings are the newest, so prepend the survivors.
78
+ const { reconciled, liveOnly } = reconcileWithLive(overview.gradings, history.value)
79
+ history.value = [...liveOnly, ...reconciled]
43
80
  } catch (e) {
44
81
  if ((e as { statusCode?: number; status?: number })?.statusCode === 503)
45
82
  available.value = false
@@ -52,10 +89,20 @@ export const useKaizenStore = defineStore('kaizen', () => {
52
89
  async function loadForExecution(executionId: string) {
53
90
  const ws = useWorkspaceStore()
54
91
  loadingExecution.value = new Set(loadingExecution.value).add(executionId)
92
+ const seq = ++loadTicket
93
+ latestExecLoad.set(executionId, seq)
55
94
  try {
56
95
  const { gradings } = await api.getKaizenForExecution(ws.requireId(), executionId)
57
- byExecution.value = { ...byExecution.value, [executionId]: gradings }
58
96
  available.value = true
97
+ // A newer load for this execution (or a live `upsert`) may have landed while this fetch
98
+ // was in flight — discard a superseded load, and merge rather than blind-replace so a
99
+ // grading pushed live mid-flight isn't dropped.
100
+ if (latestExecLoad.get(executionId) !== seq) return
101
+ const { reconciled, liveOnly } = reconcileWithLive(
102
+ gradings,
103
+ byExecution.value[executionId] ?? [],
104
+ )
105
+ byExecution.value = { ...byExecution.value, [executionId]: [...reconciled, ...liveOnly] }
59
106
  } catch (e) {
60
107
  if ((e as { statusCode?: number; status?: number })?.statusCode === 503)
61
108
  available.value = false
@@ -0,0 +1,94 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import type { SlackMemberMappingEntry } from '~/types/slack'
3
+ import {
4
+ type MemberRow,
5
+ emptyMemberRow,
6
+ hasHalfFilledRow,
7
+ toMemberEntries,
8
+ toMemberRow,
9
+ } from './slackMemberMapping'
10
+
11
+ const row = (partial: Partial<MemberRow> & { uid: string }): MemberRow => ({
12
+ userId: '',
13
+ slackUserId: '',
14
+ role: 'engineering',
15
+ ...partial,
16
+ })
17
+
18
+ describe('hasHalfFilledRow', () => {
19
+ it('is false for fully-filled rows', () => {
20
+ expect(hasHalfFilledRow([row({ uid: 'a', userId: 'usr_1', slackUserId: 'U1' })])).toBe(false)
21
+ })
22
+
23
+ it('is false for fully-empty rows (unused slots)', () => {
24
+ expect(hasHalfFilledRow([row({ uid: 'a' }), row({ uid: 'b' })])).toBe(false)
25
+ })
26
+
27
+ it('is true when only the user id is filled', () => {
28
+ expect(hasHalfFilledRow([row({ uid: 'a', userId: 'usr_1' })])).toBe(true)
29
+ })
30
+
31
+ it('is true when only the Slack id is filled', () => {
32
+ expect(hasHalfFilledRow([row({ uid: 'a', slackUserId: 'U1' })])).toBe(true)
33
+ })
34
+
35
+ it('treats whitespace-only ids as blank', () => {
36
+ expect(hasHalfFilledRow([row({ uid: 'a', userId: ' ', slackUserId: 'U1' })])).toBe(true)
37
+ })
38
+
39
+ it('flags a half-filled row among valid ones', () => {
40
+ expect(
41
+ hasHalfFilledRow([
42
+ row({ uid: 'a', userId: 'usr_1', slackUserId: 'U1' }),
43
+ row({ uid: 'b', userId: 'usr_2' }),
44
+ ]),
45
+ ).toBe(true)
46
+ })
47
+ })
48
+
49
+ describe('toMemberEntries', () => {
50
+ it('keeps fully-filled rows, drops empty slots, and strips uid', () => {
51
+ const rows: MemberRow[] = [
52
+ row({ uid: 'a', userId: 'usr_1', slackUserId: 'U1', role: 'product' }),
53
+ row({ uid: 'b' }), // empty slot — dropped
54
+ row({ uid: 'c', userId: 'usr_2', slackUserId: 'U2' }),
55
+ ]
56
+ expect(toMemberEntries(rows)).toEqual([
57
+ { userId: 'usr_1', slackUserId: 'U1', role: 'product' },
58
+ { userId: 'usr_2', slackUserId: 'U2', role: 'engineering' },
59
+ ])
60
+ })
61
+
62
+ it('does not leak the client-only uid onto the wire payload', () => {
63
+ const [entry] = toMemberEntries([row({ uid: 'a', userId: 'usr_1', slackUserId: 'U1' })])
64
+ expect(entry).not.toHaveProperty('uid')
65
+ })
66
+ })
67
+
68
+ describe('toMemberRow', () => {
69
+ it('stamps the uid and defaults a missing role', () => {
70
+ const entry: SlackMemberMappingEntry = { userId: 'usr_1', slackUserId: 'U1' }
71
+ expect(toMemberRow(entry, 'm1')).toEqual({
72
+ userId: 'usr_1',
73
+ slackUserId: 'U1',
74
+ role: 'engineering',
75
+ uid: 'm1',
76
+ })
77
+ })
78
+
79
+ it('preserves an explicit role', () => {
80
+ const entry: SlackMemberMappingEntry = { userId: 'usr_1', slackUserId: 'U1', role: 'product' }
81
+ expect(toMemberRow(entry, 'm2').role).toBe('product')
82
+ })
83
+ })
84
+
85
+ describe('emptyMemberRow', () => {
86
+ it('builds a blank engineering row with the given uid', () => {
87
+ expect(emptyMemberRow('m9')).toEqual({
88
+ uid: 'm9',
89
+ userId: '',
90
+ slackUserId: '',
91
+ role: 'engineering',
92
+ })
93
+ })
94
+ })
@@ -0,0 +1,46 @@
1
+ import type { SlackMemberMappingEntry } from '~/types/slack'
2
+
3
+ // Pure helpers for the Slack member-mapping editor (SlackPanel.vue). Extracted so
4
+ // the save-time integrity rules (UX-23) can be unit-tested without mounting the panel.
5
+
6
+ /**
7
+ * An editable member-map row: the wire entry plus a client-only stable `uid` so a
8
+ * mid-list delete keys the `v-model` by identity, not the array index (index keys
9
+ * silently rebound a neighbour's inputs — UX-23).
10
+ */
11
+ export type MemberRow = SlackMemberMappingEntry & { uid: string }
12
+
13
+ /**
14
+ * True when any row has exactly one of the two ids filled. A half-entered mapping
15
+ * used to be silently dropped on save (UX-23); the panel blocks the save instead so
16
+ * the user doesn't lose it. A fully-empty row is an unused slot, not half-filled.
17
+ */
18
+ export function hasHalfFilledRow(
19
+ rows: readonly Pick<MemberRow, 'userId' | 'slackUserId'>[],
20
+ ): boolean {
21
+ return rows.some((e) => Boolean(e.userId.trim()) !== Boolean(e.slackUserId.trim()))
22
+ }
23
+
24
+ /**
25
+ * The rows to persist: fully-filled only (empty slots dropped), with the client-only
26
+ * `uid` stripped from the wire payload.
27
+ */
28
+ export function toMemberEntries(rows: readonly MemberRow[]): SlackMemberMappingEntry[] {
29
+ return rows
30
+ .filter((e) => e.userId.trim() && e.slackUserId.trim())
31
+ .map(({ uid: _uid, ...entry }) => entry)
32
+ }
33
+
34
+ /**
35
+ * Wrap a stored wire entry as an editable row: stamp a stable `uid` and default the
36
+ * `role` (absent on older maps) so the initial load and the post-save reload produce
37
+ * identical rows rather than drifting on the default.
38
+ */
39
+ export function toMemberRow(entry: SlackMemberMappingEntry, uid: string): MemberRow {
40
+ return { role: 'engineering', ...entry, uid }
41
+ }
42
+
43
+ /** A fresh, empty editable row stamped with the given stable `uid`. */
44
+ export function emptyMemberRow(uid: string): MemberRow {
45
+ return { uid, userId: '', slackUserId: '', role: 'engineering' }
46
+ }