diff --git a/package.json b/package.json index 01dd9ce1..7b89765b 100644 --- a/package.json +++ b/package.json @@ -40,8 +40,10 @@ "content:instagram:prune": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/prune-instagram-content.mjs", "archive:catalog": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/catalog-archive-photos.mjs", "faces:index": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/index-archive-faces.mjs", + "faces:enroll-figure": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/enroll-face-figure.mjs", "archive:publish": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/publish-archive-photos.mjs", "seed:face-consents": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-face-consents.mjs", + "seed:face-figures": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-face-figures.mjs", "db:seed:minimal": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-minimal.mjs", "campaign:staging:test-account": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/bootstrap-staging-test-account.mjs", "db:check:push-chain": "node scripts/check-push-chain.mjs", diff --git a/scripts/enroll-face-figure.mjs b/scripts/enroll-face-figure.mjs new file mode 100644 index 00000000..81543d5f --- /dev/null +++ b/scripts/enroll-face-figure.mjs @@ -0,0 +1,257 @@ +/** + * C244 — enrolls the reference descriptors of one curated public figure of the + * album's "Pessoa pública" facet. Each `--image` is a portrait (official/archive + * photo, curated by a human); the CLI prepares it exactly like the batch + * (`prepareFaceImage`) and detects faces with the SAME face-api engine the + * browser and `faces:index` use, so reference and archive vectors share one + * space. The reference is refused unless the image yields EXACTLY one face + * (crop the portrait); the descriptor never leaves the server, and no score is + * ever produced. + * + * Modes: + * pnpm faces:enroll-figure … plan/dry-run (default) — resolves the + * figure and the images; no engine, no write + * pnpm faces:enroll-figure … --apply detects and writes the references; + * requires FACE_FIGURE_CONFIRM=1 outside + * local dev and the declared TEQO_ENV + * + * Options: --figure --name "Nome" (required to create) [--full-name] + * [--source "Origem"] --image (repeatable) [--replace] [--out ] + * [--help]. The facet only lights up after the runbook's bust of the + * `archivePhotos` tag (the CLI runs outside Next). + */ +import { stat } from 'node:fs/promises' +import { basename, join } from 'node:path' + +import { getPayload } from 'payload' + +import { FACE_SEARCH_MODEL, readFaceVector } from '../src/lib/faceSearch.ts' + +import { + TEQO_ENV_DATABASE_BY_ENV, + assertEnvironmentDatabaseTarget, + assertWriteConfirm, + databaseTarget, + dieWithLabel, + loadCliEnv, + writeRepoFile, +} from './lib/cli.mjs' +import { createNodeFaceEngine } from './lib/faceEngine.mjs' +import { + FACE_FIGURE_DEFAULT_OUT_DIR, + faceFigureReportStamp, + formatEnrollFigureReport, + parseEnrollFigureArgs, +} from './lib/faceFigurePlan.mjs' + +loadCliEnv() + +const die = dieWithLabel('faces:enroll-figure') + +const WRITE_CONFIRM_FLAG = 'FACE_FIGURE_CONFIRM' + +const envLines = Object.entries(TEQO_ENV_DATABASE_BY_ENV) + .map(([environment, database]) => `${environment} (${database})`) + .join(' | ') + +const HELP = ` +Uso: pnpm faces:enroll-figure --figure [opções] + +Enrola os descritores de referência de uma figura pública curada do filtro +"Pessoa pública" do álbum (C244). Cada --image precisa ter EXATAMENTE um rosto +(retrato oficial/arquivo); o descriptor nunca sai do servidor. + +Opções: + --figure slug da figura (valor de ?pessoa=) + --name "Nome" nome público; obrigatório quando a figura ainda não existe + --full-name "…" proveniência (não aparece no site) + --source "Origem" origem das imagens (default: retrato: ) + --image retrato a enrolar (repetível; 1–3 por figura) + --replace substitui as referências existentes pelas novas + --apply grava (sem a flag é plano/dry-run, sem engine) + --out diretório dos recibos (default ${FACE_FIGURE_DEFAULT_OUT_DIR}) + --help esta ajuda + +Ambiente: + DATABASE_URL alvo (obrigatório em todos os modos) + +--apply em alvo não-local/produção exige ${WRITE_CONFIRM_FLAG}=1 e TEQO_ENV=${envLines} +casando o banco exato. Ao final, buste a tag archivePhotos: + POST /api/revalidate?tag=archivePhotos (x-revalidate-secret) +` + +const findFigure = (payload, slug) => + payload.find({ + collection: 'faceFigure', + where: { slug: { equals: slug } }, + depth: 0, + limit: 1, + overrideAccess: true, + }) + +const writeReport = async (options, report) => { + const stamp = faceFigureReportStamp(report.runAt) + const relativePath = join(options.out, 'reports', `face-figure-${stamp}.json`) + await writeRepoFile({ + label: 'faces:enroll-figure', + root: process.cwd(), + relativePath, + body: JSON.stringify(report, null, 2), + }) + return join(process.cwd(), relativePath) +} + +const ensureImagesExist = async (images) => { + for (const image of images) { + try { + const info = await stat(image) + if (!info.isFile()) throw new Error('não é um arquivo') + } catch (error) { + die(`imagem inacessível "${image}": ${error?.message || error}`) + } + } +} + +async function main() { + const options = parseEnrollFigureArgs(process.argv.slice(2)) + if (options.help) { + console.log(HELP) + process.exit(0) + } + if (!process.env.DATABASE_URL) die('DATABASE_URL não definida; recusando continuar.') + + const mode = options.apply ? 'apply' : 'plan' + const runAt = new Date().toISOString() + const startedAt = Date.now() + console.log(`[faces:enroll-figure] alvo: ${databaseTarget()} | modo: ${mode}`) + + if (options.apply) { + assertWriteConfirm({ + label: 'faces:enroll-figure', + flag: WRITE_CONFIRM_FLAG, + command: 'pnpm faces:enroll-figure --figure --image --apply', + }) + assertEnvironmentDatabaseTarget() + } + + await ensureImagesExist(options.images) + + const config = (await import('../src/payload.config.ts')).default + const payload = await getPayload({ config }) + + const existingResult = await findFigure(payload, options.figure) + const existing = existingResult.docs[0] ?? null + if (!existing && !options.name) { + die(`figura "${options.figure}" não existe; informe --name para criá-la.`) + } + + const referencesBefore = existing?.references?.length ?? 0 + const action = existing ? 'atualizar' : 'criar' + + if (!options.apply) { + const report = { + runAt, + mode, + target: databaseTarget(), + model: FACE_SEARCH_MODEL, + figure: options.figure, + action, + referencesBefore, + referencesAfter: referencesBefore, + images: options.images.map((image) => basename(image)), + durationMs: Date.now() - startedAt, + } + const reportPath = await writeReport(options, report) + for (const line of formatEnrollFigureReport(report)) console.log(line) + console.log(`\n[faces:enroll-figure] recibo JSON: ${reportPath}`) + console.log('[faces:enroll-figure] dry-run — use --apply para gravar.') + process.exit(0) + } + + const { prepareFaceImage } = await import('../src/utilities/faceIndex/faceDescriptorIndex.ts') + const engine = await createNodeFaceEngine(process.cwd()).catch((error) => { + die(error?.message || String(error)) + }) + + const added = [] + for (const image of options.images) { + const prepared = await prepareFaceImage(image) + const descriptors = await engine.detectPixels(prepared) + if (descriptors.length !== 1) { + die( + `"${image}": ${descriptors.length} rosto(s) detectado(s); a referência exige exatamente 1 (recorte o retrato e tente de novo).`, + ) + } + const vector = readFaceVector(descriptors[0]) + if (!vector) die(`"${image}": descriptor inválido.`) + added.push({ + vector, + model: FACE_SEARCH_MODEL, + source: options.source ?? `retrato: ${basename(image)}`, + addedAt: new Date().toISOString(), + }) + } + + const current = options.replace ? [] : (existing?.references ?? []) + const seen = new Set(current.map((reference) => JSON.stringify(reference.vector))) + const additions = [] + for (const reference of added) { + const key = JSON.stringify(reference.vector) + if (seen.has(key)) continue + seen.add(key) + additions.push(reference) + } + const references = [...current, ...additions] + + if (existing) { + await payload.update({ + collection: 'faceFigure', + id: existing.id, + data: { + ...(options.name ? { name: options.name } : {}), + ...(options.fullName ? { fullName: options.fullName } : {}), + references, + }, + depth: 0, + overrideAccess: true, + }) + } else { + await payload.create({ + collection: 'faceFigure', + data: { + name: options.name, + slug: options.figure, + ...(options.fullName ? { fullName: options.fullName } : {}), + active: true, + references, + }, + depth: 0, + overrideAccess: true, + }) + } + + const report = { + runAt, + mode, + target: databaseTarget(), + model: FACE_SEARCH_MODEL, + figure: options.figure, + action, + referencesBefore, + referencesAfter: references.length, + added: additions.length, + images: options.images.map((image) => basename(image)), + durationMs: Date.now() - startedAt, + } + const reportPath = await writeReport(options, report) + for (const line of formatEnrollFigureReport(report)) console.log(line) + console.log(`\n[faces:enroll-figure] recibo JSON: ${reportPath}`) + console.log( + '[faces:enroll-figure] OK — buste a tag archivePhotos para a faceta refletir as referências.', + ) + process.exit(0) +} + +main().catch((error) => { + die(error?.message || String(error)) +}) diff --git a/scripts/lib/faceConsentTexts.mjs b/scripts/lib/faceConsentTexts.mjs index b573fc99..221657e1 100644 --- a/scripts/lib/faceConsentTexts.mjs +++ b/scripts/lib/faceConsentTexts.mjs @@ -43,7 +43,8 @@ export const FACE_SEARCH_CONSENT_PARAGRAPHS = [ export const FACE_INDEX_CONSENT_PARAGRAPHS = [ 'Aviso público sobre o índice de fotos: as fotos públicas aprovadas do acervo podem conter rostos de várias pessoas. Para que cada pessoa encontre as próprias fotos pela busca por selfie, o sistema mantém um índice biométrico anônimo — vetores matemáticos extraídos dos rostos presentes nessas fotos, sem nome, sem vínculo com cadastro de pessoas e sem qualquer identificação de quem aparece.', - 'O índice serve apenas para localizar as fotos da própria pessoa que faz a busca. Ele nunca é usado para identificar, nomear ou listar terceiros, e nunca cruza os dados biométricos com contatos, lideranças ou apoiadores.', + 'O índice serve para localizar fotos: encontrar as fotos da própria pessoa que faz a busca e alimentar o filtro “Pessoa pública” do álbum. Esse filtro identifica exclusivamente as figuras públicas do catálogo curado e aprovado do mandato, com aval jurídico registrado; qualquer rosto fora desse catálogo continua anônimo no índice, sem nome e sem vínculo com cadastro, e nunca é usado para identificar, nomear ou listar terceiros, nem cruza os dados biométricos com contatos, lideranças ou apoiadores.', + 'O catálogo de figuras é curado e auditável: cada figura tem descritores de referência de retratos oficiais ou do arquivo do mandato, revisados por curadoria humana, e pode ser desativada a qualquer momento, deixando de aparecer no filtro.', 'Qualquer pessoa pode retirar seu rosto do índice a qualquer momento, sem custo: use a opção “Minha presença” na página da busca e siga as instruções. A retirada apaga do índice as referências biométricas correspondentes, e o rosto deixa de ser encontrado.', 'Para pedir a remoção de uma foto específica, use o canal indicado no álbum público. Fotos que não estejam aprovadas para o público não fazem parte do índice.', ] diff --git a/scripts/lib/faceFigureCatalogSeed.mjs b/scripts/lib/faceFigureCatalogSeed.mjs new file mode 100644 index 00000000..f8f6d6cf --- /dev/null +++ b/scripts/lib/faceFigureCatalogSeed.mjs @@ -0,0 +1,50 @@ +/** + * C244 — the initial curated catalog of public figures for the album's + * "Pessoa pública" facet: the six named figures of the owner's decision + * (2026-10-01) plus the S30 state-deputy roster already in the campaign + * catalog. Names are the public display labels; the slug is `slugify(name)` + * and is the `?pessoa=` URL value, so it must stay stable. The roster + * is shared with `src/lib/publicFigureCatalog.ts` (the ficha picker) — same + * source (`stateDeputyCatalog`), different purpose: here it is the face + * catalog, there the text "quem aparece na peça". + * + * The seed only creates the identity rows (no references): a figure only shows + * up in the facet after a reference is enrolled with `pnpm faces:enroll-figure` + * and recognized in an approved photo. Júlio Pinheiro is both a named figure + * and a roster entry — the dedupe by slug keeps the curated spelling. + * + * Pure planning data: no I/O, unit-testable. + */ +import { slugify } from '../../src/lib/slug.ts' +import { stateDeputyCatalog } from '../../src/lib/stateDeputyCatalog.ts' + +/** The named figures of the owner's catalog decision (2026-10-01). */ +export const FACE_FIGURE_NAMED_ENTRIES = Object.freeze([ + { name: 'Jorge Solla', fullName: 'Jorge Solla' }, + { name: 'Lula', fullName: 'Luiz Inácio Lula da Silva' }, + { name: 'Jerônimo', fullName: 'Jerônimo Rodrigues' }, + { name: 'Geraldinho', fullName: 'Geraldo Júnior' }, + { name: 'Wagner', fullName: 'Jaques Wagner' }, + { name: 'Júlio Pinheiro' }, +]) + +/** + * The seed rows, deduped by slug: `{ name, slug, fullName? }`, named figures + * first so a roster duplicate never overrides the curated spelling. + */ +export const faceFigureCatalogSeed = () => { + const entries = [] + const seen = new Set() + + const push = (name, fullName) => { + const slug = slugify(name) + if (!slug || seen.has(slug)) return + seen.add(slug) + entries.push(fullName ? { name, slug, fullName } : { name, slug }) + } + + for (const named of FACE_FIGURE_NAMED_ENTRIES) push(named.name, named.fullName) + for (const deputy of stateDeputyCatalog) push(deputy.name) + + return entries +} diff --git a/scripts/lib/faceFigurePlan.mjs b/scripts/lib/faceFigurePlan.mjs new file mode 100644 index 00000000..5e721954 --- /dev/null +++ b/scripts/lib/faceFigurePlan.mjs @@ -0,0 +1,90 @@ +/** + * C244 — pure planning/reporting of `pnpm faces:enroll-figure`: the argv + * parser, the slug contract and the human lines/receipt stamp. No I/O of its + * own, so the unit spec drives everything with fakes. + */ +import { SLUG_PATTERN } from '../../src/lib/slug.ts' + +const USAGE = + 'uso: `pnpm faces:enroll-figure --figure [--name "Nome"] [--full-name "Nome completo"] [--source "Origem"] --image [--image ]... [--replace] [--apply] [--out ]`' + +export const FACE_FIGURE_DEFAULT_OUT_DIR = 'data/face' + +/** + * `pnpm faces:enroll-figure` argv parser — pure, so the unit spec pins it (the + * CLI owns the exit and the help text). The default mode is the plan/dry-run. + * + * @param {string[]} [argv] + * @returns {{ + * figure: string | null, + * name: string | null, + * fullName: string | null, + * source: string | null, + * images: string[], + * replace: boolean, + * apply: boolean, + * out: string, + * help: boolean, + * }} + */ +export const parseEnrollFigureArgs = (argv = process.argv.slice(2)) => { + const options = { + figure: null, + name: null, + fullName: null, + source: null, + images: [], + replace: false, + apply: false, + out: FACE_FIGURE_DEFAULT_OUT_DIR, + help: false, + } + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index] + const value = () => { + const next = argv[++index] + if (next === undefined || next.startsWith('--')) { + throw new Error(`faltou valor para ${arg} — ${USAGE}.`) + } + return next + } + + if (arg === '--help' || arg === '-h') options.help = true + else if (arg === '--apply') options.apply = true + else if (arg === '--replace') options.replace = true + else if (arg === '--figure') options.figure = value() + else if (arg === '--name') options.name = value() + else if (arg === '--full-name') options.fullName = value() + else if (arg === '--source') options.source = value() + else if (arg === '--image') options.images.push(value()) + else if (arg === '--out') options.out = value() + else throw new Error(`argumento desconhecido: ${arg} — ${USAGE}.`) + } + + if (options.help) return options + if (!options.figure || !SLUG_PATTERN.test(options.figure)) { + throw new Error(`--figure deve ser um slug (ex.: lula) — ${USAGE}.`) + } + if (options.images.length === 0) { + throw new Error(`pelo menos um --image é obrigatório — ${USAGE}.`) + } + if (options.out.split(/[\\/]/).includes('..')) { + throw new Error('--out não pode escapar do diretório do repo (sem "..").') + } + return options +} + +/** Receipt filename stamp (same contract as `faces:index`). */ +export const faceFigureReportStamp = (runAt) => String(runAt).replace(/[:.]/g, '-') + +/** The human lines of one enrollment run (plan or apply). */ +export const formatEnrollFigureReport = (report) => { + const lines = [ + `[faces:enroll-figure] modo: ${report.mode} | figura: ${report.figure} | modelo: ${report.model}`, + `[faces:enroll-figure] figura ${report.action} | referências: ${report.referencesBefore} → ${report.referencesAfter}`, + `[faces:enroll-figure] imagens: ${report.images.join(', ')}`, + `[faces:enroll-figure] bust da tag: POST /api/revalidate?tag=archivePhotos`, + ] + return lines +} diff --git a/scripts/lib/test-affected-core.mjs b/scripts/lib/test-affected-core.mjs index bf11cdb4..12d49b1b 100644 --- a/scripts/lib/test-affected-core.mjs +++ b/scripts/lib/test-affected-core.mjs @@ -80,6 +80,8 @@ export const SCRIPTS_SPEC_PINNED = [ 'scripts/lib/dossieUnit.mjs', 'scripts/lib/e2e-affected-manifest.mjs', 'scripts/lib/faceConsentTexts.mjs', + 'scripts/lib/faceFigureCatalogSeed.mjs', + 'scripts/lib/faceFigurePlan.mjs', 'scripts/lib/faceIndexPlan.mjs', 'scripts/lib/flickrApi.mjs', 'scripts/lib/flickrPlan.mjs', diff --git a/scripts/seed-face-figures.mjs b/scripts/seed-face-figures.mjs new file mode 100644 index 00000000..d9974173 --- /dev/null +++ b/scripts/seed-face-figures.mjs @@ -0,0 +1,133 @@ +/** + * C244 — seeds the initial curated catalog of public figures (`faceFigure`) + * declared in `scripts/lib/faceFigureCatalogSeed.mjs`: the six named figures of + * the owner's decision plus the state-deputy roster, WITHOUT reference + * descriptors. A seeded figure never appears in the album facet until + * `pnpm faces:enroll-figure` enrolls its references and the face index + * recognizes it. + * + * Idempotent by slug: creates only the missing rows and never updates an + * existing one (curation edits stay untouched). + * + * Modes: + * pnpm seed:face-figures plan/dry-run (default) — shows the state + * pnpm seed:face-figures --apply creates the missing rows; requires + * FACE_FIGURE_SEED_CONFIRM=1 outside local + * dev and the declared TEQO_ENV matching + * the exact database + */ +import { getPayload } from 'payload' + +import { + TEQO_ENV_DATABASE_BY_ENV, + assertEnvironmentDatabaseTarget, + assertWriteConfirm, + databaseTarget, + dieWithLabel, + loadCliEnv, +} from './lib/cli.mjs' +import { faceFigureCatalogSeed } from './lib/faceFigureCatalogSeed.mjs' + +loadCliEnv() + +const die = dieWithLabel('seed:face-figures') +const WRITE_CONFIRM_FLAG = 'FACE_FIGURE_SEED_CONFIRM' + +const envLines = Object.entries(TEQO_ENV_DATABASE_BY_ENV) + .map(([environment, database]) => `${environment} (${database})`) + .join(' | ') + +const HELP = ` +Uso: pnpm seed:face-figures [opções] + +Cria as figuras públicas iniciais do filtro "Pessoa pública" do álbum (C244), +sem descritores de referência. Nome exibido e slug vêm do catálogo curado; o +enrollment das referências é feito pelo comando faces:enroll-figure. + +Modos: + (sem flag) plano/dry-run — estado atual; sem escrita + --apply cria as figuras ausentes (exige ${WRITE_CONFIRM_FLAG}=1 fora do dev local) + +Ambiente: + DATABASE_URL alvo (obrigatório em todos os modos) + +--apply em alvo não-local/produção exige ${WRITE_CONFIRM_FLAG}=1 e TEQO_ENV=${envLines} +casando o banco exato. O plano é read-only. +` + +async function main() { + const argv = process.argv.slice(2) + if (argv.includes('--help') || argv.includes('-h')) { + console.log(HELP) + process.exit(0) + } + const unknown = argv.find((arg) => arg !== '--apply') + if (unknown) die(`argumento desconhecido: ${unknown} — uso: pnpm seed:face-figures [--apply].`) + + const apply = argv.includes('--apply') + if (!process.env.DATABASE_URL) die('DATABASE_URL não definida; recusando continuar.') + + console.log(`[seed:face-figures] alvo: ${databaseTarget()} | modo: ${apply ? 'apply' : 'plan'}`) + + if (apply) { + assertWriteConfirm({ + label: 'seed:face-figures', + flag: WRITE_CONFIRM_FLAG, + command: 'pnpm seed:face-figures --apply', + }) + assertEnvironmentDatabaseTarget() + } + + const config = (await import('../src/payload.config.ts')).default + const payload = await getPayload({ config }) + + const entries = faceFigureCatalogSeed() + let created = 0 + let existingCount = 0 + + for (const entry of entries) { + const found = await payload.find({ + collection: 'faceFigure', + where: { slug: { equals: entry.slug } }, + depth: 0, + limit: 1, + overrideAccess: true, + }) + + if (found.docs[0]) { + existingCount += 1 + continue + } + + if (!apply) continue + await payload.create({ + collection: 'faceFigure', + data: { + name: entry.name, + slug: entry.slug, + ...(entry.fullName ? { fullName: entry.fullName } : {}), + active: true, + }, + depth: 0, + overrideAccess: true, + }) + created += 1 + } + + console.log( + `[seed:face-figures] catálogo: ${entries.length} figura(s) | existentes: ${existingCount} | ${apply ? `criadas: ${created}` : `ausentes: ${entries.length - existingCount}`}`, + ) + + if (!apply) { + console.log('\n[seed:face-figures] dry-run — use --apply para criar as ausentes.') + process.exit(0) + } + console.log( + '\n[seed:face-figures] OK — catálogo inicial garantido; enrole as referências com pnpm faces:enroll-figure.', + ) + process.exit(0) +} + +main().catch((error) => { + die(error?.message || String(error)) +}) diff --git a/tests/unit/faceConsentTexts.unit.spec.ts b/tests/unit/faceConsentTexts.unit.spec.ts index bcced7dd..cd44a8e9 100644 --- a/tests/unit/faceConsentTexts.unit.spec.ts +++ b/tests/unit/faceConsentTexts.unit.spec.ts @@ -57,4 +57,12 @@ describe('face consent texts', () => { expect(text).toMatch(/Minha presença/) expect(text).toMatch(/remoção de uma foto/i) }) + + it('declares the curated public-figure filter and its limits (C244)', () => { + const text = flatten(FACE_INDEX_CONSENT_TEXT) + expect(text).toMatch(/catálogo curado/i) + expect(text).toMatch(/aval jurídico/i) + expect(text).toMatch(/fora desse catálogo continua anônimo/i) + expect(text).toMatch(/nunca é usado para identificar, nomear ou listar terceiros/i) + }) }) diff --git a/tests/unit/faceFigureCatalogSeed.unit.spec.ts b/tests/unit/faceFigureCatalogSeed.unit.spec.ts new file mode 100644 index 00000000..00bbba62 --- /dev/null +++ b/tests/unit/faceFigureCatalogSeed.unit.spec.ts @@ -0,0 +1,43 @@ +// @vitest-environment node + +import { describe, expect, it } from 'vitest' + +import { stateDeputyCatalog } from '@/lib/stateDeputyCatalog' +import { + FACE_FIGURE_NAMED_ENTRIES, + faceFigureCatalogSeed, +} from '../../scripts/lib/faceFigureCatalogSeed.mjs' + +// C244 — the initial catalog of the "Pessoa pública" facet: the six named +// figures plus the S30 roster, deduped by the public slug that the `?pessoa` +// URL uses. + +describe('faceFigureCatalogSeed', () => { + const entries = faceFigureCatalogSeed() + + it('contains the named figures of the owner decision with their display names', () => { + const bySlug = new Map(entries.map((entry) => [entry.slug, entry])) + + expect(bySlug.get('jorge-solla')?.name).toBe('Jorge Solla') + expect(bySlug.get('lula')).toMatchObject({ + name: 'Lula', + fullName: 'Luiz Inácio Lula da Silva', + }) + expect(bySlug.get('jeronimo')).toMatchObject({ name: 'Jerônimo' }) + expect(bySlug.get('geraldinho')).toMatchObject({ name: 'Geraldinho' }) + expect(bySlug.get('wagner')).toMatchObject({ name: 'Wagner' }) + expect(bySlug.get('julio-pinheiro')).toMatchObject({ name: 'Júlio Pinheiro' }) + + // Júlio Pinheiro is both a named figure and a roster entry: one dedupe. + expect(entries.length).toBe(FACE_FIGURE_NAMED_ENTRIES.length + stateDeputyCatalog.length - 1) + }) + + it('has unique, URL-safe slugs and non-empty names', () => { + const slugs = entries.map((entry) => entry.slug) + expect(new Set(slugs).size).toBe(slugs.length) + for (const entry of entries) { + expect(entry.slug).toMatch(/^[a-z0-9]+(?:-[a-z0-9]+)*$/) + expect(entry.name.trim().length).toBeGreaterThan(0) + } + }) +}) diff --git a/tests/unit/faceFigurePlan.unit.spec.ts b/tests/unit/faceFigurePlan.unit.spec.ts new file mode 100644 index 00000000..5ff8e1f0 --- /dev/null +++ b/tests/unit/faceFigurePlan.unit.spec.ts @@ -0,0 +1,99 @@ +// @vitest-environment node + +import { describe, expect, it } from 'vitest' + +import { + FACE_FIGURE_DEFAULT_OUT_DIR, + faceFigureReportStamp, + formatEnrollFigureReport, + parseEnrollFigureArgs, +} from '../../scripts/lib/faceFigurePlan.mjs' + +// C244 — the pure argv contract of `faces:enroll-figure`: the slug-shaped +// figure, at least one image, no path escape and the mode flags. + +describe('parseEnrollFigureArgs', () => { + it('parses the full enrollment with repeated images and the optional provenance', () => { + const parsed = parseEnrollFigureArgs([ + '--figure', + 'lula', + '--name', + 'Lula', + '--full-name', + 'Luiz Inácio Lula da Silva', + '--source', + 'retrato oficial 2023', + '--image', + 'a.jpg', + '--image', + 'b.jpg', + '--replace', + '--apply', + '--out', + 'data/face', + ]) + + expect(parsed).toEqual({ + figure: 'lula', + name: 'Lula', + fullName: 'Luiz Inácio Lula da Silva', + source: 'retrato oficial 2023', + images: ['a.jpg', 'b.jpg'], + replace: true, + apply: true, + out: 'data/face', + help: false, + }) + }) + + it('defaults to plan mode and to the receipt dir', () => { + const parsed = parseEnrollFigureArgs(['--figure', 'lula', '--image', 'a.jpg']) + expect(parsed.apply).toBe(false) + expect(parsed.replace).toBe(false) + expect(parsed.out).toBe(FACE_FIGURE_DEFAULT_OUT_DIR) + }) + + it('requires a slug figure and at least one image', () => { + expect(() => parseEnrollFigureArgs(['--image', 'a.jpg'])).toThrow(/--figure/) + expect(() => parseEnrollFigureArgs(['--figure', 'Lula', '--image', 'a.jpg'])).toThrow( + /--figure/, + ) + expect(() => parseEnrollFigureArgs(['--figure', 'lula'])).toThrow(/--image/) + }) + + it('refuses an unknown argument and a missing value', () => { + expect(() => parseEnrollFigureArgs(['--figure', 'lula', '--image', 'a.jpg', '--nope'])).toThrow( + /argumento desconhecido/, + ) + expect(() => parseEnrollFigureArgs(['--figure', 'lula', '--image'])).toThrow(/faltou valor/) + }) + + it('refuses an --out escaping the repo and lets --help skip the validations', () => { + expect(() => + parseEnrollFigureArgs(['--figure', 'lula', '--image', 'a.jpg', '--out', '../etc']), + ).toThrow(/não pode escapar/) + expect(parseEnrollFigureArgs(['--help']).help).toBe(true) + }) +}) + +describe('faceFigureReportStamp / formatEnrollFigureReport', () => { + it('makes a filename-safe stamp', () => { + expect(faceFigureReportStamp('2026-10-01T23:56:32.123Z')).toBe('2026-10-01T23-56-32-123Z') + }) + + it('reports the mode, the figure action, the references and the bust', () => { + const lines = formatEnrollFigureReport({ + mode: 'apply', + figure: 'lula', + model: 'face-api@1.7.15/faceRecognitionNet', + action: 'criar', + referencesBefore: 0, + referencesAfter: 2, + images: ['lula.jpg'], + }) + + expect(lines.join('\n')).toContain('modo: apply | figura: lula') + expect(lines.join('\n')).toContain('referências: 0 → 2') + expect(lines.join('\n')).toContain('/api/revalidate?tag=archivePhotos') + }) +})