feat(C170): acervo sem teto de corte — 5 s até o fim da fala (Closes #1046)

This commit is contained in:
Francisco Solla 2026-09-15 21:57:02 -03:00
parent 44e4502978
commit c010cf815b
11 changed files with 157 additions and 54 deletions

View file

@ -0,0 +1 @@
**C170 — Acervo: cortar sem teto — do mínimo de 5 s até o fim da fala (2026-09-16):** o teto de produto de 3 min some do código: `MAX_EXCERPT_SECONDS` removido da geometria (`speechExcerptSelection`), do `resolveExcerptRange` da action, da barra, do diálogo, do schema e do prompt da IA — o único limite passa a ser a `durationSeconds` da fala (fala inteira `[00:00, duração]` permitida, mínimo de 5 s mantido). A barra mostra `Mínimo 5s · sem limite — até o fim da fala` com `aria-valuemin/max` em `0–duração`, o diálogo mostra `<duração> · mínimo 5 s · até o fim da fala`, o erro do servidor diz `Selecione um trecho de 5 segundos até o fim da fala.` e o prompt da IA fala em `trecho da fala com 5 segundos ou mais`. Sem migration, sem Consent/collection/URL nova; gate do acervo, kill switch/publicação (C167/C168) e pipeline/ffmpeg (C169) intocados — corte longo que bater em timeout operacional falha honesto como hoje. Testes: units (`speechExcerptSelection`, `speechDetailPlayer` com `valuemax=duração`) + int (`speechCut`: mensagem nova, longo 0–240 aceito, <5 s rejeitado) atualizados; e2e `campaignSpeechAcervo`/`campaignSpeechCut` verdes sem spec novo.

View file

@ -0,0 +1,91 @@
# Impl: Acervo: cortar sem teto — do mínimo de 5 s até o fim da fala
Status: aprovado
Atualizado em: 2026-09-16
Issue: #1046
Intenção: docs/plans/c170-corte-sem-limite.md
Appetite restante: herdado (~0,5–1 dia eng; nenhum corte de escopo proposto)
## Leitura da intenção
- **Outcome:** remover o teto de 3 min do corte do acervo. Seleção `[início,fim]` de 5 s até a duração total da fala; corte publicado/baixado = intervalo exato escolhido (12 min, fala inteira). Mesma seleção do C166, mesmo corte do C167, sem teto. Robustez: corte longo conclui; se o pipeline falhar por limite operacional, falha honesta (causa no C169, não vira teto).
- **O que NÃO negociar:** mínimo de 5 s mantido; `excerptTMs`/seleção exata `[início,fim]` intocados; crédito "Fonte: Câmara dos Deputados · CC BY 4.0"; gate do acervo intocado (`communicator`/`coordinator`/`candidate`; advisor/leader negados fail-closed); kill switch/publicação C167/C168 intocados; sem editor de vídeo; sem trocar início/fim depois de cortado; copy diz a verdade.
- **O que reavaliar (hipóteses da intenção, a validar no gate):**
1. "Mínimo continua 5 s" — assumido A) manter. Confirmar no gate; se produto pedir outro valor, é outra fatia.
2. "Limite efetivo = `durationSeconds`" — a fala inteira `[00:00, duração]` é permitida; o teto real é a duração da fala, não um número mágico.
3. "Teto espalhado em 6 pontos" — geometria, aria, action, schema-msg, prompt IA, testes. Remover só a UI deixaria o servidor recusando o corte longo; a mudança é atômica nas pontas.
## Abordagem recomendada
```mermaid
flowchart LR
A["SpeechDetailPlayer<br/>fala durationSeconds"] -->|"Selecionar trecho"| B["SpeechExcerptControls<br/>moveRangeEdge sem MAX"]
B -->|"normalizeExcerptRange<br/>MIN=5, teto=duration"| B
B -->|"Compartilhar C166"| S["share exato"]
B -->|"Cortar vídeo C167"| C["SpeechCutDialog<br/>copy sem 180s"]
C -->|"POST acervo/cortar"| D["resolveExcerptRange<br/>guard só duração"]
D -->|ok| E["runSpeechCutJob C167/C169<br/>intocado"]
D -->|"< 5s ou fora da fala"| F["erro honesto:<br/>5 segundos até o fim da fala"]
```
**Opções consideradas:** A) manter `MAX_EXCERPT_SECONDS` mas subir para 86.400 s (1 dia); B) remover `MAX_EXCERPT_SECONDS` — `durationSeconds` é o único teto; C) `MAX` opcional/nulável com fallback para `durationSeconds`.
**Recomendação:** B — porque o teto de produto deixa de existir no código em vez de virar número grande mentiroso; um `grep MAX_EXCERPT` zerado prova a remoção; `normalizeExcerptRange`/`moveRangeEdge`/`resolveExcerptRange` passam a receber só `(range, durationSeconds)` e o mínimo continua nomeado.
**Rejeitadas:** A porque mantém o conceito de teto, a copy continuaria mentindo por omissão e um dia alguém "só ajusta o número" de volta; C porque cria dois caminhos de teto (config + duração) sem nenhum consumidor do configurável — camada sem volatilidade, complexidade sem dono.
### Decisões de engenharia (caras de reverter — nenhuma nesta fatia)
Zero decisão dura: sem collection nova, sem `Consent`/PII, sem multi-collection write, sem URL nova, sem fronteira de módulo. A única deliberação (representação do "sem teto", acima) é barata de reverter e foi explicitada para impedir remoção parcial. `speechCutJob.ts:37-44` (timeouts, 2 GiB, reaper) fica intocado por decisão — mexer no pipeline às cegas é o rabbit hole nomeado; falha operacional continua falha honesta do C167/C169.
### Componentes / mudanças
- **Migration:** nenhuma — sem mudança de schema/collection; `push:false` intocado; `payload-types.ts` não muda.
- **Access:** intocado — gate do acervo (`canReadSpeechCatalog`), `canReadSpeechCut` do C167 e leader lockdown seguem como estão; Local API segue `overrideAccess:false` no load da fala.
- **Geometria / limites (owner, sem twin):**
- **`src/lib/speechExcerptSelection.ts`**: remover `MAX_EXCERPT_SECONDS` (:21) e todo uso (:54 `normalizeExcerptRange`, :125,:134 `moveRangeEdge`); manter `MIN_EXCERPT_SECONDS = 5` (:20,:44,:50-52); limpar imports/label/JSDoc `5s..180s` (:4,:34,:68,:112-113). Assinaturas passam a clampar só em `[0, durationSeconds]` + mínimo.
- **`src/app/(campaign)/campanha/actions/speech.ts`**: `resolveExcerptRange` (:126-133, usado em :156 create e :338 sugestão) — remover guard `requested > MAX`; guardar só duração (`start >= 0`, `end <= durationSeconds`, `end - start >= MIN`); remover import (:31-33). `normalizeExcerptRange` em :136 herda a geometria sem teto.
- **UI (copy pt-BR exata, ids em inglês):**
- **`src/components/campaign/speech/SpeechExcerptControls.tsx`**: label :31 `Mínimo 5s · máximo 3min` → `Mínimo 5s · sem limite — até o fim da fala`; `aria-valuemin/max` (:114,:119) passam a `0–durationSeconds`; sem mudar tradução ponteiro/tecla → `moveRangeEdge`.
- **`src/components/campaign/speech/SpeechCutDialog.tsx`**: :242-243 `dentro da faixa de {MIN}–{MAX} s` → `mínimo 5 s · até o fim da fala` (linha `<duração> · mínimo 5 s · até o fim da fala`); remover import de MAX (:36).
- **`SpeechDetailPlayer.tsx`**: sem toque — só usa MIN (:19,:146,:229). `SpeechExcerptShare`/`lib/speechShare`: sem toque, sem teto.
- **Servidor / schema / prompt:**
- **`src/lib/schemas/speechCut.ts`**: mensagem :17 `Selecione um trecho de 5 a 180 segundos.` → `Selecione um trecho de 5 segundos até o fim da fala.`; zod :31 (0–86400) intocado — o teto real era a action.
- **`src/utilities/speech/speechCutMetadata.ts`**: prompt :42 `…de um trecho de 5 a 180 segundos.` → `…de um trecho da fala com 5 segundos ou mais.`
- **Testes que pinam 180 (atualizar, não deletar cobertura):**
- `tests/unit/speechExcerptSelection.unit.spec.ts:41-42,:113-117,:130-133,:141-144` — trocar pinos de clamp em 180 por clamp em `durationSeconds` + caso longo (ex.: 720 s passa).
- `tests/unit/speechDetailPlayer.unit.spec.tsx:290` (`aria-valuemax` 180), `:306,:320-321,:442-444` — `valuemax = duração`.
- `tests/int/speechCut.int.spec.ts:300` (regex `/5 a 180/`) — nova mensagem; adicionar caso create longo (>180 s, dentro da duração) publica/encaminha e caso `< 5 s` rejeita.
### Dados → forma (n/a)
Duração/`mm:ss` é feedback de ação, não dado do acervo — nenhum KPI/gráfico novo, conforme a intenção. Sem telemetria de corte nesta fatia.
## Fases verificáveis
1. **Tracer — geometria + servidor + schema-msg (~50% do appetite).** `speechExcerptSelection.ts` sem MAX + `resolveExcerptRange` só-duração + mensagem do schema + atualização das units/int acima. Verificação: `pnpm test:unit -- speechExcerptSelection` e `pnpm test:int -- speechCut` verdes; smoke Local API: range de 720 s dentro da duração normaliza/aceita, range de 3 s rejeita com a mensagem nova. Confirma a hipótese #3 (nenhum guard residual: `grep -rn MAX_EXCERPT` vazio fora de changelog).
2. **UI + prompt (~30%).** Barra (label + aria), diálogo (copy + import), prompt IA; ajuste das units do player. Verificação: `pnpm test:unit -- speechDetailPlayer` verde; manual no dev: fim da seleção alcança o último segundo, início o primeiro, duração visível (ex.: 12 min); diálogo mostra a copy nova; sugestão IA retorna com o prompt novo.
3. **Gates e fechamento (~20%).** `pnpm gate:fast` → `pnpm gate:ci` → `docs/changelog/2026-09-16-c170.md` (uma entrada, sem editar o agregado) → `pnpm push`. Cobertura e2e existente (`campaignSpeechAcervo`, curado C166/C167) deve passar sem spec novo — sem URL/collection nova, nada a acrescentar ao manifest. Validação do corte longo real fica com o `deploy-staging` (OPS103) antes de produção, na dependência do pipeline C169.
## Rabbit holes / Não escopo (engenharia)
- **Aumentar timeouts/limites do pipeline** (`SOURCE_DOWNLOAD_TIMEOUT_MS`, `MAX_SOURCE_BYTES`, `FFMPEG_MAX_TIMEOUT_MS`, reaper `SPEECH_CUT_STALE_MS`) — defeito do C169, não teto de produto.
- **Editor/timeline/zoom/precisão de frame, segundo player/preview** — barra atual + ímã de frase, segundos inteiros.
- **Streaming/fila assíncrona, presets de duração, multi-trecho, legenda no corte.**
- **Mecanismo/resolver VOD (C169); publicação/kill switch/biblioteca (C167/C168).**
- **Papéis do acervo, `Consent`/collection novas, segundo cadastro.**
- **Redesign da barra/paleta fora de `campaign`; DRY <3 call sites; camadas novas pass-through** — editar o owner, não twinar.
## Riscos e mitigação
- **Remoção parcial (UI libera, servidor recusa — ou o inverso).** Mitigação: tracer cobre geometria+servidor juntos na Fase 1; `grep MAX_EXCERPT` vazio como critério de aceite; int com caso longo ponta-a-ponta na action.
- **Corte longo falha por limite operacional (download 180 s, 2 GiB, ffmpeg 5 min, reaper 15 min).** Mitigação: aceite de robustez — falha honesta como hoje, nada publicado; causa registrada no C169; nenhum timeout mexido aqui.
- **Regressão C166/C167 (share e corte curto).** Mitigação: `excerptTMs`/seleção exata intocados; mínimo e ímã de frase intocados; units/int existentes atualizados, não removidos; e2e `campaignSpeechAcervo` no gate.
- **Aria/leitores de tela mentindo o limite.** Mitigação: `aria-valuemax = durationSeconds` junto com o label, coberto por unit do player.
- **Prompt IA ainda sugerindo trecho curto.** Mitigação: prompt sem números de teto; sugestão continua editável e o job normaliza com fallback — sem bloqueio.
## Aceite de engenharia
- [ ] Outcome da intenção: seleção `[início,fim]` de 5 s até a duração total (fala inteira permitida), barra não trava em 180 s, corte publicado/baixado = intervalo exato, copy verdadeira (`Mínimo 5s · sem limite — até o fim da fala` / `mínimo 5 s · até o fim da fala` / mensagem do schema / prompt IA).
- [ ] `grep -rn MAX_EXCERPT_SECONDS src tests` vazio (fora changelog); `MIN_EXCERPT_SECONDS = 5` intacto em geometria, player, action e testes.
- [ ] Invariantes AGENTS/engineering-standards: sem migração (nada a commitar em `src/migrations`); `push:false` intocado; gate do acervo e leader lockdown intocados; sem `Consent`/`Contact` paralelos; strings pt-BR, identificadores inglês; `admin.group` intocado.
- [ ] Testes previstos: units (`speechExcerptSelection`, `speechDetailPlayer` com `valuemax=duração`) + int (`speechCut`: mensagem nova, longo aceito, <5 s rejeitado) verdes; e2e `campaignSpeechAcervo` verde sem entrada nova no manifest.
- [ ] `pnpm gate:fast` e `pnpm gate:ci` verdes; changelog `docs/changelog/2026-09-16-c170.md`; validação do corte longo real no `deploy-staging` antes de produção.

