/** * test/desktop/notify-policy.test.ts — B2: shouldNotify decision rules. */ import { describe, test, expect } from 'vitest' import { shouldNotify, type NotifyOptions } from '../../desktop/src/notify-policy.js' import type { SessionStatusSnapshot } from '../../desktop/src/types.js' function snapshot(overrides: Partial = {}): SessionStatusSnapshot { return { sessionId: 'sess-1234abcd-ef', claudeStatus: 'idle', pendingApproval: false, gate: null, title: undefined, ...overrides, } } const OPTS: NotifyOptions = { windowFocused: false, notifyOnApproval: true, notifyOnStatusChange: true, } describe('shouldNotify — focus suppression', () => { test('suppresses everything when the window is focused', () => { // Arrange const prev = snapshot() const next = snapshot({ pendingApproval: true, gate: 'plan', claudeStatus: 'waiting' }) // Act const decision = shouldNotify(prev, next, { ...OPTS, windowFocused: true }) // Assert expect(decision).toEqual({ notify: false, title: '', body: '' }) }) }) describe('shouldNotify — approval rising edge', () => { test('notifies on the false → true approval edge', () => { const prev = snapshot({ pendingApproval: false }) const next = snapshot({ pendingApproval: true, gate: 'tool' }) const decision = shouldNotify(prev, next, OPTS) expect(decision.notify).toBe(true) expect(decision.title).toBe('Approval needed') expect(decision.body).toContain('tool approval') expect(decision.body).not.toContain('tool tool') }) test('reads "tool approval" (never "tool tool") for a tool or null gate', () => { for (const gate of ['tool', null] as const) { const next = snapshot({ pendingApproval: true, gate }) const decision = shouldNotify(undefined, next, OPTS) expect(decision.body).toContain('tool approval') expect(decision.body).not.toContain('tool tool') } }) test('notifies when there is no previous snapshot and approval is pending', () => { const next = snapshot({ pendingApproval: true, gate: 'plan' }) const decision = shouldNotify(undefined, next, OPTS) expect(decision.notify).toBe(true) expect(decision.body).toContain('plan approval') }) test('does NOT notify when approval was already pending', () => { const prev = snapshot({ pendingApproval: true, gate: 'tool' }) const next = snapshot({ pendingApproval: true, gate: 'tool' }) const decision = shouldNotify(prev, next, OPTS) expect(decision.notify).toBe(false) }) test('is gated off by notifyOnApproval=false', () => { const prev = snapshot({ pendingApproval: false }) const next = snapshot({ pendingApproval: true, gate: 'tool' }) const decision = shouldNotify(prev, next, { ...OPTS, notifyOnApproval: false }) expect(decision.notify).toBe(false) }) test('uses the session title in the body when known', () => { const next = snapshot({ pendingApproval: true, gate: 'plan', title: 'web-terminal' }) const decision = shouldNotify(undefined, next, OPTS) expect(decision.body).toContain('web-terminal') }) test('falls back to a short id label when title is undefined', () => { const next = snapshot({ pendingApproval: true, gate: 'tool', title: undefined }) const decision = shouldNotify(undefined, next, OPTS) expect(decision.body).toContain('session sess-123') }) }) describe('shouldNotify — status change', () => { test('notifies on a transition to waiting', () => { const prev = snapshot({ claudeStatus: 'working' }) const next = snapshot({ claudeStatus: 'waiting' }) const decision = shouldNotify(prev, next, OPTS) expect(decision.notify).toBe(true) expect(decision.body).toContain('waiting') }) test('notifies on a transition to stuck', () => { const prev = snapshot({ claudeStatus: 'working' }) const next = snapshot({ claudeStatus: 'stuck' }) const decision = shouldNotify(prev, next, OPTS) expect(decision.notify).toBe(true) expect(decision.title).toBe('Session may be stuck') expect(decision.body).toContain('stuck') }) test('does NOT notify on a transition to working', () => { const prev = snapshot({ claudeStatus: 'idle' }) const next = snapshot({ claudeStatus: 'working' }) const decision = shouldNotify(prev, next, OPTS) expect(decision.notify).toBe(false) }) test('does NOT notify on a transition to idle', () => { const prev = snapshot({ claudeStatus: 'working' }) const next = snapshot({ claudeStatus: 'idle' }) const decision = shouldNotify(prev, next, OPTS) expect(decision.notify).toBe(false) }) test('does NOT notify when the status is unchanged', () => { const prev = snapshot({ claudeStatus: 'waiting' }) const next = snapshot({ claudeStatus: 'waiting' }) const decision = shouldNotify(prev, next, OPTS) expect(decision.notify).toBe(false) }) test('is gated off by notifyOnStatusChange=false', () => { const prev = snapshot({ claudeStatus: 'working' }) const next = snapshot({ claudeStatus: 'waiting' }) const decision = shouldNotify(prev, next, { ...OPTS, notifyOnStatusChange: false }) expect(decision.notify).toBe(false) }) }) describe('shouldNotify — priority', () => { test('approval edge takes priority over a concurrent status change', () => { // Arrange: both an approval rising edge AND a status change to waiting. const prev = snapshot({ pendingApproval: false, claudeStatus: 'working' }) const next = snapshot({ pendingApproval: true, gate: 'plan', claudeStatus: 'waiting' }) // Act const decision = shouldNotify(prev, next, OPTS) // Assert: it is the approval notification, not the status one. expect(decision.title).toBe('Approval needed') }) test('returns a silent decision when nothing is notable', () => { const prev = snapshot({ claudeStatus: 'working' }) const next = snapshot({ claudeStatus: 'unknown' }) const decision = shouldNotify(prev, next, OPTS) expect(decision).toEqual({ notify: false, title: '', body: '' }) }) })