mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
refactor(C162): dono único da elegibilidade VOD, start inicial do YouTube e mídia em early-returns (simplify)
This commit is contained in:
parent
940e32e28d
commit
6854ede261
8 changed files with 200 additions and 133 deletions
|
|
@ -1,6 +1,6 @@
|
|||
# Impl: Acervo de falas: player resolve o trecho sob demanda e YouTube vira default quando existe
|
||||
|
||||
Status: em execução
|
||||
Status: executado
|
||||
Atualizado em: 2026-09-14
|
||||
Issue: #999
|
||||
Intenção: docs/plans/acervo-vod-sob-demanda.md
|
||||
|
|
@ -156,9 +156,9 @@ Rejeitadas: B porque ressuscita link efêmero (o problema que o item resolve) e
|
|||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite de produto da intenção ainda coberto (quadrantes, `excerptTMs` verbatim, Baixar verificado, estado honesto, crédito CC BY, gate do acervo).
|
||||
- [ ] Invariantes AGENTS/engineering-standards (dono único, sem twin, sem migration/collection/Consent, sem escrita nova, `overrideAccess: false`, client boundary, zero warnings, dead code).
|
||||
- [ ] Testes de domínio previstos (unit/resolver/player/int/e2e) onde o access/write path e os contratos de URL mudam.
|
||||
- [x] Aceite de produto da intenção ainda coberto (quadrantes, `excerptTMs` verbatim, Baixar verificado, estado honesto, crédito CC BY, gate do acervo).
|
||||
- [x] Invariantes AGENTS/engineering-standards (dono único, sem twin, sem migration/collection/Consent, sem escrita nova, `overrideAccess: false`, client boundary, zero warnings, dead code).
|
||||
- [x] Testes de domínio previstos (unit/resolver/player/int/e2e) onde o access/write path e os contratos de URL mudam.
|
||||
|
||||
## Self-score (decision-quality, gate ≥4)
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import {
|
|||
SPEECH_VOD_NOT_FOUND_MESSAGE,
|
||||
speechVodRequestSchema,
|
||||
} from '@/lib/schemas/speechVod'
|
||||
import type { SpeechVodResolution } from '@/lib/speechVod'
|
||||
import { speechVodCoordinates, type SpeechVodResolution } from '@/lib/speechVod'
|
||||
import { getCampaignActionContext } from '@/utilities/campaignActionContext'
|
||||
import { resolveSpeechVod } from '@/utilities/speech/speechVodResolver'
|
||||
|
||||
|
|
@ -46,11 +46,8 @@ export const resolveSpeechVodForActor = async (input: {
|
|||
const speech = result.docs[0]
|
||||
if (!speech) throw new Error(SPEECH_VOD_NOT_FOUND_MESSAGE)
|
||||
|
||||
const hasStoredVod = Boolean(speech.vodPlaybackUrl || speech.vodDownloadUrl)
|
||||
const { eventId, audioId, excerptTMs } = speech
|
||||
if (!hasStoredVod || !eventId || !audioId || !excerptTMs) {
|
||||
throw new Error(SPEECH_VOD_INELIGIBLE_MESSAGE)
|
||||
}
|
||||
const coordinates = speechVodCoordinates(speech)
|
||||
if (!coordinates) throw new Error(SPEECH_VOD_INELIGIBLE_MESSAGE)
|
||||
|
||||
return resolveSpeechVod({ eventId, audioId, excerptTms: excerptTMs })
|
||||
return resolveSpeechVod(coordinates)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
'use client'
|
||||
|
||||
import { DownloadIcon, ExternalLinkIcon, FilmIcon, PlayIcon } from 'lucide-react'
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
|
||||
import type { SpeechVodResolveResponse } from '@/app/(campaign)/campanha/(app)/comunicacao/acervo/resolver-vod/types'
|
||||
import { SpeechHighlightParts } from '@/components/campaign/speech/SpeechHighlightParts'
|
||||
|
|
@ -13,6 +13,15 @@ import { cn } from '@/lib/utils'
|
|||
import type { SpeechDetailSegmentViewModel } from '@/utilities/speech/speechViewModels'
|
||||
|
||||
const RESOLVE_ENDPOINT = '/campanha/comunicacao/acervo/resolver-vod'
|
||||
const GENERATING_TITLE = 'A Câmara está gerando o trecho deste vídeo.'
|
||||
|
||||
const buildYoutubeSrc = (videoId: string, startSeconds: number | null): string => {
|
||||
const params = new URLSearchParams({ playsinline: '1', rel: '0' })
|
||||
if (startSeconds !== null && startSeconds > 0) {
|
||||
params.set('start', String(Math.floor(startSeconds)))
|
||||
}
|
||||
return `https://www.youtube-nocookie.com/embed/${videoId}?${params.toString()}`
|
||||
}
|
||||
|
||||
type ResolutionState =
|
||||
| { kind: 'idle' }
|
||||
|
|
@ -96,8 +105,8 @@ export const SpeechDetailPlayer = ({
|
|||
const [resolution, setResolution] = useState<ResolutionState>({ kind: 'idle' })
|
||||
const [activeStart, setActiveStart] = useState<number | null>(null)
|
||||
const [youtubeStart, setYoutubeStart] = useState<number | null>(() =>
|
||||
youtubeVideoId && youtubeOffsetSeconds !== null && initialSeconds !== null
|
||||
? youtubeOffsetSeconds + initialSeconds
|
||||
youtubeVideoId && youtubeOffsetSeconds !== null
|
||||
? youtubeOffsetSeconds + (initialSeconds ?? 0)
|
||||
: null,
|
||||
)
|
||||
|
||||
|
|
@ -177,95 +186,105 @@ export const SpeechDetailPlayer = ({
|
|||
setActiveStart(active?.startSeconds ?? null)
|
||||
}
|
||||
|
||||
const youtubeSrc = useMemo(() => {
|
||||
const params = new URLSearchParams({ playsinline: '1', rel: '0' })
|
||||
if (youtubeStart !== null && youtubeStart > 0)
|
||||
params.set('start', String(Math.floor(youtubeStart)))
|
||||
return `https://www.youtube-nocookie.com/embed/${youtubeVideoId}?${params.toString()}`
|
||||
}, [youtubeVideoId, youtubeStart])
|
||||
|
||||
const seekable = youtubeVideoId ? youtubeOffsetSeconds !== null : Boolean(playbackUrl)
|
||||
|
||||
const downloadAvailable = vodResolvable
|
||||
|
||||
const media = youtubeVideoId ? (
|
||||
<iframe
|
||||
src={youtubeSrc}
|
||||
title="Vídeo da sessão no YouTube"
|
||||
loading="lazy"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; compute-pressure"
|
||||
allowFullScreen
|
||||
className="aspect-video w-full rounded-lg border bg-black"
|
||||
/>
|
||||
) : playbackUrl ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
controls
|
||||
preload="metadata"
|
||||
src={playbackUrl}
|
||||
onTimeUpdate={onTimeUpdate}
|
||||
className="aspect-video w-full rounded-lg border bg-black"
|
||||
>
|
||||
<track kind="captions" />
|
||||
</video>
|
||||
) : resolution.kind === 'resolving' ? (
|
||||
<StatusPanel
|
||||
busy
|
||||
title="Resolvendo o trecho na Câmara…"
|
||||
detail="Isso pode levar alguns segundos. Se demorar, tente novamente em instantes — a página não trava."
|
||||
/>
|
||||
) : resolution.kind === 'generating' ? (
|
||||
<StatusPanel
|
||||
title="A Câmara está gerando o trecho deste vídeo."
|
||||
detail="A geração pode levar alguns instantes. Tente novamente em um momento."
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-1 min-h-10"
|
||||
onClick={() => void requestResolution(false)}
|
||||
>
|
||||
Tentar novamente
|
||||
</Button>
|
||||
</StatusPanel>
|
||||
) : resolution.kind === 'failed' ||
|
||||
(resolution.kind === 'resolved' && !resolution.playbackUrl) ? (
|
||||
<StatusPanel
|
||||
title="Não foi possível carregar o vídeo deste trecho."
|
||||
detail={
|
||||
resolution.kind === 'failed' && resolution.message
|
||||
? resolution.message
|
||||
: 'A Câmara não entregou o arquivo agora. A transcrição e a fonte oficial continuam disponíveis.'
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-1 min-h-10"
|
||||
onClick={() => void requestResolution(false)}
|
||||
>
|
||||
Tentar novamente
|
||||
</Button>
|
||||
</StatusPanel>
|
||||
) : vodResolvable ? (
|
||||
<StatusPanel
|
||||
title="O trecho deste vídeo é gerado pela Câmara dos Deputados."
|
||||
detail="Clique para resolver o arquivo exato desta fala — ele é verificado antes de tocar."
|
||||
>
|
||||
<Button className="mt-1 min-h-10" onClick={() => void requestResolution(false)}>
|
||||
<PlayIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Assistir o trecho
|
||||
</Button>
|
||||
</StatusPanel>
|
||||
) : (
|
||||
<StatusPanel
|
||||
title="Vídeo indisponível neste momento."
|
||||
detail="Esta fala não tem vídeo no YouTube nem trecho gerado pela Câmara disponível agora."
|
||||
/>
|
||||
)
|
||||
const renderMedia = (): ReactNode => {
|
||||
if (youtubeVideoId) {
|
||||
return (
|
||||
<iframe
|
||||
src={buildYoutubeSrc(youtubeVideoId, youtubeStart)}
|
||||
title="Vídeo da sessão no YouTube"
|
||||
loading="lazy"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; compute-pressure"
|
||||
allowFullScreen
|
||||
className="aspect-video w-full rounded-lg border bg-black"
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (playbackUrl) {
|
||||
return (
|
||||
<video
|
||||
ref={videoRef}
|
||||
controls
|
||||
preload="metadata"
|
||||
src={playbackUrl}
|
||||
onTimeUpdate={onTimeUpdate}
|
||||
className="aspect-video w-full rounded-lg border bg-black"
|
||||
>
|
||||
<track kind="captions" />
|
||||
</video>
|
||||
)
|
||||
}
|
||||
if (resolution.kind === 'resolving') {
|
||||
return (
|
||||
<StatusPanel
|
||||
busy
|
||||
title="Resolvendo o trecho na Câmara…"
|
||||
detail="Isso pode levar alguns segundos. Se demorar, tente novamente em instantes — a página não trava."
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (resolution.kind === 'generating') {
|
||||
return (
|
||||
<StatusPanel
|
||||
title={GENERATING_TITLE}
|
||||
detail="A geração pode levar alguns instantes. Tente novamente em um momento."
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-1 min-h-10"
|
||||
onClick={() => void requestResolution(false)}
|
||||
>
|
||||
Tentar novamente
|
||||
</Button>
|
||||
</StatusPanel>
|
||||
)
|
||||
}
|
||||
if (resolution.kind === 'failed' || resolution.kind === 'resolved') {
|
||||
return (
|
||||
<StatusPanel
|
||||
title="Não foi possível carregar o vídeo deste trecho."
|
||||
detail={
|
||||
resolution.kind === 'failed' && resolution.message
|
||||
? resolution.message
|
||||
: 'A Câmara não entregou o arquivo agora. A transcrição e a fonte oficial continuam disponíveis.'
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-1 min-h-10"
|
||||
onClick={() => void requestResolution(false)}
|
||||
>
|
||||
Tentar novamente
|
||||
</Button>
|
||||
</StatusPanel>
|
||||
)
|
||||
}
|
||||
if (vodResolvable) {
|
||||
return (
|
||||
<StatusPanel
|
||||
title="O trecho deste vídeo é gerado pela Câmara dos Deputados."
|
||||
detail="Clique para resolver o arquivo exato desta fala — ele é verificado antes de tocar."
|
||||
>
|
||||
<Button className="mt-1 min-h-10" onClick={() => void requestResolution(false)}>
|
||||
<PlayIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Assistir o trecho
|
||||
</Button>
|
||||
</StatusPanel>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<StatusPanel
|
||||
title="Vídeo indisponível neste momento."
|
||||
detail="Esta fala não tem vídeo no YouTube nem trecho gerado pela Câmara disponível agora."
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const inlineNotice = youtubeVideoId ? (
|
||||
resolution.kind === 'generating' ? (
|
||||
<p className="text-xs text-muted-foreground" role="status" aria-live="polite">
|
||||
A Câmara está gerando o trecho deste vídeo. Tente novamente em um momento.
|
||||
{GENERATING_TITLE} Tente novamente em um momento.
|
||||
</p>
|
||||
) : resolution.kind === 'failed' ? (
|
||||
<p className="text-xs text-destructive" role="status" aria-live="polite">
|
||||
|
|
@ -281,10 +300,10 @@ export const SpeechDetailPlayer = ({
|
|||
|
||||
return (
|
||||
<div data-slot="speech-player" aria-busy={resolving || undefined}>
|
||||
{media}
|
||||
{renderMedia()}
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
{downloadAvailable ? (
|
||||
{vodResolvable ? (
|
||||
<Button
|
||||
className="min-h-10"
|
||||
disabled={resolving}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,30 @@ export type SpeechVodResolution =
|
|||
| { state: 'gerando' }
|
||||
| { state: 'indisponivel' }
|
||||
|
||||
export type SpeechVodCoordinatesSource = {
|
||||
vodPlaybackUrl?: string | null
|
||||
vodDownloadUrl?: string | null
|
||||
eventId?: number | null
|
||||
audioId?: number | null
|
||||
excerptTMs?: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* The Câmara coordinates of a speech whose excerpt can be re-resolved: a
|
||||
* stored VOD link (eligibility signal only — it is a cache and never a URL
|
||||
* handed to the browser) plus `eventId`/`audioId`/`excerptTMs`. Null means the
|
||||
* player must not offer the resolution; the view model and the action share
|
||||
* this one decision.
|
||||
*/
|
||||
export const speechVodCoordinates = (
|
||||
speech: SpeechVodCoordinatesSource,
|
||||
): { eventId: number; audioId: number; excerptTms: number } | null => {
|
||||
const hasStoredVod = Boolean(speech.vodPlaybackUrl || speech.vodDownloadUrl)
|
||||
const { eventId, audioId, excerptTMs } = speech
|
||||
if (!hasStoredVod || !eventId || !audioId || !excerptTMs) return null
|
||||
return { eventId, audioId, excerptTms: excerptTMs }
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes the `video-sob-demanda` JSON (`{ estado, video }`). `state` is
|
||||
* `GERANDO` while the server transcodes, `PRONTO` when the MP4 exists,
|
||||
|
|
@ -149,21 +173,24 @@ const brtWallClock = new Intl.DateTimeFormat('en-CA', {
|
|||
hour12: false,
|
||||
})
|
||||
|
||||
/** BRT wall-clock of an epoch instant, as seconds since epoch-treated-as-UTC. */
|
||||
const wallClockSeconds = (epochMs: number): number | null => {
|
||||
/** JavaScript Date range: ±100,000,000 days from the epoch. */
|
||||
const MAX_DATE_MS = 8.64e15
|
||||
|
||||
/** BRT wall-clock of a valid epoch instant, as seconds since epoch-as-UTC. */
|
||||
const wallClockSeconds = (epochMs: number): number => {
|
||||
const parts = brtWallClock.formatToParts(new Date(epochMs))
|
||||
const get = (type: Intl.DateTimeFormatPartTypes): number =>
|
||||
Number(parts.find((part) => part.type === type)?.value)
|
||||
const [year, month, day, hour, minute, second] = [
|
||||
get('year'),
|
||||
get('month'),
|
||||
get('day'),
|
||||
get('hour'),
|
||||
get('minute'),
|
||||
get('second'),
|
||||
]
|
||||
if (![year, month, day, hour, minute, second].every(Number.isFinite)) return null
|
||||
return Date.UTC(year, month - 1, day, hour % 24, minute, second) / 1000
|
||||
return (
|
||||
Date.UTC(
|
||||
get('year'),
|
||||
get('month') - 1,
|
||||
get('day'),
|
||||
get('hour') % 24,
|
||||
get('minute'),
|
||||
get('second'),
|
||||
) / 1000
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -174,7 +201,7 @@ const wallClockSeconds = (epochMs: number): number | null => {
|
|||
*/
|
||||
export const excerptOffsetSeconds = (excerptTMs: unknown, eventStartAt: unknown): number | null => {
|
||||
const epochMs = Number(excerptTMs)
|
||||
if (!Number.isFinite(epochMs) || epochMs <= 0) return null
|
||||
if (!Number.isFinite(epochMs) || epochMs <= 0 || epochMs > MAX_DATE_MS) return null
|
||||
|
||||
const match = NAIVE_DATETIME.exec(String(eventStartAt ?? '').trim())
|
||||
if (!match) return null
|
||||
|
|
@ -183,11 +210,16 @@ export const excerptOffsetSeconds = (excerptTMs: unknown, eventStartAt: unknown)
|
|||
if (mo < 1 || mo > 12 || d < 1 || d > 31 || h > 23 || mi > 59 || s > 59) {
|
||||
return null
|
||||
}
|
||||
const startSeconds = Date.UTC(y, mo - 1, d, h, mi, s) / 1000
|
||||
const startMs = Date.UTC(y, mo - 1, d, h, mi, s)
|
||||
const startDate = new Date(startMs)
|
||||
if (
|
||||
startDate.getUTCFullYear() !== y ||
|
||||
startDate.getUTCMonth() !== mo - 1 ||
|
||||
startDate.getUTCDate() !== d
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const excerptSeconds = wallClockSeconds(epochMs)
|
||||
if (excerptSeconds === null) return null
|
||||
|
||||
const offset = Math.floor(excerptSeconds - startSeconds)
|
||||
const offset = Math.floor(wallClockSeconds(epochMs) - startMs / 1000)
|
||||
return offset >= 0 ? offset : null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import {
|
|||
type SpeechHighlightPart,
|
||||
} from '@/lib/speechHighlight'
|
||||
import { normalizeForSearch } from '@/lib/speechSearch'
|
||||
import { excerptOffsetSeconds, parseYoutubeVideoId } from '@/lib/speechVod'
|
||||
import { excerptOffsetSeconds, parseYoutubeVideoId, speechVodCoordinates } from '@/lib/speechVod'
|
||||
import type { Municipality } from '@/payload-types'
|
||||
import { speechScopeLabels, speechTopicLabels } from '@/utilities/speech/speechListUrl'
|
||||
|
||||
|
|
@ -245,12 +245,7 @@ export const toSpeechDetailViewModel = ({
|
|||
}): SpeechDetailViewModel => {
|
||||
// The stored VOD link is a cache and never a URL handed to the client; it
|
||||
// only signals that this record had a generated excerpt to re-resolve.
|
||||
const vodResolvable = Boolean(
|
||||
(speech.vodPlaybackUrl || speech.vodDownloadUrl) &&
|
||||
speech.eventId &&
|
||||
speech.audioId &&
|
||||
speech.excerptTMs,
|
||||
)
|
||||
const vodResolvable = speechVodCoordinates(speech) !== null
|
||||
|
||||
return {
|
||||
id: speech.id,
|
||||
|
|
|
|||
|
|
@ -225,7 +225,7 @@ describe('speech acervo (C154)', () => {
|
|||
const row = data.rows.find((item) => item.id === id)
|
||||
|
||||
expect(row).toBeDefined()
|
||||
expect('downloadUrl' in (row as object)).toBe(false)
|
||||
expect(row).not.toHaveProperty('downloadUrl')
|
||||
})
|
||||
|
||||
it('allows communicator/coordinator/candidate and denies advisor/leader', async () => {
|
||||
|
|
|
|||
|
|
@ -95,6 +95,8 @@ describe('SpeechDetailPlayer — VOD-only quadrant', () => {
|
|||
expect(screen.getByText(/O trecho deste vídeo é gerado pela Câmara/)).toBeDefined()
|
||||
expect(videoElement()).toBeNull()
|
||||
expect(iframeElement()).toBeNull()
|
||||
// The invariant: rendering the detail never calls the Câmara.
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /assistir o trecho/i }))
|
||||
|
||||
|
|
@ -178,6 +180,18 @@ describe('SpeechDetailPlayer — YouTube quadrant', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('embeds the session at the excerpt offset even without a ?t deep link', () => {
|
||||
renderPlayer({
|
||||
youtubeVideoId: 'lLhRDkSPw0A',
|
||||
youtubeOffsetSeconds: 2634,
|
||||
initialSeconds: null,
|
||||
})
|
||||
|
||||
expect(iframeElement()?.getAttribute('src')).toBe(
|
||||
'https://www.youtube-nocookie.com/embed/lLhRDkSPw0A?playsinline=1&rel=0&start=2634',
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves the transcript inert when the session offset is unknown', () => {
|
||||
renderPlayer({ youtubeVideoId: 'lLhRDkSPw0A', youtubeOffsetSeconds: null })
|
||||
|
||||
|
|
|
|||
|
|
@ -127,20 +127,30 @@ describe('resolveSpeechVod', () => {
|
|||
expect(calls).toHaveLength(1)
|
||||
|
||||
vi.unstubAllGlobals()
|
||||
stubFetch(async () => jsonResponse({ estado: 'INDISPONIVEL', video: null }))
|
||||
const unavailableCalls = stubFetch(async () =>
|
||||
jsonResponse({ estado: 'INDISPONIVEL', video: null }),
|
||||
)
|
||||
await expect(resolveSpeechVod({ eventId: 1, audioId: 2, excerptTms: 3 })).resolves.toEqual({
|
||||
state: 'indisponivel',
|
||||
})
|
||||
expect(unavailableCalls).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('throws after the bounded retries when the Câmara is unreachable', async () => {
|
||||
const calls = stubFetch(async () => {
|
||||
throw new Error('connect ETIMEDOUT')
|
||||
})
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const calls = stubFetch(async () => {
|
||||
throw new Error('connect ETIMEDOUT')
|
||||
})
|
||||
|
||||
await expect(resolveSpeechVod({ eventId: 1, audioId: 2, excerptTms: 3 })).rejects.toThrow(
|
||||
'connect ETIMEDOUT',
|
||||
)
|
||||
expect(calls).toHaveLength(2)
|
||||
const assertion = expect(
|
||||
resolveSpeechVod({ eventId: 1, audioId: 2, excerptTms: 3 }),
|
||||
).rejects.toThrow('connect ETIMEDOUT')
|
||||
await vi.advanceTimersByTimeAsync(1_100)
|
||||
await assertion
|
||||
expect(calls).toHaveLength(2)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue