import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { setActivePinia, createPinia } from 'pinia' import { useSystemActivityStore } from '../src/stores/systemActivity.js' // Milestone 422 step 4. Covers the store half of the worker-lane dial — the // part that decides what the card can tell the operator. // // The distinction being protected: a change that was STORED but not pushed // (`applied: false`, because the lane is restarting) is not a failure, and a // REFUSED value (400) is. Collapsing those two into one message is how a // control stops being trustworthy — one invites waiting, the other invites // changing what you asked for. function stubFetch(handler) { globalThis.fetch = vi.fn(async (url, init) => { const { status, body } = handler(url, init) return { ok: status >= 200 && status < 300, status, statusText: String(status), text: async () => (body == null ? '' : JSON.stringify(body)), } }) } const LANES_BODY = { lanes: [ { name: 'worker', display_name: 'Worker', queues: ['default', 'import', 'thumbnail', 'download'], slots: 1, slots_cap: 4, ceiling: 8, enabled: true, memory_bound: false, live: { present: true, replicas: 1, pool: 1, active: 0, reserved: 3 }, queue_depth: 5, pending: 8, }, { name: 'ml', display_name: 'ML tagging', queues: ['ml'], slots: 0, slots_cap: 1, ceiling: 2, enabled: false, memory_bound: true, live: { present: false, replicas: 0, pool: null, active: 0, reserved: 0 }, queue_depth: null, pending: null, }, ], fetched_at: '2026-09-22T12:00:00Z', } describe('worker lanes store', () => { beforeEach(() => setActivePinia(createPinia())) afterEach(() => vi.restoreAllMocks()) it('loads the lanes', async () => { stubFetch(() => ({ status: 200, body: LANES_BODY })) const s = useSystemActivityStore() await s.loadLanes() expect(s.lanes.lanes.map((l) => l.name)).toEqual(['worker', 'ml']) }) it('a load failure records the error rather than throwing at the caller', async () => { // The card polls this every 3s. An unhandled rejection per tick would // drown the console and stop the other pollers in the same function. stubFetch(() => ({ status: 500, body: { error: 'boom' } })) const s = useSystemActivityStore() await expect(s.loadLanes()).resolves.toBeUndefined() expect(s.lastError).toBeTruthy() }) it('setLane posts only the fields it was given', async () => { // Partial update: the stepper sends slots without restating a cap it did // not touch. Sending the whole row back would make two operators editing // different fields clobber each other. const calls = [] stubFetch((url, init) => { calls.push({ url, init }) if (init?.method === 'POST') { return { status: 200, body: { name: 'worker', slots: 2, applied: true } } } return { status: 200, body: LANES_BODY } }) const s = useSystemActivityStore() await s.setLane('worker', { slots: 2 }) const post = calls.find((c) => c.init?.method === 'POST') expect(post.url).toContain('/api/system/workers/worker') expect(JSON.parse(post.init.body)).toEqual({ slots: 2 }) }) it('setLane refetches so the card shows the server truth, not the guess', async () => { // The reply is one lane; the card renders all of them plus live pool and // pending. Patching the local row from the reply would leave every other // column stale and eventually wrong. let gets = 0 stubFetch((url, init) => { if (init?.method === 'POST') return { status: 200, body: { applied: true } } gets += 1 return { status: 200, body: LANES_BODY } }) const s = useSystemActivityStore() await s.setLane('worker', { slots: 2 }) expect(gets).toBe(1) }) it('a stored-but-unapplied change comes back as applied:false, not an error', async () => { // The lane is restarting. The value IS saved and the reconcile will carry // it — so this must reach the card as information, not as a failure that // invites the operator to set it again. stubFetch((url, init) => { if (init?.method === 'POST') { return { status: 200, body: { applied: false, apply_error: 'lane is not running', slots: 2 }, } } return { status: 200, body: LANES_BODY } }) const s = useSystemActivityStore() const reply = await s.setLane('worker', { slots: 2 }) expect(reply.applied).toBe(false) expect(reply.apply_error).toContain('not running') }) it('a refused value throws so the card can show the reason', async () => { // Deliberately NOT swallowed. The detail is written to be read by a person // ("above what this container can hold"), and a control that silently does // nothing is worse than one that refuses out loud. stubFetch((url, init) => { if (init?.method === 'POST') { return { status: 400, body: { error: 'refused', detail: 'cap 10000 is above what this container can hold (2 for ML tagging)' }, } } return { status: 200, body: LANES_BODY } }) const s = useSystemActivityStore() // Assert the REASON is reachable, not merely that it threw. `toThrow()` // alone passes whether the card can read the sentence or not — which is // how the first version of the card shipped reading `e.detail` (always // undefined) and would have shown the operator the bare word "refused". const err = await s.setLane('ml', { slots_cap: 10000 }).catch((e) => e) expect(err.status).toBe(400) expect(err.body.detail).toContain('container can hold') }) })