Files
FabledCurator/frontend/test/placement.spec.js
T
bvandeusenandClaude Opus 5 2dd9b956d5
CI / lint (push) Successful in 2s
CI / extension-version (push) Successful in 2s
Build images / sign-extension (push) Successful in 4s
Build images / build-ml (push) Successful in 6s
Build images / build-agent (push) Successful in 6s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 32s
Build images / build-web (push) Successful in 1m6s
Build images / smoke-web (push) Skipped
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m17s
feat: File placement card — survey, plan per artist, apply, put back (4246, slice 3c)
The UI half of the reconciler, in Maintenance. Survey shows how many images
sit in the wrong artist's folder and which folders they are in; each artist
gets its own Plan button; each run can be reviewed, applied, and put back.

Deliberately NOT using useMaintenanceTask. That composable stashes a task id
in localStorage so a result survives navigate-away, which is the right answer
when the only record is a Celery result. Here the runs are database rows — so
a reload, another machine, or coming back tomorrow simply shows the same
state, because the state IS the row. The card polls the runs endpoint instead.

Copy avoids the vocabulary this work has been tripping over: "folder", "put
back", "in the wrong folder" rather than artist_id, revert and canonical. The
one thing the operator most needs to know — nothing here changes who an image
belongs to — is what the blurb says first.

Three things I had assumed and checked instead: MaintenanceTile lives in
common/ not settings/; there is no generic ConfirmDialog (BackupCard uses a
purpose-built modal), so this uses a plain v-dialog; and `loadArtistNames`
did not exist — it does now, mapping id to name so a run row reads "Conto"
rather than "#47". The name deliberately is not denormalised into the run: it
belongs to the artist and would go stale on a rename.

Also extracted `stubFetch` to frontend/test/stubFetch.js. The shape ledger
flagged it as a byte-identical duplicate across five specs and this would
have been the sixth; the others keep their copies until each is next touched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
2026-09-21 14:32:51 -04:00

112 lines
3.6 KiB
JavaScript

import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useCleanupStore } from '../src/stores/cleanup.js'
import { stubFetch } from './stubFetch.js'
describe('placement reconciler store (milestone #421)', () => {
beforeEach(() => setActivePinia(createPinia()))
afterEach(() => vi.restoreAllMocks())
it('loadLayout leaves the disk check off by default', async () => {
const s = useCleanupStore()
let seen = ''
stubFetch((url) => {
seen = url
return { status: 200, body: { total_rows: 10, misplaced_rows: 2, artists: [] } }
})
await s.loadLayout()
// One stat per misplaced row over NFS is the cost; it must be opt-in.
expect(seen).not.toContain('check_disk')
expect(s.layout.misplaced_rows).toBe(2)
})
it('loadLayout asks for the disk check when requested', async () => {
const s = useCleanupStore()
let seen = ''
stubFetch((url) => {
seen = url
return { status: 200, body: { total_rows: 0, misplaced_rows: 0, artists: [] } }
})
await s.loadLayout(true)
expect(seen).toContain('check_disk=1')
})
it('planPlacement scopes to an artist when given one', async () => {
const s = useCleanupStore()
let sent = null
stubFetch((url, init) => {
sent = JSON.parse(init.body)
return { status: 202, body: { status: 'dispatched' } }
})
await s.planPlacement(47)
expect(sent).toEqual({ artist_id: 47 })
})
it('planPlacement sends no scope for the whole library', async () => {
const s = useCleanupStore()
let sent = null
stubFetch((url, init) => {
sent = JSON.parse(init.body)
return { status: 202, body: { status: 'dispatched' } }
})
await s.planPlacement()
// Not `{artist_id: null}` — the endpoint rejects a non-integer, and an
// absent key is how "whole library" is spelled.
expect(sent).toEqual({})
})
it('loadPlacementRuns keeps the rows for the table', async () => {
const s = useCleanupStore()
stubFetch(() => ({
status: 200,
body: { runs: [{ id: 3, status: 'ready', planned_count: 12 }] },
}))
await s.loadPlacementRuns()
expect(s.placementRuns).toHaveLength(1)
expect(s.placementRuns[0].status).toBe('ready')
})
it('getPlacementRun carries the moves — it is the preview', async () => {
const s = useCleanupStore()
stubFetch(() => ({
status: 200,
body: {
id: 3, status: 'ready', planned_count: 1,
moves: [{ image_id: 9, from: '/images/Conto/x.png', to: '/images/conto/x.png' }],
},
}))
const run = await s.getPlacementRun(3)
expect(run.moves[0].from).toBe('/images/Conto/x.png')
expect(run.moves[0].to).toBe('/images/conto/x.png')
})
it('loadArtistNames maps id to name for the run rows', async () => {
const s = useCleanupStore()
stubFetch(() => ({
status: 200,
body: [{ id: 47, name: 'Conto', slug: 'conto' }],
}))
expect(await s.loadArtistNames()).toEqual({ 47: 'Conto' })
})
it('loadArtistNames survives an empty roster', async () => {
const s = useCleanupStore()
stubFetch(() => ({ status: 200, body: [] }))
expect(await s.loadArtistNames()).toEqual({})
})
it('applyPlacement and revertPlacement post to their own run', async () => {
const s = useCleanupStore()
const urls = []
stubFetch((url) => {
urls.push(url)
return { status: 202, body: { status: 'dispatched' } }
})
await s.applyPlacement(5)
await s.revertPlacement(5)
expect(urls[0]).toContain('/api/cleanup/placement/runs/5/apply')
expect(urls[1]).toContain('/api/cleanup/placement/runs/5/revert')
})
})