View file

@ -27,11 +27,7 @@ import {
} from '@/lib/schemas/speechVod'
import { formatSpeechDate } from '@/lib/speechClock'
import { toSpeechCutViewModel, type SpeechCutViewModel } from '@/lib/speechCut'
import {
MAX_EXCERPT_SECONDS,
MIN_EXCERPT_SECONDS,
normalizeExcerptRange,
} from '@/lib/speechExcerptSelection'
import { MIN_EXCERPT_SECONDS, normalizeExcerptRange } from '@/lib/speechExcerptSelection'
import { speechVodCoordinates, type SpeechVodResolution } from '@/lib/speechVod'
import type { CampaignUser } from '@/payload-types'
import { getCampaignActionContext } from '@/utilities/campaignActionContext'
@ -118,10 +114,10 @@ const loadSpeechForCut = async (payload: Payload, actor: CampaignUser, speechId:
}
/**
* Validates the requested window against the C166 bounds and the speech
* duration, then normalizes it. Deterministic (the action and the suggestion
* must agree): a forged request fails closed instead of silently cutting a
* different interval.
* Validates the requested window against the 5 s minimum (C170: no upper cap —
* the speech duration is the limit), then normalizes it. Deterministic (the
* action and the suggestion must agree): a forged request fails closed instead
* of silently cutting a different interval.
*/
const resolveExcerptRange = (
durationSeconds: number | null | undefined,
@ -130,7 +126,7 @@ const resolveExcerptRange = (
): { startSeconds: number; endSeconds: number } => {
const duration = Math.floor(durationSeconds ?? 0)
const requested = endSeconds - startSeconds
if (requested < MIN_EXCERPT_SECONDS || requested > MAX_EXCERPT_SECONDS || endSeconds > duration) {
if (requested < MIN_EXCERPT_SECONDS || endSeconds > duration) {
throw new Error(SPEECH_CUT_INVALID_RANGE_MESSAGE)
}
const range = normalizeExcerptRange(startSeconds, endSeconds, duration)

View file

@ -32,11 +32,7 @@ import {
type SpeechCutStep,
type SpeechCutViewModel,
} from '@/lib/speechCut'
import {
MAX_EXCERPT_SECONDS,
MIN_EXCERPT_SECONDS,
type ExcerptRange,
} from '@/lib/speechExcerptSelection'
import { MIN_EXCERPT_SECONDS, type ExcerptRange } from '@/lib/speechExcerptSelection'
import { cn } from '@/lib/utils'
const SAVE_ENDPOINT = '/campanha/comunicacao/acervo/cortar'
@ -239,8 +235,8 @@ export const SpeechCutDialog = ({
{formatSpeechClock(range.startSeconds)} → {formatSpeechClock(range.endSeconds)}
</span>
<span className="text-xs text-muted-foreground">
{formatSpeechSpan(durationSeconds)} · dentro da faixa de {MIN_EXCERPT_SECONDS}–
{MAX_EXCERPT_SECONDS} s
{formatSpeechSpan(durationSeconds)} · mínimo {MIN_EXCERPT_SECONDS} s · até o fim
da fala
</span>
</div>
</div>

View file

@ -5,7 +5,6 @@ import { useRef } from 'react'
import { formatSpeechClock, formatSpeechSpan } from '@/lib/speechClock'
import {
MAX_EXCERPT_SECONDS,
MIN_EXCERPT_SECONDS,
moveRangeEdge,
rangeDurationSeconds,
@ -28,7 +27,7 @@ const HANDLE_KEY_DELTAS: Record<string, number> = {
PageUp: 5,
}
const excerptLimitsLabel = `Mínimo ${MIN_EXCERPT_SECONDS}s · máximo ${MAX_EXCERPT_SECONDS / 60}min`
const excerptLimitsLabel = `Mínimo ${MIN_EXCERPT_SECONDS}s · sem limite — até o fim da fala`
type SpeechExcerptControlsProps = {
segments: readonly ExcerptSegment[]
@ -109,14 +108,8 @@ export const SpeechExcerptControls = ({
role: 'slider' as const,
tabIndex: 0,
'aria-label': edge === 'start' ? 'Início do trecho' : 'Fim do trecho',
'aria-valuemin':
edge === 'start'
? Math.max(0, range.endSeconds - MAX_EXCERPT_SECONDS)
: range.startSeconds + MIN_EXCERPT_SECONDS,
'aria-valuemax':
edge === 'start'
? range.endSeconds - MIN_EXCERPT_SECONDS
: Math.min(durationSeconds, range.startSeconds + MAX_EXCERPT_SECONDS),
'aria-valuemin': edge === 'start' ? 0 : range.startSeconds + MIN_EXCERPT_SECONDS,
'aria-valuemax': edge === 'start' ? range.endSeconds - MIN_EXCERPT_SECONDS : durationSeconds,
'aria-valuenow': seconds,
'aria-valuetext': formatSpeechClock(seconds),
'data-excerpt-edge': edge,

View file

@ -13,8 +13,9 @@ export const SPEECH_CUT_SPEECH_NOT_FOUND_MESSAGE = 'Fala não encontrada.'
/** The cut does not exist (or the actor cannot read it). */
export const SPEECH_CUT_NOT_FOUND_MESSAGE = 'Corte não encontrado.'
/** Selection outside the C166 bounds (5–180 s) or outside the speech duration. */
export const SPEECH_CUT_INVALID_RANGE_MESSAGE = 'Selecione um trecho de 5 a 180 segundos.'
/** Selection below the 5 s minimum or outside the speech duration. */
export const SPEECH_CUT_INVALID_RANGE_MESSAGE =
'Selecione um trecho de 5 segundos até o fim da fala.'
/** Retry only applies to a failed cut — a published one is not re-cut. */
export const SPEECH_CUT_RETRY_NOT_FAILED_MESSAGE =

View file

@ -1,7 +1,8 @@
/**
* C166 — pure geometry for the acervo excerpt picker: whole-second
* [start, end] ranges snapped to transcript segment boundaries, clamped to the
* excerpt limits (5 s..180 s) and to the speech duration. No DOM and no React:
* 5 s minimum and to the speech duration (C170: no upper cap — the speech
* itself is the limit). No DOM and no React:
* the client controls only translate pointers/keys into these calls.
*/
@ -18,7 +19,6 @@ export type ExcerptRange = {
export type ExcerptEdge = 'start' | 'end'
export const MIN_EXCERPT_SECONDS = 5
export const MAX_EXCERPT_SECONDS = 180
export const rangeDurationSeconds = (range: ExcerptRange): number =>
range.endSeconds - range.startSeconds
@ -31,7 +31,7 @@ const wholeCeil = (seconds: number): number => (Number.isFinite(seconds) ? Math.
/**
* Clamp a raw [start, end] pair into a valid excerpt range: whole seconds,
* inside [0, duration], at least 5 s and at most 180 s long (a short pair
* inside [0, duration], at least 5 s long (a short pair
* grows forward, and backwards when the speech ends first). Returns null when
* the duration cannot hold the minimum — the caller then hides the picker.
*/
@ -51,7 +51,6 @@ export const normalizeExcerptRange = (
end = Math.min(duration, start + MIN_EXCERPT_SECONDS)
start = Math.max(0, end - MIN_EXCERPT_SECONDS)
}
if (end - start > MAX_EXCERPT_SECONDS) end = start + MAX_EXCERPT_SECONDS
return { startSeconds: start, endSeconds: end }
}
@ -65,7 +64,7 @@ export const initialExcerptRange = (
? selectSegmentRange(segments, 0, durationSeconds)
: normalizeExcerptRange(0, MIN_EXCERPT_SECONDS, durationSeconds)
/** A whole phrase, clamped by the duration and by the 5 s/180 s limits. */
/** A whole phrase, clamped by the duration and by the 5 s minimum. */
export const selectSegmentRange = (
segments: readonly ExcerptSegment[],
index: number,
@ -110,7 +109,7 @@ export const extendRangeToSegment = (
/**
* Handle/keyboard move of one edge: the other edge stays put, the span stays
* between 5 s and 180 s, and the range stays inside the speech.
* at least 5 s, and the range stays inside the speech (C170: no upper cap).
*/
export const moveRangeEdge = (
range: ExcerptRange,
@ -122,16 +121,15 @@ export const moveRangeEdge = (
const requested = Math.round(Number.isFinite(nextSeconds) ? nextSeconds : 0)
if (edge === 'start') {
const minimum = Math.max(0, range.endSeconds - MAX_EXCERPT_SECONDS)
const maximum = range.endSeconds - MIN_EXCERPT_SECONDS
return {
startSeconds: Math.min(Math.max(requested, minimum), maximum),
startSeconds: Math.min(Math.max(requested, 0), maximum),
endSeconds: range.endSeconds,
}
}
const minimum = range.startSeconds + MIN_EXCERPT_SECONDS
const maximum = Math.min(duration, range.startSeconds + MAX_EXCERPT_SECONDS)
const maximum = duration
return {
startSeconds: range.startSeconds,
endSeconds: Math.min(Math.max(requested, minimum), maximum),

View file

@ -39,7 +39,7 @@ const suggestionSchema = z.object({
const SYSTEM_PROMPT =
'Você prepara cortes de falas do deputado federal Jorge Solla no acervo da Câmara dos Deputados. ' +
'Receberá tipo, data, resumo oficial e a transcrição automática (ASR) de um trecho de 5 a 180 segundos. ' +
'Receberá tipo, data, resumo oficial e a transcrição automática (ASR) de um trecho da fala com 5 segundos ou mais. ' +
'Escreva um título curto e concreto (o assunto do trecho, não o nome do deputado) e uma descrição de uma ' +
'a duas frases, em português do Brasil, adequadas para compartilhar o vídeo no WhatsApp. ' +
'Use apenas o que está no trecho e no resumo; nunca invente números, acordos ou citações, e a transcrição ' +

View file

@ -297,7 +297,7 @@ describe('speech cuts (C167)', () => {
title: 't',
description: 'd',
}),
).rejects.toThrow(/5 a 180/)
).rejects.toThrow(/5 segundos até o fim da fala/)
for (const denied of [advisor, leader]) {
asActor(denied)
@ -313,6 +313,32 @@ describe('speech cuts (C167)', () => {
}
})
it('accepts a long selection up to the speech duration (C170, no 180 s cap)', async () => {
const speech = await createSpeech()
const { communicator } = await createUsers()
asActor(communicator)
const long = await saveSpeechCutForActor({
speechId: speech,
startSeconds: 0,
endSeconds: 240,
title: 'Discurso inteiro',
description: 'Trecho longo sem teto',
})
expect(long.durationSeconds).toBe(240)
createdCutIds.add(long.id)
await expect(
saveSpeechCutForActor({
speechId: speech,
startSeconds: 0,
endSeconds: 3,
title: 't',
description: 'd',
}),
).rejects.toThrow(/5 segundos até o fim da fala/)
})
it('retries only a failed row, reusing the same id', async () => {
const speech = await createSpeech()
const { communicator } = await createUsers()

View file

@ -287,7 +287,7 @@ describe('SpeechDetailPlayer — C166 excerpt selection and share', () => {
fireEvent.click(segmentButton(43))
expect(startSlider().getAttribute('aria-valuenow')).toBe('0')
expect(endSlider().getAttribute('aria-valuenow')).toBe('50')
expect(endSlider().getAttribute('aria-valuemax')).toBe('180')
expect(endSlider().getAttribute('aria-valuemax')).toBe('300')
// C162 contract: the selection mode does not seek the embed.
expect(iframeElement()?.getAttribute('src')).toBe(
'https://www.youtube-nocookie.com/embed/lLhRDkSPw0A?playsinline=1&rel=0&start=2634',
@ -303,7 +303,7 @@ describe('SpeechDetailPlayer — C166 excerpt selection and share', () => {
expect(iframeElement()?.getAttribute('src')).toContain('start=2677')
})
it('adjusts the edges by keyboard with the 5 s/180 s limits', () => {
it('adjusts the edges by keyboard with the 5 s minimum and no upper cap', () => {
renderPlayer({ youtubeVideoId: 'lLhRDkSPw0A', youtubeOffsetSeconds: 2634 })
toggleSelection()
@ -317,11 +317,11 @@ describe('SpeechDetailPlayer — C166 excerpt selection and share', () => {
expect(startSlider().getAttribute('aria-valuenow')).toBe('45')
fireEvent.keyDown(endSlider(), { key: 'End' })
expect(endSlider().getAttribute('aria-valuenow')).toBe('225')
expect(screen.getByText(/3min00s/)).toBeDefined()
expect(endSlider().getAttribute('aria-valuenow')).toBe('300')
expect(screen.getByText(/4min15s/)).toBeDefined()
fireEvent.keyDown(endSlider(), { key: 'ArrowLeft' })
expect(endSlider().getAttribute('aria-valuenow')).toBe('224')
expect(endSlider().getAttribute('aria-valuenow')).toBe('299')
fireEvent.keyDown(endSlider(), { key: 'Home' })
expect(endSlider().getAttribute('aria-valuenow')).toBe('50')
})
@ -439,9 +439,9 @@ describe('SpeechDetailPlayer — C166 excerpt selection and share', () => {
fireEvent.pointerMove(handle, { pointerId: 1, clientX: 150 })
expect(endSlider().getAttribute('aria-valuenow')).toBe('150')
// 250s would pass the 180 s span cap from the 0 s start.
// 250s is far from every boundary and within the speech, so the handle moves freely.
fireEvent.pointerMove(handle, { pointerId: 1, clientX: 250 })
expect(endSlider().getAttribute('aria-valuenow')).toBe('180')
expect(endSlider().getAttribute('aria-valuenow')).toBe('250')
fireEvent.pointerUp(handle, { pointerId: 1, clientX: 250 })
expect(releasePointerCapture).toHaveBeenCalled()

View file

@ -38,8 +38,9 @@ describe('normalizeExcerptRange', () => {
expect(normalizeExcerptRange(248, 252, 252)).toEqual({ startSeconds: 247, endSeconds: 252 })
})
it('caps at 180 s', () => {
expect(normalizeExcerptRange(0, 500, 600)).toEqual({ startSeconds: 0, endSeconds: 180 })
it('has no upper cap — the speech duration is the limit', () => {
expect(normalizeExcerptRange(0, 500, 600)).toEqual({ startSeconds: 0, endSeconds: 500 })
expect(normalizeExcerptRange(0, 600, 600)).toEqual({ startSeconds: 0, endSeconds: 600 })
expect(normalizeExcerptRange(500, 500, 600)).toEqual({ startSeconds: 500, endSeconds: 505 })
})
@ -110,10 +111,10 @@ describe('extendRangeToSegment', () => {
expect(extendRangeToSegment(range, SEGMENTS, 9, 252)).toEqual(range)
})
it('caps the extension at 180 s when the clicked phrase is long', () => {
it('extends to the end of a long phrase without a cap', () => {
expect(extendRangeToSegment(range, SEGMENTS, 3, 252)).toEqual({
startSeconds: 43,
endSeconds: 223,
endSeconds: 252,
})
})
})
@ -127,9 +128,9 @@ describe('moveRangeEdge', () => {
expect(moveRangeEdge(range, 'start', 20, 252)).toEqual({ startSeconds: 20, endSeconds: 51 })
})
it('moves the end inside start + 5 s and the 180 s cap', () => {
it('moves the end between start + 5 s and the speech duration', () => {
expect(moveRangeEdge(range, 'end', 0, 252)).toEqual({ startSeconds: 43, endSeconds: 48 })
expect(moveRangeEdge(range, 'end', 500, 252)).toEqual({ startSeconds: 43, endSeconds: 223 })
expect(moveRangeEdge(range, 'end', 500, 252)).toEqual({ startSeconds: 43, endSeconds: 252 })
expect(moveRangeEdge(range, 'end', 200, 252)).toEqual({ startSeconds: 43, endSeconds: 200 })
})
@ -139,7 +140,7 @@ describe('moveRangeEdge', () => {
endSeconds: 203,
})
expect(moveRangeEdge({ startSeconds: 240, endSeconds: 252 }, 'start', 10, 252)).toEqual({
startSeconds: 72,
startSeconds: 10,
endSeconds: 252,
})
})