teqo-backup/tests/unit/faceIndexPlan.unit.spec.ts
Francisco Solla cc4b344a3b feat(C242/C243): abre a busca por selfie a qualquer visitante (escopo B) + seção na homepage
O escopo A/C do gate (PR #1370) é superado por decisão do dono (2026-10-01, aval jurídico declarado): o índice vira anônimo (archivePhotoFace, foto + descritor 128-d, sem nome/vínculo), o enrollment por pessoa morre (faceSubject/faces:enroll removidos) e qualquer visitante encontra as próprias fotos aprovadas.

- faces:index persiste os descritores por foto (transação substitui as linhas + marker = modelo); desaprovar/remover/deletar foto apaga os descritores (hooks + FK cascade)
- POST /api/fotos/selfie casa o vetor da consulta contra o índice em memória (TTL 60 s) e devolve só a interseção com o read aprovado do C233, sem score/nome de terceiro; leave-index apaga os descritores que casarem
- Consentimentos seguem fail-closed: busca-selfie-fotos (consulta) e busca-selfie-indice (aviso público do índice), textos versionados em scripts/lib/faceConsentTexts.mjs e provisionados por pnpm seed:face-consents
- pnpm archive:publish aprova o acervo em lote (draft -> approved; removed nunca) com guardas e recibo
- C243: seção condicional SelfieSearchHomeSection (port do design hi-fi, cenas 390/1280) entre a Central e a newsletter; sem engine/captura na home e sem CTA morto quando fechada
- docs: planos C242/C243, AGENTS-public, runbook §C242, changelog

Provas: gate:fast 457 arquivos/5.055 testes; int full 109/1.075; e2e frontendFotosSelfie 9/9; knip verde.
2026-10-01 12:29:04 -03:00

129 lines
4.3 KiB
TypeScript

// @vitest-environment node
import { describe, expect, it } from 'vitest'
import {
FACE_INDEX_DEFAULT_OUT_DIR,
faceIndexReportStamp,
formatFaceIndexInventory,
formatFaceIndexReport,
parseFaceIndexCliArgs,
summarizeFaceIndexInventory,
summarizeFaceIndexResults,
} from '../../scripts/lib/faceIndexPlan.mjs'
// C242 — the pure parser/summaries of `pnpm faces:index`: the honest staleness
// accounting (scope B: descriptors, not subjects) and the operator-facing lines.
describe('parseFaceIndexCliArgs', () => {
it('defaults to the dry-run and parses the modes', () => {
expect(parseFaceIndexCliArgs([])).toEqual({
apply: false,
verify: false,
limit: null,
refresh: false,
out: FACE_INDEX_DEFAULT_OUT_DIR,
help: false,
})
const apply = parseFaceIndexCliArgs(['--apply', '--limit', '5', '--refresh', '--out', 'x'])
expect(apply.apply).toBe(true)
expect(apply.limit).toBe(5)
expect(apply.refresh).toBe(true)
expect(apply.out).toBe('x')
expect(parseFaceIndexCliArgs(['--verify']).verify).toBe(true)
expect(parseFaceIndexCliArgs(['--help']).help).toBe(true)
})
it('fails closed on mutual exclusion, bad limit and escaping out', () => {
expect(() => parseFaceIndexCliArgs(['--apply', '--verify'])).toThrow(/mutuamente exclusivos/)
expect(() => parseFaceIndexCliArgs(['--limit', '0'])).toThrow(/--limit/)
expect(() => parseFaceIndexCliArgs(['--limit', 'x'])).toThrow(/--limit/)
expect(() => parseFaceIndexCliArgs(['--out', '../etc'])).toThrow(/--out/)
expect(() => parseFaceIndexCliArgs(['--refresh=1'])).toThrow(/argumento desconhecido/)
expect(() => parseFaceIndexCliArgs(['--limit'])).toThrow(/faltou valor/)
})
})
describe('summarizeFaceIndexResults', () => {
it('counts indexed photos, stored descriptors and failures with the stage', () => {
const summary = summarizeFaceIndexResults([
{ photoId: 1, status: 'indexed', descriptorCount: 3 },
{ photoId: 2, status: 'indexed', descriptorCount: 0 },
{ photoId: 3, status: 'indexed', descriptorCount: 1 },
{ photoId: 4, status: 'failed', stage: 'download', error: 'sem arquivo' },
])
expect(summary.indexed).toBe(3)
expect(summary.descriptors).toBe(4)
expect(summary.failed).toBe(1)
expect(summary.failures).toEqual([{ photoId: 4, stage: 'download', error: 'sem arquivo' }])
})
})
describe('summarizeFaceIndexInventory', () => {
it('derives the honest gap and the stored descriptor count', () => {
const inventory = summarizeFaceIndexInventory({
queue: { indexKey: 'face-api@1.7.15/faceRecognitionNet', totalApproved: 10, stale: 4 },
descriptors: 23,
})
expect(inventory).toEqual({
indexKey: 'face-api@1.7.15/faceRecognitionNet',
totalApproved: 10,
indexed: 6,
stale: 4,
descriptors: 23,
})
})
it('never reports a negative indexed count', () => {
const inventory = summarizeFaceIndexInventory({
queue: { indexKey: 'k', totalApproved: 2, stale: 5 },
descriptors: 0,
})
expect(inventory.indexed).toBe(0)
})
})
describe('formatting', () => {
it('stamps the receipt name and prints the inventory lines', () => {
expect(faceIndexReportStamp('2026-09-27T16:42:01.123Z')).toBe('2026-09-27T16-42-01-123Z')
const lines = formatFaceIndexInventory(
{
indexKey: 'k',
totalApproved: 10,
indexed: 6,
stale: 4,
descriptors: 23,
},
'face-api@1.7.15/faceRecognitionNet',
)
const text = lines.join('\n')
expect(text).toContain('fotos aprovadas: 10')
expect(text).toContain('desatualizadas (a processar): 4')
expect(text).toContain('rostos no índice (modelo atual): 23')
})
it('prints the apply summary with failures', () => {
const report = {
mode: 'apply',
target: 'host/db',
model: 'face-api@1.7.15/faceRecognitionNet',
queue: { items: 3, totalApproved: 10 },
summary: {
indexed: 2,
failed: 1,
descriptors: 5,
failures: [{ photoId: 4, stage: 'analyze', error: 'engine fora' }],
},
}
const text = formatFaceIndexReport(report).join('\n')
expect(text).toContain('modo: apply')
expect(text).toContain('processadas: 2 | rostos indexados: 5 | falharam: 1')
expect(text).toContain('✗ foto 4 [analyze]: engine fora')
})
})