revert: remove the placement reconciler — it manufactured the problem it solved
CI / lint (push) Successful in 2s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 3s
Build images / build-agent (push) Successful in 6s
CI / frontend-build (push) Successful in 26s
CI / backend-lint-and-test (push) Successful in 33s
Build images / build-web (push) Successful in 59s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 1m55s
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m22s
CI / lint (push) Successful in 2s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 3s
Build images / build-agent (push) Successful in 6s
CI / frontend-build (push) Successful in 26s
CI / backend-lint-and-test (push) Successful in 33s
Build images / build-web (push) Successful in 59s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 1m55s
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m22s
Milestone #421 built a sweep that compared each image's `artist_id` to the name of the directory holding its file, and called every mismatch a misplaced image. It reported 33,789 of 63,605 as wrongly filed. That number described the comparison, not the library. What it actually was: 32,475 (97.1%) one artist's own folder, spelled differently — Telepurte/ vs telepurte/. Same artist, same art. 657 ( 2.0%) loose at the images root 328 ( 1.0%) in a folder named after a different artist And the 1% did not mean what the tool assumed either. `ImageProvenance` records the post and source every file was downloaded from — the authoritative answer, which the tool never consulted. Querying it for all 328: 144 provenance agrees with the record (move would be right) 87 provenance agrees with the FOLDER (the record is wrong; move wrong) 53 provenance names SEVERAL artists (no single correct folder) 41 no provenance at all 3 agrees with neither So the sweep would have misfiled or arbitrarily picked for ~41% of the only set it was really needed for. The system already knew where each file came from; the tool inferred it from a column and a directory name instead. Operator, 2026-09-21: *"the current system consistently records where items are and where they came from this is just complicating something works and doesn't need fixing."* Correct on both counts. Removed: the service, the tasks, the model and migration 0099's table, the /api/cleanup/layout and /placement/* endpoints, the Maintenance card and its store actions, and the tests. 0100 drops the table (rule #22 — no legacy). KEPT deliberately, per the operator: - `utils.paths.canonical_subdir` — new filesystem imports derive their directory from the artist's slug, matching what the downloader always did. Not part of this tool; removing it would be churn that fixes nothing. - The 327 files run 1 moved (InsoUwu/ -> insouwu/). Same artist either way, and the gallery renders them correctly. - Everything from #4223 (three-gate dedup, 256-bit pHash) and #4234 (backup credential exclusion). Those fixed problems that were actually reported. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
This commit is contained in:
@@ -1,111 +0,0 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -1,24 +0,0 @@
|
||||
import { vi } from 'vitest'
|
||||
|
||||
// The canonical fetch stub for store specs.
|
||||
//
|
||||
// `handler(url, init)` returns `{ status, body }`; body is JSON-encoded, and
|
||||
// `ok` is derived from the status so a store's error path can be exercised by
|
||||
// returning 4xx/5xx. Returns the vi.fn so a caller can assert on calls.
|
||||
//
|
||||
// Extracted 2026-09-21 from six specs carrying byte-identical copies
|
||||
// (adminStore, credentials, dbMaintenance, gallery, suggestions,
|
||||
// galleryRelatedStrip). Those still hold their own; migrate each the next
|
||||
// time it is touched rather than in one sweep.
|
||||
export 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)),
|
||||
}
|
||||
})
|
||||
return globalThis.fetch
|
||||
}
|
||||
Reference in New Issue
Block a user