teqo-backup/tests/unit/reelShotList.unit.spec.ts

177 lines
6.2 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import {
captureScenes,
graphicScenes,
normalizeShotList,
shotListHash,
} from '../../scripts/lib/reelShotList.mjs'
// C196 — the shot list is the single source of truth of a reel: the schema is
// fail-closed and the hash identifies the rendered version (C195 ingests by it).
const rawShotList = (overrides: Record<string, unknown> = {}) => ({
slug: 'cards',
title: 'Crie seu card de apoio',
feature: 'cards',
route: '/',
fixture: { cardName: 'Joana' },
scenes: [
{ id: 'hook', kind: 'graphic', template: 'hook', durationMs: 2400 },
{
id: 'step-1',
kind: 'capture',
badge: { number: 1, total: 3, label: 'Escolha um modelo' },
caption: { parts: [{ text: 'Toque em ' }, { text: '“Card”', accent: true }] },
setup: [{ action: 'scrollIntoView', selector: '#cards-title' }],
steps: [{ action: 'click', selector: '[data-card-model-tile="eu-sou-solla"]' }],
},
{ id: 'cta', kind: 'graphic', template: 'cta', durationMs: 3200 },
],
...overrides,
})
describe('reelShotList', () => {
it('normalizes a valid shot list with defaults', () => {
const shotList = normalizeShotList(rawShotList())
expect(shotList.slug).toBe('cards')
expect(shotList.route).toBe('/')
expect(captureScenes(shotList)).toHaveLength(1)
expect(graphicScenes(shotList)).toHaveLength(2)
const scene = captureScenes(shotList)[0]
expect(scene.leadInMs).toBe(700)
expect(scene.trailOutMs).toBe(800)
expect(scene.captionDelayMs).toBe(250)
expect(scene.captionDurationMs).toBeNull()
expect(scene.setup).toEqual([{ action: 'scrollIntoView', selector: '#cards-title' }])
})
it('hashes the normalized shot list deterministically', () => {
const first = normalizeShotList(rawShotList())
const second = normalizeShotList(rawShotList())
expect(shotListHash(first)).toBe(shotListHash(second))
const changed = normalizeShotList(rawShotList({ fixture: { cardName: 'Outra Pessoa' } }))
expect(shotListHash(changed)).not.toBe(shotListHash(first))
})
it('requires the hook first and the CTA last', () => {
expect(() =>
normalizeShotList(
rawShotList({
scenes: [
{
id: 'step-1',
kind: 'capture',
badge: { number: 1, total: 1, label: 'Passo' },
caption: { parts: [{ text: 'Toque.' }] },
steps: [{ action: 'click', selector: '#a' }],
},
],
}),
),
).toThrow(/primeira cena deve ser o hook/)
})
it('rejects unknown actions and empty selectors', () => {
expect(() =>
normalizeShotList(
rawShotList({
scenes: [
{ id: 'hook', kind: 'graphic', template: 'hook', durationMs: 1000 },
{
id: 'step-1',
kind: 'capture',
badge: { number: 1, total: 1, label: 'Passo' },
caption: { parts: [{ text: 'x' }] },
steps: [{ action: 'explode', selector: '#a' }],
},
{ id: 'cta', kind: 'graphic', template: 'cta', durationMs: 1000 },
],
}),
),
).toThrow(/action "explode" inválida/)
expect(() =>
normalizeShotList(
rawShotList({
scenes: [
{ id: 'hook', kind: 'graphic', template: 'hook', durationMs: 1000 },
{
id: 'step-1',
kind: 'capture',
badge: { number: 1, total: 1, label: 'Passo' },
caption: { parts: [{ text: 'x' }] },
steps: [{ action: 'click', selector: '' }],
},
{ id: 'cta', kind: 'graphic', template: 'cta', durationMs: 1000 },
],
}),
),
).toThrow(/selector deve ser string não vazia/)
})
it('requires fixture.cardName when a fill step has no value', () => {
const withoutFixture = rawShotList({ fixture: {} })
const scenes = (withoutFixture.scenes as Array<Record<string, unknown>>).slice()
scenes[1] = {
...scenes[1],
steps: [{ action: 'fill', selector: 'role=textbox[name="Seu nome"]' }],
}
expect(() => normalizeShotList({ ...withoutFixture, scenes })).toThrow(/fixture.cardName/)
})
it('normalizes the narration and preserves the authored caption spacing', () => {
const shotList = normalizeShotList(
rawShotList({
coverAlt: 'Alt próprio da capa',
scenes: [
{ id: 'hook', kind: 'graphic', template: 'hook', durationMs: 1000, narration: ' Olá.' },
{
id: 'step-1',
kind: 'capture',
badge: { number: 1, total: 3, label: 'Escolha' },
caption: { parts: [{ text: 'Toque em ' }, { text: '“Card”', accent: true }] },
narration: 'Fale por cima.',
steps: [{ action: 'click', selector: '#a' }],
},
{ id: 'cta', kind: 'graphic', template: 'cta', durationMs: 1000 },
],
}),
)
expect(shotList.coverAlt).toBe('Alt próprio da capa')
expect(shotList.scenes[0]).toMatchObject({ narration: 'Olá.' })
expect(captureScenes(shotList)[0]).toMatchObject({ narration: 'Fale por cima.' })
expect(shotList.scenes[2]).toMatchObject({ narration: null })
expect(captureScenes(shotList)[0].caption?.parts[0].text).toBe('Toque em ')
})
it('falls back coverAlt to the title and hashes the narration', () => {
const shotList = normalizeShotList(rawShotList())
expect(shotList.coverAlt).toBe(shotList.title)
const scenes = (rawShotList().scenes as Array<Record<string, unknown>>).map((scene, index) =>
index === 0 ? { ...scene, narration: 'Nova fala.' } : scene,
)
const withSpeech = normalizeShotList(rawShotList({ coverAlt: 'x', scenes }))
expect(shotListHash(withSpeech)).not.toBe(shotListHash(shotList))
})
it('rejects an over-long narration', () => {
expect(() =>
normalizeShotList(
rawShotList({
scenes: [
{
id: 'hook',
kind: 'graphic',
template: 'hook',
durationMs: 1000,
narration: 'a'.repeat(601),
},
rawShotList().scenes[1],
rawShotList().scenes[2],
],
}),
),
).toThrow(/narration excede 600 caracteres/)
})
})