feat(C244): aviso do índice curado, seed de figuras e enrollment

This commit is contained in:
Francisco Solla 2026-10-02 09:09:17 -03:00
parent e46ed57e2c
commit ee3dda757b
10 changed files with 686 additions and 1 deletions

View file

@ -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",

View file

@ -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 <slug> --name "Nome" (required to create) [--full-name]
* [--source "Origem"] --image <path> (repeatable) [--replace] [--out <dir>]
* [--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 <slug> [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> slug da figura (valor de ?pessoa=<slug>)
--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: <arquivo>)
--image <arquivo> 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 <dir> 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 <slug> --image <arquivo> --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))
})

View file

@ -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.',
]

View file

@ -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=<slug>` 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
}

View file

@ -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 <slug> [--name "Nome"] [--full-name "Nome completo"] [--source "Origem"] --image <arquivo> [--image <arquivo>]... [--replace] [--apply] [--out <dir>]`'
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
}

View file

@ -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',

View file

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

View file

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

View file

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

View file

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