Files
FabledCurator/frontend/test/workerLanes.spec.js
T
bvandeusenandClaude Opus 5.5 ff70f837d0
CI and images / lint (push) Successful in 2s
CI and images / extension-version (push) Successful in 2s
CI and images / frontend-build (push) Successful in 20s
CI and images / backend-lint-and-test (push) Successful in 30s
CI and images / integration (push) Successful in 2m18s
CI and images / sign-extension (push) Successful in 3s
CI and images / build-agent (push) Successful in 6s
CI and images / build-web (push) Successful in 1m44s
CI and images / smoke-web (push) Successful in 56s
CI and images / promote (push) Skipped
refactor: test row factories and the fetch stub have one copy each (3109)
tests/factories.py holds image_row/make_image/make_image_async/make_tag. The
17 byte-identical _img/_tag helpers (15 modules) now import them under their
old names, so no call site changed. frontend/test/support/stubFetch.js
replaces 15 copies that differed only in formatting. Copies whose bodies
differ (other defaults, other columns, a url-only stub) are left as they
are; folding those needs a look at each caller.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
2026-09-24 16:39:38 -04:00

224 lines
8.9 KiB
JavaScript

import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { laneStuckFor, useSystemActivityStore } from '../src/stores/systemActivity.js'
import { stubFetch } from './support/stubFetch.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.
//
// What is protected here since 2026-09-23: pressing the dial must not wait on
// a broker round trip. Operator: *"when the number is changed the change
// should be queued so that it isn't blocking of the webui or the system
// itself. we shouldn't have to wait for the validation live."* The endpoint
// answers once the cap is STORED, so the store patches its row from that
// reply and lets the 15s poll bring the live columns — it used to refetch,
// and GET /api/system/workers costs a celery inspect on an eleven-second
// budget.
//
// And still: a REFUSED value (400) is a failure and must reach the operator
// as one. A control that silently does nothing is worse than one that
// refuses out loud.
const LANES_BODY = {
lanes: [
{
name: 'worker', display_name: 'Worker',
queues: ['default', 'import', 'thumbnail', 'download'],
slots_cap: 1, 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_cap: 0, 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 System tab polls this every 15s. 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 () => {
// One field: the cap. How many workers are running is a measurement the
// sizing pass owns, so there is nothing else for the UI to send.
const calls = []
stubFetch((url, init) => {
calls.push({ url, init })
if (init?.method === 'POST') {
return { status: 200, body: { name: 'worker', slots_cap: 2, queued: true } }
}
return { status: 200, body: LANES_BODY }
})
const s = useSystemActivityStore()
await s.setLane('worker', { slots_cap: 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_cap: 2 })
})
it('setLane does not refetch — that refetch is what blocked the press', async () => {
// It refetched until 2026-09-23, so that the card showed server truth
// rather than a local guess. The cost was the operator's: GET
// /api/system/workers runs a celery inspect, so every press of `+` sat
// with the stepper disabled through a broker round trip the press did not
// need.
let gets = 0
stubFetch((url, init) => {
if (init?.method === 'POST') {
return { status: 200, body: { slots_cap: 2, ceiling: 8, enabled: true } }
}
gets += 1
return { status: 200, body: LANES_BODY }
})
const s = useSystemActivityStore()
await s.loadLanes()
gets = 0
await s.setLane('worker', { slots_cap: 2 })
expect(gets).toBe(0)
})
it('patches the row from the reply, so the new number shows at once', async () => {
stubFetch((url, init) => {
if (init?.method === 'POST') {
return {
status: 200,
body: { name: 'worker', slots_cap: 2, ceiling: 8, enabled: true, queued: true },
}
}
return { status: 200, body: LANES_BODY }
})
const s = useSystemActivityStore()
await s.loadLanes()
await s.setLane('worker', { slots_cap: 2 })
const worker = s.lanes.lanes.find((l) => l.name === 'worker')
expect(worker.slots_cap).toBe(2)
})
it('does not invent the live columns the reply cannot know', async () => {
// The reply is decided from the stored cap and the machine's ceiling
// alone — it never asked a worker anything. Pool, active and pending are
// MEASUREMENTS, and the poll a few seconds later is what carries them.
// Zeroing or guessing them here would make the table lie in the direction
// that reads as "the lane stopped".
stubFetch((url, init) => {
if (init?.method === 'POST') {
return { status: 200, body: { slots_cap: 2, ceiling: 8, enabled: true } }
}
return { status: 200, body: LANES_BODY }
})
const s = useSystemActivityStore()
await s.loadLanes()
const before = { ...s.lanes.lanes[0].live }
await s.setLane('worker', { slots_cap: 2 })
expect(s.lanes.lanes[0].live).toEqual(before)
expect(s.lanes.lanes[0].pending).toBe(8)
})
it('survives a reply arriving for a lane it has not loaded yet', async () => {
// First paint, or a lane added by a newer build. Patching must not be the
// thing that throws inside the click handler.
stubFetch(() => ({ status: 200, body: { slots_cap: 2 } }))
const s = useSystemActivityStore()
await expect(s.setLane('worker', { slots_cap: 2 })).resolves.toBeTruthy()
})
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')
})
})
// --- the long-running-task warning (step 7) ----------------------------------
//
// The operator asked for "a task runs for x concurrent time" to TRIGGER
// growth. It became a warning: extra slots do not make a running task finish
// sooner. What is pinned here is the AND — a warning that fires on half its
// condition is one people learn to ignore, and at that point it is worse than
// not having it.
describe('laneStuckFor', () => {
const lane = (over = {}) => ({
oldest_running_minutes: 40,
live: { present: true, pool: 4, active: 4 },
...over,
})
it('warns when every slot is busy and the oldest task is old', () => {
expect(laneStuckFor(lane())).toBe('40 minutes')
})
it('says nothing when the lane has a free slot', () => {
expect(laneStuckFor(lane({ live: { present: true, pool: 4, active: 3 } })))
.toBeNull()
})
it('says nothing for a saturated lane whose tasks are young', () => {
expect(laneStuckFor(lane({ oldest_running_minutes: 2 }))).toBeNull()
})
it('says nothing when nothing is running', () => {
expect(laneStuckFor(lane({ oldest_running_minutes: null }))).toBeNull()
})
it('says nothing about a lane that is not answering', () => {
// Unknown is not busy. A lane nothing answered for has no live reading to
// call saturated, and asserting one would be a verdict from an unswept
// read — the same distinction `present` exists for everywhere else here.
expect(laneStuckFor(lane({ live: { present: false, pool: null, active: 0 } })))
.toBeNull()
})
it('says nothing when the pool is zero', () => {
// A lane with no workers is not "fully busy at zero" — it is off.
expect(laneStuckFor(lane({ live: { present: true, pool: 0, active: 0 } })))
.toBeNull()
})
it('switches to hours once minutes stop being readable', () => {
expect(laneStuckFor(lane({ oldest_running_minutes: 195 }))).toBe('3 hours')
})
})