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') }) })