teqo-backup/tests/unit/archiveCatalogPlan.unit.spec.ts
Francisco Solla 930db3eb02 feat(C245): catalogar o acervo em produção por camadas — metadados primeiro
A catalogação do C232 nunca rodou em produção: catalog.* zerado nas 6.492
fotos publicadas e o engine de visão fora do ar. O pnpm archive:catalog
ganha --metadata-only: deriva município (gazetteer) e temas do texto já
ingerido, sem engine e sem mídia, grava catalog.metadataCheckedAt
(migration aditiva) e não marca catalogedAt — a fila da IA segue aberta.
Nunca escreve catalog.people (C244 assume a faceta por facial).

A passada de IA passa a carimbar os dois markers; a fila de metadados exclui
linha já catalogada pela IA (não rebaixa o source); o --verify relata as duas
camadas com exit pela selecionada; o --apply de metadados dispensa S3_* e
engine. Runbook §C245: metadados → revalidate archivePhotos → IA.

Closes #1417
2026-10-02 10:17:49 -03:00

201 lines
6.8 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import {
ARCHIVE_CATALOG_DEFAULT_OUT_DIR,
archiveCatalogLayerLabel,
archiveCatalogPendingForLayer,
formatArchiveCatalogInventory,
formatArchiveCatalogReport,
parseArchiveCatalogCliArgs,
summarizeArchiveCatalogInventory,
summarizeArchiveCatalogResults,
} from '../../scripts/lib/archiveCatalogPlan.mjs'
// C232 — the pure CLI planning/reporting of the archive cataloguing: the argv
// contract, the run summaries and the `--verify` inventory the receipt and the
// stdout lines are built from. C245 adds the layer selector and the two-layer
// inventory.
describe('parseArchiveCatalogCliArgs (C232)', () => {
it('defaults to the plan mode, the AI layer and the canonical receipt dir', () => {
expect(parseArchiveCatalogCliArgs([])).toEqual({
apply: false,
verify: false,
metadataOnly: false,
limit: null,
out: ARCHIVE_CATALOG_DEFAULT_OUT_DIR,
help: false,
})
})
it('parses the modes and options', () => {
expect(
parseArchiveCatalogCliArgs(['--apply', '--limit', '5', '--out', 'data/x']),
).toMatchObject({
apply: true,
limit: 5,
out: 'data/x',
})
expect(parseArchiveCatalogCliArgs(['--verify'])).toMatchObject({ verify: true })
expect(parseArchiveCatalogCliArgs(['--help'])).toMatchObject({ help: true })
})
it('parses the metadata-only layer selector in every mode', () => {
expect(parseArchiveCatalogCliArgs(['--metadata-only'])).toMatchObject({
metadataOnly: true,
apply: false,
verify: false,
})
expect(parseArchiveCatalogCliArgs(['--metadata-only', '--apply'])).toMatchObject({
metadataOnly: true,
apply: true,
})
expect(parseArchiveCatalogCliArgs(['--metadata-only', '--verify'])).toMatchObject({
metadataOnly: true,
verify: true,
})
})
it('refuses conflicting, unknown and unsafe input', () => {
expect(() => parseArchiveCatalogCliArgs(['--apply', '--verify'])).toThrow(/mutuamente/)
expect(() => parseArchiveCatalogCliArgs(['--limit', '0'])).toThrow(/--limit/)
expect(() => parseArchiveCatalogCliArgs(['--limit'])).toThrow(/faltou valor/)
expect(() => parseArchiveCatalogCliArgs(['--force'])).toThrow(/desconhecido/)
expect(() => parseArchiveCatalogCliArgs(['--out', '../fora'])).toThrow(/\.\./)
})
})
describe('summarizeArchiveCatalogResults (C232)', () => {
it('counts the outcomes, the sources and names every failure', () => {
const summary = summarizeArchiveCatalogResults([
{ flickrId: '1', status: 'cataloged', source: 'ai' },
{ flickrId: '2', status: 'cataloged', source: 'none' },
{ flickrId: '3', status: 'skipped' },
{ flickrId: '4', status: 'failed', stage: 'analyze', error: 'engine fora do ar' },
])
expect(summary).toEqual({
cataloged: 2,
skipped: 1,
failed: 1,
bySource: { ai: 1, metadata: 0, none: 1 },
failures: [{ flickrId: '4', stage: 'analyze', error: 'engine fora do ar' }],
})
})
})
describe('summarizeArchiveCatalogInventory (C232/C245)', () => {
it('separates both layers, counts the provenance and flags the missing file', () => {
const inventory = summarizeArchiveCatalogInventory([
{
flickrId: '1',
filename: 'a.jpg',
catalog: { catalogedAt: 'x', source: 'ai', scene: 'evento', municipality: 1 },
},
{ flickrId: '2', filename: 'b.jpg', catalog: { catalogedAt: 'x', source: 'metadata' } },
{ flickrId: '3', filename: 'c.jpg', catalog: {} },
{ flickrId: '4', filename: null, catalog: { catalogedAt: 'x', source: 'none' } },
{
flickrId: '5',
filename: 'e.jpg',
catalog: { metadataCheckedAt: 'x', source: 'metadata', municipality: 9 },
},
])
expect(inventory).toEqual({
total: 5,
ai: { cataloged: 3, pending: 2 },
metadata: { checked: 4, pending: 1 },
bySource: { ai: 1, metadata: 2, none: 1 },
withoutScene: 2,
withoutMunicipality: 2,
missingFilename: ['4'],
})
})
})
describe('archiveCatalogLayerLabel (C245)', () => {
it('labels the two layers in pt-BR', () => {
expect(archiveCatalogLayerLabel('ai')).toBe('IA')
expect(archiveCatalogLayerLabel('metadata')).toBe('metadados')
})
it('reads the pending gap of the selected layer only', () => {
const inventory = {
ai: { cataloged: 3, pending: 7 },
metadata: { checked: 6, pending: 4 },
}
expect(archiveCatalogPendingForLayer(inventory, 'metadata')).toBe(4)
expect(archiveCatalogPendingForLayer(inventory, 'ai')).toBe(7)
})
})
describe('report lines (C232/C245)', () => {
it('renders the plan line with the layer and the pending count', () => {
const lines = formatArchiveCatalogReport({
mode: 'plan',
layer: 'ai',
target: '127.0.0.1/teqo_staging',
engine: { host: '100.94.122.26', model: 'qwen2.5vl:7b', scope: 'local' },
pending: 12,
durationMs: 1500,
})
expect(lines[0]).toContain('100.94.122.26 (qwen2.5vl:7b, local)')
const text = lines.join('\n')
expect(text).toContain('camada: IA')
expect(text).toContain('pendentes na fila: 12')
})
it('renders the metadata-only plan without an engine line', () => {
const lines = formatArchiveCatalogReport({
mode: 'plan',
layer: 'metadata',
engine: null,
pending: 3,
durationMs: 10,
})
const text = lines.join('\n')
expect(text).toContain('camada: metadados')
expect(text).not.toContain('engine:')
})
it('renders the apply summary and every failure', () => {
const lines = formatArchiveCatalogReport({
mode: 'apply',
layer: 'metadata',
target: '127.0.0.1/teqo_staging',
durationMs: 2000,
summary: summarizeArchiveCatalogResults([
{ flickrId: '1', status: 'cataloged', source: 'ai' },
{ flickrId: '2', status: 'failed', stage: 'analyze', error: 'timeout' },
]),
})
const text = lines.join('\n')
expect(text).toContain('camada: metadados')
expect(text).toContain('processadas: 1')
expect(text).toContain('falharam: 1')
expect(text).toContain('IA: 1')
expect(text).toContain('2 (analyze): timeout')
})
it('renders the verify inventory with the pending gap of each layer', () => {
const lines = formatArchiveCatalogInventory({
total: 5,
ai: { cataloged: 3, pending: 2 },
metadata: { checked: 4, pending: 1 },
bySource: { ai: 1, metadata: 2, none: 1 },
withoutScene: 2,
withoutMunicipality: 2,
missingFilename: [],
})
const text = lines.join('\n')
expect(text).toContain('camada IA: catalogadas 3 · pendentes 2')
expect(text).toContain('camada metadados: verificadas 4 · pendentes 1')
expect(text).toContain('pendentes de metadados: 1')
expect(text).toContain('pendentes de IA: 2')
})
})