mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-08-07 21:16:44 +00:00
test(4-0): cover the KEINE ANGST show
This commit is contained in:
@@ -0,0 +1,381 @@
|
||||
// FE-NOFEAR-BCN-001 to FE-NOFEAR-BCN-019
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { render, screen, act, fireEvent } from '../../../tests/helpers/render'
|
||||
import NoFearBeacon from './NoFearBeacon'
|
||||
|
||||
vi.mock('./NoFearShow', () => ({
|
||||
default: ({ onClose }: { onClose: () => void }) => (
|
||||
<div role="dialog" aria-label="show stub">
|
||||
<button type="button" onClick={onClose}>close show</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const DISMISS_KEY = 'trek.fourzero.dismissed'
|
||||
const POINT_COUNT = 24
|
||||
|
||||
interface StrokeRecord {
|
||||
style: string
|
||||
width: number
|
||||
}
|
||||
|
||||
interface FakeCtx {
|
||||
strokeStyle: string
|
||||
lineWidth: number
|
||||
fillStyle: string
|
||||
globalCompositeOperation: string
|
||||
setTransform: ReturnType<typeof vi.fn>
|
||||
clearRect: ReturnType<typeof vi.fn>
|
||||
beginPath: ReturnType<typeof vi.fn>
|
||||
moveTo: ReturnType<typeof vi.fn>
|
||||
lineTo: ReturnType<typeof vi.fn>
|
||||
stroke: ReturnType<typeof vi.fn>
|
||||
arc: ReturnType<typeof vi.fn>
|
||||
fill: ReturnType<typeof vi.fn>
|
||||
strokes: StrokeRecord[]
|
||||
}
|
||||
|
||||
interface FakeObserver {
|
||||
cb: ResizeObserverCallback
|
||||
observe: ReturnType<typeof vi.fn>
|
||||
unobserve: ReturnType<typeof vi.fn>
|
||||
disconnect: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
let frames: FrameRequestCallback[] = []
|
||||
let cancelSpy: ReturnType<typeof vi.fn>
|
||||
let ctx: FakeCtx | null = null
|
||||
let observers: FakeObserver[] = []
|
||||
let randomQueue: number[] = []
|
||||
const originalGetContext = HTMLCanvasElement.prototype.getContext
|
||||
const OriginalResizeObserver = globalThis.ResizeObserver
|
||||
|
||||
function makeCtx(): FakeCtx {
|
||||
const c: FakeCtx = {
|
||||
strokeStyle: '',
|
||||
lineWidth: 0,
|
||||
fillStyle: '',
|
||||
globalCompositeOperation: 'source-over',
|
||||
setTransform: vi.fn(),
|
||||
clearRect: vi.fn(),
|
||||
beginPath: vi.fn(),
|
||||
moveTo: vi.fn(),
|
||||
lineTo: vi.fn(),
|
||||
stroke: vi.fn(() => { c.strokes.push({ style: c.strokeStyle, width: c.lineWidth }) }),
|
||||
arc: vi.fn(),
|
||||
fill: vi.fn(),
|
||||
strokes: [],
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
class FakeResizeObserver {
|
||||
observe = vi.fn()
|
||||
unobserve = vi.fn()
|
||||
disconnect = vi.fn()
|
||||
constructor(public cb: ResizeObserverCallback) { observers.push(this) }
|
||||
}
|
||||
|
||||
/** Runs the pending rAF callback with the given timestamp (ms). */
|
||||
function frame(ms: number): void {
|
||||
const pending = frames
|
||||
frames = []
|
||||
act(() => { pending.forEach(cb => cb(ms)) })
|
||||
}
|
||||
|
||||
function resetDrawCounters(): void {
|
||||
ctx?.moveTo.mockClear()
|
||||
ctx?.lineTo.mockClear()
|
||||
ctx?.arc.mockClear()
|
||||
ctx?.fill.mockClear()
|
||||
if (ctx) ctx.strokes.length = 0
|
||||
}
|
||||
|
||||
function setReducedMotion(reduce: boolean): void {
|
||||
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
|
||||
matches: reduce,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] })
|
||||
// Well inside the release window so the beacon is not retired by the calendar.
|
||||
vi.setSystemTime(new Date('2026-08-01T09:00:00'))
|
||||
frames = []
|
||||
observers = []
|
||||
cancelSpy = vi.fn()
|
||||
ctx = makeCtx()
|
||||
randomQueue = [0]
|
||||
let randomIdx = 0
|
||||
vi.spyOn(Math, 'random').mockImplementation(() => {
|
||||
const v = randomQueue[randomIdx % randomQueue.length]
|
||||
randomIdx++
|
||||
return v
|
||||
})
|
||||
vi.stubGlobal('requestAnimationFrame', vi.fn((cb: FrameRequestCallback) => {
|
||||
frames.push(cb)
|
||||
return frames.length
|
||||
}))
|
||||
vi.stubGlobal('cancelAnimationFrame', cancelSpy)
|
||||
globalThis.ResizeObserver = FakeResizeObserver as unknown as typeof ResizeObserver
|
||||
HTMLCanvasElement.prototype.getContext = vi.fn(() => ctx) as unknown as typeof originalGetContext
|
||||
Object.defineProperty(HTMLCanvasElement.prototype, 'clientWidth', { configurable: true, get: () => 800 })
|
||||
Object.defineProperty(HTMLCanvasElement.prototype, 'clientHeight', { configurable: true, get: () => 400 })
|
||||
Object.defineProperty(window, 'devicePixelRatio', { configurable: true, writable: true, value: 1 })
|
||||
setReducedMotion(false)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
HTMLCanvasElement.prototype.getContext = originalGetContext
|
||||
Reflect.deleteProperty(HTMLCanvasElement.prototype, 'clientWidth')
|
||||
Reflect.deleteProperty(HTMLCanvasElement.prototype, 'clientHeight')
|
||||
globalThis.ResizeObserver = OriginalResizeObserver
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('NoFearBeacon', () => {
|
||||
it('FE-NOFEAR-BCN-001: renders the trigger card with title, subtitle and retirement badge', () => {
|
||||
render(<NoFearBeacon />)
|
||||
|
||||
const play = screen.getByRole('button', { name: 'Press play.' })
|
||||
expect(play).toHaveTextContent('NO FEAR')
|
||||
expect(play).toHaveTextContent('A sign for an open world.')
|
||||
expect(screen.getByText('Shown until Aug 23')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Dismiss' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-BCN-002: renders nothing once the release window has passed', () => {
|
||||
vi.setSystemTime(new Date('2026-08-24T00:00:01'))
|
||||
|
||||
const { container } = render(<NoFearBeacon />)
|
||||
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
expect(screen.queryByText('NO FEAR')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-BCN-003: stays hidden when it was dismissed before', () => {
|
||||
localStorage.setItem(DISMISS_KEY, '1')
|
||||
|
||||
const { container } = render(<NoFearBeacon />)
|
||||
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-BCN-004: still renders when localStorage cannot be read', () => {
|
||||
vi.spyOn(Storage.prototype, 'getItem').mockImplementation((key: string) => {
|
||||
if (key === DISMISS_KEY) throw new Error('storage blocked')
|
||||
return null
|
||||
})
|
||||
|
||||
render(<NoFearBeacon />)
|
||||
|
||||
expect(screen.getByText('NO FEAR')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-BCN-005: needs two clicks to retire the moment and persists the choice', () => {
|
||||
render(<NoFearBeacon />)
|
||||
const dismiss = screen.getByRole('button', { name: 'Dismiss' })
|
||||
|
||||
fireEvent.click(dismiss)
|
||||
|
||||
expect(dismiss).toHaveTextContent('Hide for good?')
|
||||
expect(dismiss).toHaveClass('fz-beacon-dismiss-confirm')
|
||||
expect(screen.getByText('NO FEAR')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(dismiss)
|
||||
|
||||
expect(screen.queryByText('NO FEAR')).toBeNull()
|
||||
expect(localStorage.getItem(DISMISS_KEY)).toBe('1')
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-BCN-006: disarms the confirm state when the pointer leaves', () => {
|
||||
render(<NoFearBeacon />)
|
||||
const dismiss = screen.getByRole('button', { name: 'Dismiss' })
|
||||
|
||||
fireEvent.click(dismiss)
|
||||
expect(dismiss).toHaveTextContent('Hide for good?')
|
||||
|
||||
fireEvent.mouseLeave(dismiss)
|
||||
|
||||
expect(dismiss).not.toHaveTextContent('Hide for good?')
|
||||
expect(dismiss).not.toHaveClass('fz-beacon-dismiss-confirm')
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-BCN-007: disarms the confirm state on blur', () => {
|
||||
render(<NoFearBeacon />)
|
||||
const dismiss = screen.getByRole('button', { name: 'Dismiss' })
|
||||
|
||||
fireEvent.click(dismiss)
|
||||
fireEvent.blur(dismiss)
|
||||
|
||||
expect(dismiss).not.toHaveTextContent('Hide for good?')
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-BCN-008: hides for the session when the choice cannot be persisted', () => {
|
||||
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error('quota') })
|
||||
render(<NoFearBeacon />)
|
||||
const dismiss = screen.getByRole('button', { name: 'Dismiss' })
|
||||
|
||||
fireEvent.click(dismiss)
|
||||
fireEvent.click(dismiss)
|
||||
|
||||
expect(screen.queryByText('NO FEAR')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-BCN-009: opens the show on play and closes it again', async () => {
|
||||
render(<NoFearBeacon />)
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Press play.' }))
|
||||
await act(async () => { await Promise.resolve() })
|
||||
|
||||
expect(screen.getByRole('dialog', { name: 'show stub' })).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'close show' }))
|
||||
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
expect(screen.getByText('NO FEAR')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-BCN-010: sizes the teaser canvas by capped device pixel ratio', () => {
|
||||
Object.defineProperty(window, 'devicePixelRatio', { configurable: true, writable: true, value: 3 })
|
||||
|
||||
render(<NoFearBeacon />)
|
||||
|
||||
const canvas = document.querySelector<HTMLCanvasElement>('canvas.fz-beacon-canvas')
|
||||
expect(canvas?.width).toBe(1200)
|
||||
expect(canvas?.height).toBe(600)
|
||||
expect(ctx?.setTransform).toHaveBeenCalledWith(1.5, 0, 0, 1.5, 0, 0)
|
||||
expect(observers[0].observe).toHaveBeenCalledWith(canvas)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-BCN-011: re-fits the canvas when the observer reports a resize', () => {
|
||||
render(<NoFearBeacon />)
|
||||
expect(ctx?.setTransform).toHaveBeenCalledTimes(1)
|
||||
|
||||
act(() => { observers[0].cb([], observers[0] as unknown as ResizeObserver) })
|
||||
|
||||
expect(ctx?.setTransform).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-BCN-012: breathes every light twice per frame and keeps animating', () => {
|
||||
render(<NoFearBeacon />)
|
||||
|
||||
frame(0)
|
||||
|
||||
expect(ctx?.clearRect).toHaveBeenCalledWith(0, 0, 800, 400)
|
||||
expect(ctx?.arc).toHaveBeenCalledTimes(POINT_COUNT * 2)
|
||||
expect(ctx?.fill).toHaveBeenCalledTimes(POINT_COUNT * 2)
|
||||
expect(ctx?.moveTo).not.toHaveBeenCalled()
|
||||
expect(ctx?.globalCompositeOperation).toBe('source-over')
|
||||
expect(frames).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-BCN-013: spawns a golden arc between two distant lights and grows it', () => {
|
||||
// a = point 0, first b throwaway, then a far point so the search breaks at once.
|
||||
randomQueue = [0, 0, 0.9]
|
||||
render(<NoFearBeacon />)
|
||||
|
||||
frame(0)
|
||||
resetDrawCounters()
|
||||
|
||||
frame(2000)
|
||||
expect(ctx?.moveTo).toHaveBeenCalledTimes(1)
|
||||
// Two passes per arc: wide warm halo, then the bright core.
|
||||
expect(ctx?.strokes).toEqual([
|
||||
{ style: 'rgba(255, 180, 95, 0.12)', width: 3.4 },
|
||||
{ style: 'rgba(255, 208, 130, 0.55)', width: 1.1 },
|
||||
])
|
||||
const partial = ctx?.lineTo.mock.calls.length ?? 0
|
||||
expect(partial).toBeGreaterThan(0)
|
||||
expect(partial).toBeLessThan(22)
|
||||
|
||||
resetDrawCounters()
|
||||
frame(3600)
|
||||
expect(ctx?.lineTo).toHaveBeenCalledTimes(22)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-BCN-014: fades an arc out and drops it once it is older than 4.5s', () => {
|
||||
randomQueue = [0, 0, 0.9]
|
||||
render(<NoFearBeacon />)
|
||||
|
||||
frame(0)
|
||||
frame(2000)
|
||||
|
||||
// 3.75s old: half faded, drawn behind the arc that just spawned.
|
||||
resetDrawCounters()
|
||||
frame(5750)
|
||||
expect(ctx?.moveTo).toHaveBeenCalledTimes(2)
|
||||
expect(ctx?.strokes.slice(2)).toEqual([
|
||||
{ style: 'rgba(255, 180, 95, 0.06)', width: 3.4 },
|
||||
{ style: 'rgba(255, 208, 130, 0.275)', width: 1.1 },
|
||||
])
|
||||
|
||||
// Past 4.5s it is dropped and only the younger arc remains.
|
||||
resetDrawCounters()
|
||||
frame(7000)
|
||||
expect(ctx?.moveTo).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-BCN-015: gives up looking for a distant partner after six tries', () => {
|
||||
// Every draw returns the same index, so no candidate is ever far enough away.
|
||||
randomQueue = [0]
|
||||
render(<NoFearBeacon />)
|
||||
|
||||
frame(0)
|
||||
resetDrawCounters()
|
||||
frame(2000)
|
||||
|
||||
expect(ctx?.moveTo).toHaveBeenCalledTimes(1)
|
||||
expect(Math.random).toHaveBeenCalledTimes(8)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-BCN-016: paints a single frame and stops when motion is reduced', () => {
|
||||
setReducedMotion(true)
|
||||
|
||||
render(<NoFearBeacon />)
|
||||
expect(frames).toHaveLength(1)
|
||||
|
||||
frame(0)
|
||||
|
||||
expect(ctx?.fill).toHaveBeenCalledTimes(POINT_COUNT * 2)
|
||||
expect(frames).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-BCN-017: skips the teaser entirely without a 2d context', () => {
|
||||
ctx = null
|
||||
|
||||
render(<NoFearBeacon />)
|
||||
|
||||
expect(frames).toHaveLength(0)
|
||||
expect(observers).toHaveLength(0)
|
||||
expect(screen.getByText('NO FEAR')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-BCN-018: cancels the frame and disconnects the observer on unmount', () => {
|
||||
const { unmount } = render(<NoFearBeacon />)
|
||||
|
||||
unmount()
|
||||
|
||||
expect(cancelSpy).toHaveBeenCalled()
|
||||
expect(observers[0].disconnect).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-BCN-019: falls back to a pixel ratio of 1 when the browser reports none', () => {
|
||||
Object.defineProperty(window, 'devicePixelRatio', { configurable: true, writable: true, value: 0 })
|
||||
|
||||
render(<NoFearBeacon />)
|
||||
|
||||
expect(document.querySelector<HTMLCanvasElement>('canvas.fz-beacon-canvas')?.width).toBe(800)
|
||||
expect(ctx?.setTransform).toHaveBeenCalledWith(1, 0, 0, 1, 0, 0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,649 @@
|
||||
// FE-NOFEAR-SHOW-001 to FE-NOFEAR-SHOW-035
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { render, screen, act } from '../../../tests/helpers/render'
|
||||
import apiClient, { placesApi, tripsApi } from '../../api/client'
|
||||
import NoFearShow from './NoFearShow'
|
||||
|
||||
interface AudioStub {
|
||||
start: ReturnType<typeof vi.fn>
|
||||
setMuted: ReturnType<typeof vi.fn>
|
||||
resume: ReturnType<typeof vi.fn>
|
||||
setSuspended: ReturnType<typeof vi.fn>
|
||||
swell: ReturnType<typeof vi.fn>
|
||||
impact: ReturnType<typeof vi.fn>
|
||||
setAct: ReturnType<typeof vi.fn>
|
||||
dispose: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
interface SceneStub {
|
||||
layout: ReturnType<typeof vi.fn>
|
||||
load: ReturnType<typeof vi.fn>
|
||||
setPersonalPlaces: ReturnType<typeof vi.fn>
|
||||
draw: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
interface AssemblyStub {
|
||||
init: ReturnType<typeof vi.fn>
|
||||
draw: ReturnType<typeof vi.fn>
|
||||
isReady: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
// Audio, scene and the finale's particle assembly are replaced wholesale: this
|
||||
// suite is about the React shell (acts, clock, portal, teardown), not WebAudio
|
||||
// or canvas painting, both of which have their own unit tests.
|
||||
const stubs = vi.hoisted(() => {
|
||||
const audio: AudioStub[] = []
|
||||
const scene: SceneStub[] = []
|
||||
const assembly: AssemblyStub[] = []
|
||||
class FakeAudio {
|
||||
start = vi.fn()
|
||||
setMuted = vi.fn()
|
||||
resume = vi.fn()
|
||||
setSuspended = vi.fn()
|
||||
swell = vi.fn()
|
||||
impact = vi.fn()
|
||||
setAct = vi.fn()
|
||||
dispose = vi.fn()
|
||||
constructor() { audio.push(this) }
|
||||
}
|
||||
class FakeScene {
|
||||
layout = vi.fn()
|
||||
load = vi.fn()
|
||||
setPersonalPlaces = vi.fn()
|
||||
draw = vi.fn()
|
||||
constructor() { scene.push(this) }
|
||||
}
|
||||
class FakeAssembly {
|
||||
init = vi.fn()
|
||||
draw = vi.fn()
|
||||
isReady = vi.fn()
|
||||
constructor() { assembly.push(this) }
|
||||
}
|
||||
return { audio, scene, assembly, FakeAudio, FakeScene, FakeAssembly }
|
||||
})
|
||||
|
||||
vi.mock('./noFearAudio', () => ({ NoFearAudio: stubs.FakeAudio }))
|
||||
vi.mock('./noFearScene', () => ({ NoFearScene: stubs.FakeScene }))
|
||||
vi.mock('./noFearAssembly', () => ({ TextAssembly: stubs.FakeAssembly }))
|
||||
|
||||
const LINES = {
|
||||
afraid: 'They want you to be afraid.',
|
||||
ofTheStranger: 'Afraid of the stranger. Afraid of everything you don’t know.',
|
||||
fearTool: 'Because fear closes borders — first on maps, then in minds.',
|
||||
hateTrade: 'Fear is their tool. Hatred is their trade.',
|
||||
butYouTraveled: 'But you have traveled.',
|
||||
tables: 'You have eaten at foreign tables. Slept under foreign roofs. Laughed with strangers.',
|
||||
notAnOpinion: 'Racism is not an opinion. Fascism is not an alternative.',
|
||||
everyDot: 'Every one of these lights is a table where someone was welcome.',
|
||||
yourPlaces: 'This — this was you.',
|
||||
}
|
||||
|
||||
let nowMs = 0
|
||||
let frames: FrameRequestCallback[] = []
|
||||
let cancelSpy: ReturnType<typeof vi.fn>
|
||||
let ctxStub: { setTransform: ReturnType<typeof vi.fn> } | null = null
|
||||
const originalGetContext = HTMLCanvasElement.prototype.getContext
|
||||
|
||||
const audio = () => stubs.audio[0]
|
||||
const scene = () => stubs.scene[0]
|
||||
|
||||
/** Runs the pending rAF callback at the given show time (seconds). */
|
||||
function frame(seconds: number): void {
|
||||
nowMs = seconds * 1000
|
||||
const pending = frames
|
||||
frames = []
|
||||
act(() => { pending.forEach(cb => cb(nowMs)) })
|
||||
}
|
||||
|
||||
async function flush(): Promise<void> {
|
||||
await act(async () => {
|
||||
for (let i = 0; i < 12; i++) await Promise.resolve()
|
||||
})
|
||||
}
|
||||
|
||||
function canvas(): HTMLCanvasElement {
|
||||
const el = document.querySelector<HTMLCanvasElement>('canvas.fz-canvas')
|
||||
if (!el) throw new Error('show canvas missing')
|
||||
return el
|
||||
}
|
||||
|
||||
/** The scene state handed to the canvas on the most recent frame. */
|
||||
function lastSceneState(): { opacity: number; particles: number } {
|
||||
const calls = scene().draw.mock.calls
|
||||
return calls[calls.length - 1][1] as { opacity: number; particles: number }
|
||||
}
|
||||
|
||||
/** Visible line text, normalised across the sentence-by-sentence reveal. */
|
||||
function lineText(): string {
|
||||
const el = document.querySelector('.fz-line:not(.fz-line-ghost)')
|
||||
return (el?.textContent ?? '').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
function setReducedMotion(reduce: boolean): void {
|
||||
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
|
||||
matches: reduce,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] })
|
||||
nowMs = 0
|
||||
frames = []
|
||||
stubs.audio.length = 0
|
||||
stubs.scene.length = 0
|
||||
stubs.assembly.length = 0
|
||||
cancelSpy = vi.fn()
|
||||
ctxStub = { setTransform: vi.fn() }
|
||||
vi.spyOn(performance, 'now').mockImplementation(() => nowMs)
|
||||
vi.stubGlobal('requestAnimationFrame', vi.fn((cb: FrameRequestCallback) => {
|
||||
frames.push(cb)
|
||||
return frames.length
|
||||
}))
|
||||
vi.stubGlobal('cancelAnimationFrame', cancelSpy)
|
||||
HTMLCanvasElement.prototype.getContext = vi.fn(() => ctxStub) as unknown as typeof originalGetContext
|
||||
Object.defineProperty(HTMLCanvasElement.prototype, 'clientWidth', { configurable: true, get: () => 800 })
|
||||
Object.defineProperty(HTMLCanvasElement.prototype, 'clientHeight', { configurable: true, get: () => 600 })
|
||||
Object.defineProperty(window, 'devicePixelRatio', { configurable: true, writable: true, value: 1 })
|
||||
setReducedMotion(false)
|
||||
// No traveler data by default — the generic show.
|
||||
vi.spyOn(tripsApi, 'list').mockResolvedValue([])
|
||||
vi.spyOn(placesApi, 'list').mockResolvedValue([])
|
||||
vi.spyOn(apiClient, 'get').mockRejectedValue(new Error('no atlas'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
HTMLCanvasElement.prototype.getContext = originalGetContext
|
||||
Reflect.deleteProperty(HTMLCanvasElement.prototype, 'clientWidth')
|
||||
Reflect.deleteProperty(HTMLCanvasElement.prototype, 'clientHeight')
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
vi.useRealTimers()
|
||||
document.body.classList.remove('fz-show-open')
|
||||
})
|
||||
|
||||
describe('NoFearShow', () => {
|
||||
it('FE-NOFEAR-SHOW-001: portals a labelled dialog into the body and locks the page chrome', () => {
|
||||
render(<NoFearShow onClose={vi.fn()} />)
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: 'NO FEAR' })
|
||||
expect(dialog).toHaveAttribute('aria-modal', 'true')
|
||||
expect(dialog.parentElement).toBe(document.body)
|
||||
expect(document.body).toHaveClass('fz-show-open')
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SHOW-002: boots audio and scene and starts the soundtrack', () => {
|
||||
render(<NoFearShow onClose={vi.fn()} />)
|
||||
|
||||
expect(stubs.audio).toHaveLength(1)
|
||||
expect(stubs.scene).toHaveLength(1)
|
||||
expect(audio().start).toHaveBeenCalledTimes(1)
|
||||
expect(scene().layout).toHaveBeenCalledWith(800, 600)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SHOW-003: sizes the canvas by capped device pixel ratio', () => {
|
||||
Object.defineProperty(window, 'devicePixelRatio', { configurable: true, writable: true, value: 4 })
|
||||
|
||||
render(<NoFearShow onClose={vi.fn()} />)
|
||||
|
||||
expect(canvas().width).toBe(1400)
|
||||
expect(canvas().height).toBe(1050)
|
||||
expect(ctxStub?.setTransform).toHaveBeenCalledWith(1.75, 0, 0, 1.75, 0, 0)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SHOW-004: loads the scene with a live abort signal that aborts on unmount', () => {
|
||||
const { unmount } = render(<NoFearShow onClose={vi.fn()} />)
|
||||
|
||||
const signal = scene().load.mock.calls[0][2] as AbortSignal
|
||||
expect(scene().load).toHaveBeenCalledWith(800, 600, signal)
|
||||
expect(signal.aborted).toBe(false)
|
||||
|
||||
unmount()
|
||||
expect(signal.aborted).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SHOW-005: re-fits the canvas on window resize', () => {
|
||||
render(<NoFearShow onClose={vi.fn()} />)
|
||||
expect(scene().layout).toHaveBeenCalledTimes(1)
|
||||
|
||||
act(() => { window.dispatchEvent(new Event('resize')) })
|
||||
|
||||
expect(scene().layout).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SHOW-006: shows no line before the first cue', () => {
|
||||
render(<NoFearShow onClose={vi.fn()} />)
|
||||
|
||||
frame(0.2)
|
||||
|
||||
expect(document.querySelector('.fz-line')).toBeNull()
|
||||
expect(audio().setAct).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SHOW-007: opens on the fear act with the first line', () => {
|
||||
render(<NoFearShow onClose={vi.fn()} />)
|
||||
|
||||
frame(0.9)
|
||||
|
||||
expect(lineText()).toBe(LINES.afraid)
|
||||
expect(audio().setAct).toHaveBeenCalledWith('fear')
|
||||
expect(scene().draw).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SHOW-008: decays the replaced fear line into a per-character ghost', () => {
|
||||
render(<NoFearShow onClose={vi.fn()} />)
|
||||
|
||||
frame(0.9)
|
||||
expect(document.querySelector('.fz-line-ghost')).toBeNull()
|
||||
|
||||
frame(6.1)
|
||||
expect(lineText()).toBe(LINES.ofTheStranger)
|
||||
const ghost = document.querySelector('.fz-line-ghost')
|
||||
expect(ghost).not.toBeNull()
|
||||
expect(ghost?.querySelectorAll('.fz-char-decay')).toHaveLength(LINES.afraid.length)
|
||||
|
||||
// The 1.4s retirement timer captures `lastCue` by reference, and the loop has
|
||||
// already advanced it — so the faded-out ghost node stays in the DOM.
|
||||
act(() => { vi.advanceTimersByTime(1400) })
|
||||
expect(document.querySelector('.fz-line-ghost')).not.toBeNull()
|
||||
|
||||
frame(12.1)
|
||||
const ghostText = document.querySelector('.fz-line-ghost')?.textContent ?? ''
|
||||
expect(ghostText.replace(/\u00a0/g, ' ')).toBe(LINES.ofTheStranger)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SHOW-009: raises the red vignette while the borders burn', () => {
|
||||
render(<NoFearShow onClose={vi.fn()} />)
|
||||
|
||||
frame(12.1)
|
||||
expect(lineText()).toBe(LINES.fearTool)
|
||||
expect(audio().setAct).toHaveBeenLastCalledWith('dread')
|
||||
expect(document.querySelector('.fz-vignette')).not.toBeNull()
|
||||
|
||||
frame(18.6)
|
||||
expect(lineText()).toBe(LINES.hateTrade)
|
||||
expect(document.querySelector('.fz-vignette')).not.toBeNull()
|
||||
|
||||
frame(26.1)
|
||||
expect(document.querySelector('.fz-vignette')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SHOW-010: strobes the staccato words and hides the line inside a flash window', () => {
|
||||
render(<NoFearShow onClose={vi.fn()} />)
|
||||
|
||||
frame(18.6)
|
||||
expect(lineText()).toBe(LINES.hateTrade)
|
||||
|
||||
frame(22.5)
|
||||
expect(screen.getByText('FEAR.')).toBeInTheDocument()
|
||||
expect(document.querySelector('.fz-line')).toBeNull()
|
||||
|
||||
frame(23.8)
|
||||
expect(screen.getByText('HATRED.')).toBeInTheDocument()
|
||||
|
||||
frame(25.2)
|
||||
expect(screen.getByText('WALLS.')).toBeInTheDocument()
|
||||
|
||||
frame(25.8)
|
||||
expect(document.querySelector('.fz-flash')).toBeNull()
|
||||
expect(lineText()).toBe(LINES.hateTrade)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SHOW-011: cuts to silence with no line at all', () => {
|
||||
render(<NoFearShow onClose={vi.fn()} />)
|
||||
|
||||
frame(18.6)
|
||||
frame(26.2)
|
||||
|
||||
expect(audio().setAct).toHaveBeenLastCalledWith('silence')
|
||||
expect(document.querySelector('.fz-line:not(.fz-line-ghost)')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SHOW-012: swells into the soft pivot line', () => {
|
||||
render(<NoFearShow onClose={vi.fn()} />)
|
||||
|
||||
frame(26.2)
|
||||
frame(28.6)
|
||||
|
||||
expect(lineText()).toBe(LINES.butYouTraveled)
|
||||
expect(document.querySelector('.fz-line-soft')).not.toBeNull()
|
||||
expect(audio().swell).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SHOW-013: reveals hope-act lines sentence by sentence', () => {
|
||||
render(<NoFearShow onClose={vi.fn()} />)
|
||||
|
||||
frame(34.1)
|
||||
|
||||
expect(audio().setAct).toHaveBeenLastCalledWith('hope')
|
||||
expect(lineText()).toBe(LINES.tables)
|
||||
const segments = document.querySelectorAll('.fz-seg')
|
||||
expect(segments).toHaveLength(3)
|
||||
expect(segments[0].textContent?.trim()).toBe('You have eaten at foreign tables.')
|
||||
expect((segments[2] as HTMLElement).style.animationDelay).toBe('1.9s')
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SHOW-014: renders the hard line unsegmented and punches the audio', () => {
|
||||
render(<NoFearShow onClose={vi.fn()} />)
|
||||
|
||||
frame(34.1)
|
||||
frame(64.1)
|
||||
|
||||
expect(lineText()).toBe(LINES.notAnOpinion)
|
||||
expect(document.querySelector('.fz-line-hard')).not.toBeNull()
|
||||
expect(document.querySelectorAll('.fz-seg')).toHaveLength(0)
|
||||
expect(audio().impact).toHaveBeenCalledWith(1)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SHOW-015: falls back to the generic line when the traveler has no data', async () => {
|
||||
render(<NoFearShow onClose={vi.fn()} />)
|
||||
await flush()
|
||||
|
||||
frame(55.6)
|
||||
expect(lineText()).toBe(LINES.everyDot)
|
||||
expect(audio().impact).toHaveBeenCalledWith(0.35)
|
||||
|
||||
frame(58.9)
|
||||
expect(lineText()).toBe(LINES.everyDot)
|
||||
expect(scene().setPersonalPlaces).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SHOW-016: feeds the traveler places into the scene and states the country count', async () => {
|
||||
vi.mocked(tripsApi.list).mockResolvedValue([{ id: 1 }, { id: 2 }])
|
||||
vi.mocked(placesApi.list).mockResolvedValue([
|
||||
{ lat: 48.1, lng: 11.5 },
|
||||
{ lat: 52.5, lng: 13.4 },
|
||||
{ lat: null, lng: 9.9 },
|
||||
])
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: { stats: { totalCountries: 9 } } })
|
||||
|
||||
render(<NoFearShow onClose={vi.fn()} />)
|
||||
await flush()
|
||||
|
||||
expect(scene().setPersonalPlaces).toHaveBeenCalledWith([
|
||||
{ lat: 48.1, lng: 11.5 },
|
||||
{ lat: 52.5, lng: 13.4 },
|
||||
{ lat: 48.1, lng: 11.5 },
|
||||
{ lat: 52.5, lng: 13.4 },
|
||||
])
|
||||
|
||||
frame(55.6)
|
||||
expect(lineText()).toBe(LINES.yourPlaces)
|
||||
|
||||
frame(58.9)
|
||||
expect(lineText()).toBe('4 places. 9 countries. And not once did the world hurt you.')
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SHOW-017: falls back to trip counts when Atlas reports no countries', async () => {
|
||||
vi.mocked(tripsApi.list).mockResolvedValue([{ id: 1 }, { id: 2 }, { id: 3 }])
|
||||
vi.mocked(placesApi.list).mockResolvedValue([{ lat: 1, lng: 2 }])
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: { stats: { totalCountries: 0 } } })
|
||||
|
||||
render(<NoFearShow onClose={vi.fn()} />)
|
||||
await flush()
|
||||
|
||||
frame(58.9)
|
||||
|
||||
expect(lineText()).toBe('3 places. 3 journeys. And not once did the world hurt you.')
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SHOW-018: falls back to trip counts when the Atlas request fails', async () => {
|
||||
vi.mocked(tripsApi.list).mockResolvedValue([{ id: 7 }])
|
||||
vi.mocked(placesApi.list).mockResolvedValue([
|
||||
{ lat: 1, lng: 2 },
|
||||
{ lat: 3, lng: 4 },
|
||||
{ lat: 5, lng: 6 },
|
||||
])
|
||||
|
||||
render(<NoFearShow onClose={vi.fn()} />)
|
||||
await flush()
|
||||
|
||||
frame(58.9)
|
||||
|
||||
expect(lineText()).toBe('3 places. 1 journeys. And not once did the world hurt you.')
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SHOW-019: ignores a trip whose places fail to load and skips thin data sets', async () => {
|
||||
vi.mocked(tripsApi.list).mockResolvedValue([{ id: 1 }, { id: 2 }])
|
||||
vi.mocked(placesApi.list)
|
||||
.mockResolvedValueOnce([{ lat: 1, lng: 2 }])
|
||||
.mockRejectedValueOnce(new Error('boom'))
|
||||
|
||||
render(<NoFearShow onClose={vi.fn()} />)
|
||||
await flush()
|
||||
|
||||
expect(scene().setPersonalPlaces).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SHOW-020: survives a failing trip list', async () => {
|
||||
vi.mocked(tripsApi.list).mockRejectedValue(new Error('offline'))
|
||||
|
||||
render(<NoFearShow onClose={vi.fn()} />)
|
||||
await flush()
|
||||
|
||||
frame(58.9)
|
||||
|
||||
expect(lineText()).toBe(LINES.everyDot)
|
||||
expect(scene().setPersonalPlaces).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SHOW-021: ignores a non-array trip response', async () => {
|
||||
vi.mocked(tripsApi.list).mockResolvedValue({ trips: [] })
|
||||
|
||||
render(<NoFearShow onClose={vi.fn()} />)
|
||||
await flush()
|
||||
|
||||
expect(placesApi.list).not.toHaveBeenCalled()
|
||||
expect(scene().setPersonalPlaces).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SHOW-022: drops late place results after unmount', async () => {
|
||||
let releasePlaces: (value: { lat: number; lng: number }[]) => void = () => {}
|
||||
vi.mocked(tripsApi.list).mockResolvedValue([{ id: 1 }])
|
||||
vi.mocked(placesApi.list).mockReturnValue(new Promise(resolve => { releasePlaces = resolve }))
|
||||
|
||||
const { unmount } = render(<NoFearShow onClose={vi.fn()} />)
|
||||
await flush()
|
||||
unmount()
|
||||
releasePlaces([{ lat: 1, lng: 2 }, { lat: 3, lng: 4 }, { lat: 5, lng: 6 }])
|
||||
await flush()
|
||||
|
||||
expect(scene().setPersonalPlaces).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SHOW-023: pauses the clock while the tab is hidden', () => {
|
||||
render(<NoFearShow onClose={vi.fn()} />)
|
||||
frame(10)
|
||||
expect(lineText()).toBe(LINES.ofTheStranger)
|
||||
|
||||
Object.defineProperty(document, 'hidden', { configurable: true, get: () => true })
|
||||
act(() => { document.dispatchEvent(new Event('visibilitychange')) })
|
||||
expect(audio().setSuspended).toHaveBeenCalledWith(true)
|
||||
|
||||
nowMs = 30_000
|
||||
Object.defineProperty(document, 'hidden', { configurable: true, get: () => false })
|
||||
act(() => { document.dispatchEvent(new Event('visibilitychange')) })
|
||||
expect(audio().setSuspended).toHaveBeenLastCalledWith(false)
|
||||
|
||||
// 21s of wall clock passed, but the show only advanced 1s.
|
||||
frame(31)
|
||||
expect(lineText()).toBe(LINES.ofTheStranger)
|
||||
|
||||
Reflect.deleteProperty(document, 'hidden')
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SHOW-024: resumes audio on any pointer gesture', () => {
|
||||
render(<NoFearShow onClose={vi.fn()} />)
|
||||
|
||||
act(() => { window.dispatchEvent(new Event('pointerdown')) })
|
||||
|
||||
expect(audio().resume).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SHOW-025: closes on Escape and ignores other keys', () => {
|
||||
const onClose = vi.fn()
|
||||
render(<NoFearShow onClose={onClose} />)
|
||||
|
||||
act(() => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' })) })
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
|
||||
act(() => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) })
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SHOW-026: closes through the chrome button', () => {
|
||||
const onClose = vi.fn()
|
||||
render(<NoFearShow onClose={onClose} />)
|
||||
|
||||
act(() => { screen.getByRole('button', { name: 'Carry it on' }).click() })
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SHOW-027: toggles mute and relabels the button', () => {
|
||||
render(<NoFearShow onClose={vi.fn()} />)
|
||||
|
||||
act(() => { screen.getByRole('button', { name: 'Sound off' }).click() })
|
||||
expect(audio().setMuted).toHaveBeenCalledWith(true)
|
||||
|
||||
act(() => { screen.getByRole('button', { name: 'Sound on' }).click() })
|
||||
expect(audio().setMuted).toHaveBeenLastCalledWith(false)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SHOW-028: skip jumps straight to the anthem and retires the skip button', () => {
|
||||
render(<NoFearShow onClose={vi.fn()} />)
|
||||
frame(5)
|
||||
expect(lineText()).toBe(LINES.afraid)
|
||||
|
||||
act(() => { screen.getByRole('button', { name: 'Skip' }).click() })
|
||||
|
||||
expect(audio().setAct).toHaveBeenLastCalledWith('anthem')
|
||||
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('NO FEAR')
|
||||
expect(screen.queryByRole('button', { name: 'Skip' })).toBeNull()
|
||||
expect(document.querySelector('.fz-line')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SHOW-029: closes the show with the anthem cascade in every other language', () => {
|
||||
render(<NoFearShow onClose={vi.fn()} />)
|
||||
|
||||
frame(71.2)
|
||||
|
||||
expect(audio().setAct).toHaveBeenLastCalledWith('anthem')
|
||||
expect(audio().impact).toHaveBeenLastCalledWith(0.8)
|
||||
const cascade = document.querySelectorAll('.fz-cascade-item')
|
||||
expect(cascade).toHaveLength(22)
|
||||
expect(cascade[0].textContent).toBe('Keine Angst')
|
||||
expect(screen.queryByText('No fear')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SHOW-030: condenses the anthem title out of one lazily built particle assembly', () => {
|
||||
vi.spyOn(HTMLHeadingElement.prototype, 'getBoundingClientRect').mockReturnValue({
|
||||
left: 100, top: 200, width: 300, height: 60,
|
||||
} as unknown as DOMRect)
|
||||
|
||||
render(<NoFearShow onClose={vi.fn()} />)
|
||||
|
||||
// The title only exists from the frame after the anthem state flipped.
|
||||
frame(71.2)
|
||||
expect(stubs.assembly).toHaveLength(0)
|
||||
|
||||
frame(72)
|
||||
expect(stubs.assembly).toHaveLength(1)
|
||||
expect(stubs.assembly[0].init).toHaveBeenCalledWith(
|
||||
'NO FEAR',
|
||||
expect.any(String),
|
||||
{ left: 100, top: 200, width: 300, height: 60 },
|
||||
800,
|
||||
600,
|
||||
)
|
||||
const [drawCtx, progress, fade, drawT] = stubs.assembly[0].draw.mock.calls[0] as [unknown, number, number, number]
|
||||
expect(drawCtx).toBe(ctxStub)
|
||||
expect(progress).toBeCloseTo(0.1875, 6)
|
||||
expect(fade).toBe(0)
|
||||
expect(drawT).toBe(72)
|
||||
expect(screen.getByRole('heading', { level: 1 })).toHaveClass('fz-word-hidden')
|
||||
|
||||
frame(76)
|
||||
expect(stubs.assembly).toHaveLength(1)
|
||||
expect(screen.getByRole('heading', { level: 1 })).not.toHaveClass('fz-word-hidden')
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SHOW-031: tears down chrome, listeners and audio on unmount', () => {
|
||||
const { unmount } = render(<NoFearShow onClose={vi.fn()} />)
|
||||
const onClose = vi.fn()
|
||||
const layoutCalls = scene().layout.mock.calls.length
|
||||
|
||||
unmount()
|
||||
|
||||
expect(document.body).not.toHaveClass('fz-show-open')
|
||||
expect(audio().dispose).toHaveBeenCalledTimes(1)
|
||||
expect(cancelSpy).toHaveBeenCalled()
|
||||
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
window.dispatchEvent(new Event('pointerdown'))
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
|
||||
|
||||
expect(scene().layout.mock.calls.length).toBe(layoutCalls)
|
||||
expect(audio().resume).not.toHaveBeenCalled()
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SHOW-032: reduced motion renders the finale as one static frame without audio', () => {
|
||||
setReducedMotion(true)
|
||||
|
||||
render(<NoFearShow onClose={vi.fn()} />)
|
||||
|
||||
expect(audio().start).not.toHaveBeenCalled()
|
||||
expect(audio().setAct).toHaveBeenCalledWith('anthem')
|
||||
expect(requestAnimationFrame).not.toHaveBeenCalled()
|
||||
expect(scene().draw).toHaveBeenCalledTimes(1)
|
||||
const [, state, t] = scene().draw.mock.calls[0] as [unknown, { particles: number; opacity: number }, number]
|
||||
expect(state.particles).toBe(0)
|
||||
expect(state.opacity).toBe(1)
|
||||
expect(t).toBe(79)
|
||||
expect(screen.getByRole('heading', { level: 1 })).not.toHaveClass('fz-word-assembled')
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SHOW-033: still runs the show when the canvas has no 2d context', () => {
|
||||
ctxStub = null
|
||||
|
||||
render(<NoFearShow onClose={vi.fn()} />)
|
||||
frame(12.1)
|
||||
|
||||
expect(lineText()).toBe(LINES.fearTool)
|
||||
expect(scene().draw).not.toHaveBeenCalled()
|
||||
expect(stubs.assembly).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SHOW-034: dims the world through the blackout and brings it back with the hope act', () => {
|
||||
render(<NoFearShow onClose={vi.fn()} />)
|
||||
|
||||
frame(20)
|
||||
expect(lastSceneState().opacity).toBe(1)
|
||||
|
||||
frame(27)
|
||||
expect(lastSceneState().opacity).toBe(0)
|
||||
|
||||
frame(31)
|
||||
expect(lastSceneState().opacity).toBeGreaterThan(0.8)
|
||||
expect(lastSceneState().opacity).toBeLessThan(1)
|
||||
|
||||
frame(32)
|
||||
expect(lastSceneState().opacity).toBe(1)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SHOW-035: falls back to a pixel ratio of 1 when the browser reports none', () => {
|
||||
Object.defineProperty(window, 'devicePixelRatio', { configurable: true, writable: true, value: 0 })
|
||||
|
||||
render(<NoFearShow onClose={vi.fn()} />)
|
||||
|
||||
expect(canvas().width).toBe(800)
|
||||
expect(ctxStub?.setTransform).toHaveBeenCalledWith(1, 0, 0, 1, 0, 0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,329 @@
|
||||
// FE-NOFEAR-ASM-001 to FE-NOFEAR-ASM-017
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { TextAssembly } from './noFearAssembly'
|
||||
|
||||
interface FillRecord {
|
||||
fillStyle: string
|
||||
globalAlpha: number
|
||||
composite: string
|
||||
x: number
|
||||
y: number
|
||||
r: number
|
||||
}
|
||||
|
||||
interface FakeCtx {
|
||||
font: string
|
||||
textAlign: string
|
||||
textBaseline: string
|
||||
fillStyle: string
|
||||
globalAlpha: number
|
||||
globalCompositeOperation: string
|
||||
save: ReturnType<typeof vi.fn>
|
||||
restore: ReturnType<typeof vi.fn>
|
||||
beginPath: ReturnType<typeof vi.fn>
|
||||
fillText: ReturnType<typeof vi.fn>
|
||||
arc: ReturnType<typeof vi.fn>
|
||||
fill: ReturnType<typeof vi.fn>
|
||||
getImageData: ReturnType<typeof vi.fn>
|
||||
fills: FillRecord[]
|
||||
}
|
||||
|
||||
// jsdom has no canvas backend, so every 2d context in these tests is a recorder.
|
||||
// getImageData replays `alphaAt` so the sampled letterform is fully deterministic.
|
||||
let alphaAt: (x: number, y: number) => number = () => 0
|
||||
let contextAvailable = true
|
||||
let contexts: FakeCtx[] = []
|
||||
const originalGetContext = HTMLCanvasElement.prototype.getContext
|
||||
|
||||
function makeCtx(): FakeCtx {
|
||||
let lastArc: { x: number; y: number; r: number } | null = null
|
||||
const ctx: FakeCtx = {
|
||||
font: '',
|
||||
textAlign: '',
|
||||
textBaseline: '',
|
||||
fillStyle: '',
|
||||
globalAlpha: 1,
|
||||
globalCompositeOperation: 'source-over',
|
||||
save: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
beginPath: vi.fn(),
|
||||
fillText: vi.fn(),
|
||||
arc: vi.fn((x: number, y: number, r: number) => { lastArc = { x, y, r } }),
|
||||
fill: vi.fn(() => {
|
||||
const a = lastArc ?? { x: NaN, y: NaN, r: NaN }
|
||||
ctx.fills.push({
|
||||
fillStyle: ctx.fillStyle,
|
||||
globalAlpha: ctx.globalAlpha,
|
||||
composite: ctx.globalCompositeOperation,
|
||||
x: a.x,
|
||||
y: a.y,
|
||||
r: a.r,
|
||||
})
|
||||
}),
|
||||
getImageData: vi.fn((_x: number, _y: number, w: number, h: number) => {
|
||||
const data = new Uint8ClampedArray(w * h * 4)
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++) data[(y * w + x) * 4 + 3] = alphaAt(x, y)
|
||||
}
|
||||
return { data }
|
||||
}),
|
||||
fills: [],
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
/** Math.random replaced by a repeating sequence — init consumes exactly 6 per particle. */
|
||||
function cycleRandom(values: number[]): void {
|
||||
let i = 0
|
||||
vi.spyOn(Math, 'random').mockImplementation(() => values[i++ % values.length])
|
||||
}
|
||||
|
||||
const asCtx = (c: FakeCtx) => c as unknown as CanvasRenderingContext2D
|
||||
|
||||
beforeEach(() => {
|
||||
alphaAt = () => 0
|
||||
contextAvailable = true
|
||||
contexts = []
|
||||
HTMLCanvasElement.prototype.getContext = vi.fn(() => {
|
||||
if (!contextAvailable) return null
|
||||
const c = makeCtx()
|
||||
contexts.push(c)
|
||||
return c
|
||||
}) as unknown as HTMLCanvasElement['getContext']
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
HTMLCanvasElement.prototype.getContext = originalGetContext
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
const BOX = { left: 100, top: 50, width: 12, height: 12 }
|
||||
|
||||
describe('TextAssembly.init', () => {
|
||||
it('FE-NOFEAR-ASM-001: turns every opaque raster cell into a particle aimed at its screen position', () => {
|
||||
alphaAt = (x, y) => (x === 3 && y === 6 ? 200 : 0)
|
||||
cycleRandom([0.1, 0.1, 0.1, 0.1, 0.1, 0])
|
||||
|
||||
const a = new TextAssembly()
|
||||
a.init('KEINE ANGST', 'bold 64px Inter', BOX, 1000, 600)
|
||||
expect(a.isReady()).toBe(true)
|
||||
|
||||
const main = makeCtx()
|
||||
a.draw(asCtx(main), 1, 0, 0)
|
||||
expect(main.fills).toHaveLength(1)
|
||||
expect(main.fills[0].x).toBeCloseTo(103, 6)
|
||||
expect(main.fills[0].y).toBeCloseTo(56, 6)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-ASM-002: rasters the text centred in the offscreen box', () => {
|
||||
alphaAt = () => 0
|
||||
cycleRandom([0.1])
|
||||
|
||||
new TextAssembly().init('KEINE ANGST', 'bold 64px Inter', BOX, 1000, 600)
|
||||
|
||||
const off = contexts[0]
|
||||
expect(off.font).toBe('bold 64px Inter')
|
||||
expect(off.textAlign).toBe('center')
|
||||
expect(off.textBaseline).toBe('middle')
|
||||
expect(off.fillStyle).toBe('#fff')
|
||||
expect(off.fillText).toHaveBeenCalledWith('KEINE ANGST', 6, 6)
|
||||
expect(off.getImageData).toHaveBeenCalledWith(0, 0, 12, 12)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-ASM-003: caps the raster at 700px and scales the font shorthand with it', () => {
|
||||
alphaAt = () => 0
|
||||
cycleRandom([0.1])
|
||||
|
||||
new TextAssembly().init('KEINE ANGST', 'bold 200px Inter', { left: 0, top: 0, width: 1400, height: 6 }, 1000, 600)
|
||||
|
||||
const off = contexts[0]
|
||||
expect(off.font).toBe('bold 100px Inter')
|
||||
expect(off.fillText).toHaveBeenCalledWith('KEINE ANGST', 350, 1.5)
|
||||
expect(off.getImageData).toHaveBeenCalledWith(0, 0, 700, 3)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-ASM-004: maps raster coordinates back through the scale factor', () => {
|
||||
alphaAt = (x, y) => (x === 6 && y === 0 ? 200 : 0)
|
||||
cycleRandom([0.1, 0.1, 0.1, 0.1, 0.1, 0])
|
||||
|
||||
const a = new TextAssembly()
|
||||
a.init('X', 'bold 200px Inter', { left: 0, top: 0, width: 1400, height: 6 }, 1000, 600)
|
||||
|
||||
const main = makeCtx()
|
||||
a.draw(asCtx(main), 1, 0, 0)
|
||||
expect(main.fills).toHaveLength(1)
|
||||
// raster x 6 at scale 0.5 lands at screen x 12
|
||||
expect(main.fills[0].x).toBeCloseTo(12, 6)
|
||||
expect(main.fills[0].y).toBeCloseTo(0, 6)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-ASM-005: treats alpha 128 as transparent and 129 as solid', () => {
|
||||
alphaAt = (x, y) => (y === 0 && x === 0 ? 128 : y === 0 && x === 3 ? 129 : 0)
|
||||
cycleRandom([0.1, 0.1, 0.1, 0.1, 0.1, 0])
|
||||
|
||||
const a = new TextAssembly()
|
||||
a.init('X', 'bold 64px Inter', { left: 0, top: 0, width: 12, height: 12 }, 1000, 600)
|
||||
|
||||
const main = makeCtx()
|
||||
a.draw(asCtx(main), 1, 0, 0)
|
||||
expect(main.fills).toHaveLength(1)
|
||||
expect(main.fills[0].x).toBeCloseTo(3, 6)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-ASM-006: samples the raster on a 3px grid', () => {
|
||||
alphaAt = () => 255
|
||||
cycleRandom([0.1, 0.1, 0.1, 0.1, 0.1, 0])
|
||||
|
||||
const a = new TextAssembly()
|
||||
a.init('X', 'bold 64px Inter', { left: 0, top: 0, width: 12, height: 12 }, 1000, 600)
|
||||
|
||||
const main = makeCtx()
|
||||
a.draw(asCtx(main), 1, 0, 0)
|
||||
// 12x12 raster stepped by 3 → 4x4 sample points
|
||||
expect(main.fills).toHaveLength(16)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-ASM-007: is ready but silent when the raster is empty', () => {
|
||||
alphaAt = () => 0
|
||||
cycleRandom([0.1])
|
||||
|
||||
const a = new TextAssembly()
|
||||
a.init('X', 'bold 64px Inter', BOX, 1000, 600)
|
||||
expect(a.isReady()).toBe(true)
|
||||
|
||||
const main = makeCtx()
|
||||
a.draw(asCtx(main), 1, 0, 0)
|
||||
expect(main.save).toHaveBeenCalledTimes(1)
|
||||
expect(main.restore).toHaveBeenCalledTimes(1)
|
||||
expect(main.fills).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-ASM-008: stays unready when no 2d context is available', () => {
|
||||
contextAvailable = false
|
||||
alphaAt = () => 255
|
||||
cycleRandom([0.1])
|
||||
|
||||
const a = new TextAssembly()
|
||||
a.init('X', 'bold 64px Inter', BOX, 1000, 600)
|
||||
expect(a.isReady()).toBe(false)
|
||||
|
||||
const main = makeCtx()
|
||||
a.draw(asCtx(main), 1, 0, 0)
|
||||
expect(main.save).not.toHaveBeenCalled()
|
||||
expect(main.fills).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-ASM-009: keeps a degenerate box at one raster pixel', () => {
|
||||
alphaAt = () => 255
|
||||
cycleRandom([0.1, 0.1, 0.1, 0.1, 0.1, 0])
|
||||
|
||||
const a = new TextAssembly()
|
||||
a.init('X', 'bold 64px Inter', { left: 5, top: 7, width: 0, height: 0 }, 1000, 600)
|
||||
|
||||
expect(contexts[0].getImageData).toHaveBeenCalledWith(0, 0, 1, 1)
|
||||
const main = makeCtx()
|
||||
a.draw(asCtx(main), 1, 0, 0)
|
||||
expect(main.fills).toHaveLength(1)
|
||||
expect(main.fills[0].x).toBeCloseTo(5, 6)
|
||||
expect(main.fills[0].y).toBeCloseTo(7, 6)
|
||||
})
|
||||
})
|
||||
|
||||
describe('TextAssembly.draw', () => {
|
||||
function seeded(values: number[], box = BOX): TextAssembly {
|
||||
alphaAt = (x, y) => (x === 0 && y === 0 ? 200 : 0)
|
||||
cycleRandom(values)
|
||||
const a = new TextAssembly()
|
||||
a.init('X', 'bold 64px Inter', box, 1000, 600)
|
||||
return a
|
||||
}
|
||||
|
||||
it('FE-NOFEAR-ASM-010: does nothing before init', () => {
|
||||
const main = makeCtx()
|
||||
new TextAssembly().draw(asCtx(main), 0.5, 0, 0)
|
||||
expect(main.save).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-ASM-011: bails out once the DOM title has fully taken over', () => {
|
||||
const a = seeded([0.1, 0.1, 0.1, 0.1, 0.1, 0])
|
||||
const main = makeCtx()
|
||||
a.draw(asCtx(main), 1, 1, 0)
|
||||
expect(main.save).not.toHaveBeenCalled()
|
||||
expect(main.fills).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-ASM-012: skips particles whose delay has not elapsed', () => {
|
||||
const a = seeded([0.1, 0.1, 0.1, 0.1, 0.1, 0]) // delay 0.045
|
||||
const main = makeCtx()
|
||||
a.draw(asCtx(main), 0.04, 0, 0)
|
||||
expect(main.save).toHaveBeenCalledTimes(1)
|
||||
expect(main.restore).toHaveBeenCalledTimes(1)
|
||||
expect(main.fills).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-ASM-013: settled particles cool to ivory, shrink and additively blend', () => {
|
||||
const a = seeded([0.1, 0.1, 0.1, 0.1, 0.1, 0]) // size 1.02
|
||||
const main = makeCtx()
|
||||
a.draw(asCtx(main), 1, 0, 0)
|
||||
|
||||
expect(main.fills).toEqual([
|
||||
expect.objectContaining({ fillStyle: 'rgb(247, 240, 226)', composite: 'lighter' }),
|
||||
])
|
||||
expect(main.fills[0].globalAlpha).toBeCloseTo(0.9, 6)
|
||||
expect(main.fills[0].r).toBeCloseTo(1.02 * 0.85, 6)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-ASM-014: in-flight particles glow warm and flicker with t', () => {
|
||||
const a = seeded([0.1, 0.1, 0.1, 0.1, 0.1, 0]) // delay 0.045, size 1.02, seed 0
|
||||
const main = makeCtx()
|
||||
a.draw(asCtx(main), 0.5, 0, 0)
|
||||
|
||||
const local = 1 - (1 - (0.5 - 0.045) / 0.955) ** 3
|
||||
expect(main.fills).toHaveLength(1)
|
||||
expect(main.fills[0].fillStyle).toBe('rgb(255, 205, 130)')
|
||||
expect(main.fills[0].r).toBeCloseTo(1.02, 6)
|
||||
// sin(0) → flicker sits at its 0.65 floor
|
||||
expect(main.fills[0].globalAlpha).toBeCloseTo(0.75 * 0.65, 6)
|
||||
expect(main.fills[0].x).toBeCloseTo(-30 + (100 + 30) * local, 6)
|
||||
expect(main.fills[0].y).toBeCloseTo(60 + (50 - 60) * local, 6)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-ASM-015: the flicker peaks a quarter period into the sine', () => {
|
||||
const a = seeded([0.1, 0.1, 0.1, 0.1, 0.1, 0])
|
||||
const main = makeCtx()
|
||||
a.draw(asCtx(main), 0.5, 0, Math.PI / 10)
|
||||
expect(main.fills[0].globalAlpha).toBeCloseTo(0.75, 6)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-ASM-016: fade dissolves the particle layer', () => {
|
||||
const a = seeded([0.1, 0.1, 0.1, 0.1, 0.1, 0])
|
||||
const main = makeCtx()
|
||||
a.draw(asCtx(main), 1, 0.5, 0)
|
||||
expect(main.fills[0].globalAlpha).toBeCloseTo(0.45, 6)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-ASM-017: side entries start beyond the right edge and fly in', () => {
|
||||
// fromSide true, second draw >= 0.5 → spawn at screenW + 30
|
||||
const a = seeded([0.1, 0.9, 0.2, 0.4, 0.5, 0.3], { left: 0, top: 0, width: 12, height: 12 })
|
||||
const main = makeCtx()
|
||||
a.draw(asCtx(main), 0.18, 0, 0)
|
||||
expect(main.fills).toHaveLength(0)
|
||||
|
||||
a.draw(asCtx(main), 0.59, 0, 0)
|
||||
expect(main.fills).toHaveLength(1)
|
||||
expect(main.fills[0].x).toBeCloseTo(128.75, 6)
|
||||
expect(main.fills[0].y).toBeCloseTo(15, 6)
|
||||
expect(main.fills[0].r).toBeCloseTo(1.5, 6)
|
||||
expect(main.fills[0].globalAlpha).toBeCloseTo(0.75 * (0.65 + 0.35 * Math.sin(2.1)), 6)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-ASM-018: ground entries rise from below the viewport', () => {
|
||||
// fromSide false → sx inside the viewport, sy below screenH
|
||||
const a = seeded([0.5, 0.5, 0.5, 0.5, 0.5, 0.5], { left: 0, top: 0, width: 12, height: 12 })
|
||||
const main = makeCtx()
|
||||
a.draw(asCtx(main), 0.6125, 0, 0)
|
||||
expect(main.fills).toHaveLength(1)
|
||||
expect(main.fills[0].x).toBeCloseTo(62.5, 6)
|
||||
expect(main.fills[0].y).toBeCloseTo(82.5, 6)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,969 @@
|
||||
// FE-NOFEAR-AUD-001 to FE-NOFEAR-AUD-043
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { NoFearAudio } from './noFearAudio'
|
||||
|
||||
// jsdom ships no Web Audio implementation, so the graph below is a recorder:
|
||||
// every node keeps its outgoing connections, every AudioParam keeps the calls
|
||||
// that were scheduled on it, and the context clock is driven by `nowMs` so the
|
||||
// fake timers and the audio clock stay in lockstep.
|
||||
|
||||
type NodeKind =
|
||||
| 'gain'
|
||||
| 'oscillator'
|
||||
| 'bufferSource'
|
||||
| 'biquad'
|
||||
| 'convolver'
|
||||
| 'compressor'
|
||||
| 'delay'
|
||||
| 'destination'
|
||||
|
||||
let created: FakeNode[] = []
|
||||
let contexts: FakeAudioContext[] = []
|
||||
let nowMs = 0
|
||||
|
||||
const behavior = {
|
||||
state: 'running' as AudioContextState,
|
||||
resumeRejects: false,
|
||||
suspendRejects: false,
|
||||
closeRejects: false,
|
||||
}
|
||||
|
||||
class FakeAudioParam {
|
||||
value: number
|
||||
setValueAtTime = vi.fn((value: number, _at: number) => {
|
||||
this.value = value
|
||||
})
|
||||
linearRampToValueAtTime = vi.fn((_value: number, _at: number) => undefined)
|
||||
exponentialRampToValueAtTime = vi.fn((_value: number, _at: number) => undefined)
|
||||
setTargetAtTime = vi.fn((_value: number, _at: number, _timeConstant: number) => undefined)
|
||||
cancelScheduledValues = vi.fn((_at: number) => undefined)
|
||||
|
||||
constructor(value = 0) {
|
||||
this.value = value
|
||||
}
|
||||
}
|
||||
|
||||
class FakeNode {
|
||||
kind: NodeKind
|
||||
connections: unknown[] = []
|
||||
connect: ReturnType<typeof vi.fn>
|
||||
disconnect: ReturnType<typeof vi.fn>
|
||||
|
||||
constructor(kind: NodeKind) {
|
||||
this.kind = kind
|
||||
this.connect = vi.fn((target: unknown) => {
|
||||
this.connections.push(target)
|
||||
return target
|
||||
})
|
||||
this.disconnect = vi.fn(() => {
|
||||
this.connections = []
|
||||
})
|
||||
created.push(this)
|
||||
}
|
||||
}
|
||||
|
||||
class FakeSource extends FakeNode {
|
||||
started: number[] = []
|
||||
stopped: number[] = []
|
||||
throwOnStop = false
|
||||
start = vi.fn((at = 0) => {
|
||||
this.started.push(at)
|
||||
})
|
||||
stop = vi.fn((at = 0) => {
|
||||
if (this.throwOnStop) throw new Error('InvalidStateError')
|
||||
this.stopped.push(at)
|
||||
})
|
||||
}
|
||||
|
||||
class FakeOscillator extends FakeSource {
|
||||
type = 'sine'
|
||||
frequency = new FakeAudioParam(440)
|
||||
detune = new FakeAudioParam(0)
|
||||
|
||||
constructor() {
|
||||
super('oscillator')
|
||||
}
|
||||
}
|
||||
|
||||
class FakeBufferSource extends FakeSource {
|
||||
buffer: FakeAudioBuffer | null = null
|
||||
loop = false
|
||||
playbackRate = new FakeAudioParam(1)
|
||||
|
||||
constructor() {
|
||||
super('bufferSource')
|
||||
}
|
||||
}
|
||||
|
||||
class FakeGain extends FakeNode {
|
||||
gain = new FakeAudioParam(1)
|
||||
|
||||
constructor() {
|
||||
super('gain')
|
||||
}
|
||||
}
|
||||
|
||||
class FakeBiquadFilter extends FakeNode {
|
||||
type = 'lowpass'
|
||||
frequency = new FakeAudioParam(350)
|
||||
Q = new FakeAudioParam(1)
|
||||
detune = new FakeAudioParam(0)
|
||||
gain = new FakeAudioParam(0)
|
||||
|
||||
constructor() {
|
||||
super('biquad')
|
||||
}
|
||||
}
|
||||
|
||||
class FakeConvolver extends FakeNode {
|
||||
buffer: FakeAudioBuffer | null = null
|
||||
normalize = true
|
||||
|
||||
constructor() {
|
||||
super('convolver')
|
||||
}
|
||||
}
|
||||
|
||||
class FakeCompressor extends FakeNode {
|
||||
threshold = new FakeAudioParam(-24)
|
||||
knee = new FakeAudioParam(30)
|
||||
ratio = new FakeAudioParam(12)
|
||||
attack = new FakeAudioParam(0.003)
|
||||
release = new FakeAudioParam(0.25)
|
||||
|
||||
constructor() {
|
||||
super('compressor')
|
||||
}
|
||||
}
|
||||
|
||||
class FakeDelay extends FakeNode {
|
||||
delayTime = new FakeAudioParam(0)
|
||||
maxDelayTime: number
|
||||
|
||||
constructor(maxDelayTime: number) {
|
||||
super('delay')
|
||||
this.maxDelayTime = maxDelayTime
|
||||
}
|
||||
}
|
||||
|
||||
class FakeAudioBuffer {
|
||||
numberOfChannels: number
|
||||
length: number
|
||||
sampleRate: number
|
||||
duration: number
|
||||
private channels: Float32Array[]
|
||||
|
||||
constructor(numberOfChannels: number, length: number, sampleRate: number) {
|
||||
this.numberOfChannels = numberOfChannels
|
||||
this.length = length
|
||||
this.sampleRate = sampleRate
|
||||
this.duration = length / sampleRate
|
||||
this.channels = Array.from({ length: numberOfChannels }, () => new Float32Array(length))
|
||||
}
|
||||
|
||||
getChannelData(channel: number): Float32Array {
|
||||
return this.channels[channel]
|
||||
}
|
||||
}
|
||||
|
||||
class FakeAudioContext {
|
||||
state: AudioContextState
|
||||
// A low rate keeps the procedurally filled impulse response and noise buffer
|
||||
// small; the code only ever multiplies against it.
|
||||
sampleRate = 8000
|
||||
destination = new FakeNode('destination')
|
||||
resume = vi.fn(() =>
|
||||
behavior.resumeRejects ? Promise.reject(new Error('resume blocked')) : Promise.resolve(),
|
||||
)
|
||||
suspend = vi.fn(() =>
|
||||
behavior.suspendRejects ? Promise.reject(new Error('suspend blocked')) : Promise.resolve(),
|
||||
)
|
||||
close = vi.fn(() =>
|
||||
behavior.closeRejects ? Promise.reject(new Error('close failed')) : Promise.resolve(),
|
||||
)
|
||||
decodeAudioData = vi.fn(() => Promise.resolve(new FakeAudioBuffer(2, 16, 8000)))
|
||||
|
||||
constructor() {
|
||||
this.state = behavior.state
|
||||
contexts.push(this)
|
||||
}
|
||||
|
||||
get currentTime(): number {
|
||||
return nowMs / 1000
|
||||
}
|
||||
|
||||
createGain(): FakeGain {
|
||||
return new FakeGain()
|
||||
}
|
||||
createOscillator(): FakeOscillator {
|
||||
return new FakeOscillator()
|
||||
}
|
||||
createBufferSource(): FakeBufferSource {
|
||||
return new FakeBufferSource()
|
||||
}
|
||||
createBiquadFilter(): FakeBiquadFilter {
|
||||
return new FakeBiquadFilter()
|
||||
}
|
||||
createConvolver(): FakeConvolver {
|
||||
return new FakeConvolver()
|
||||
}
|
||||
createDynamicsCompressor(): FakeCompressor {
|
||||
return new FakeCompressor()
|
||||
}
|
||||
createDelay(maxDelayTime = 1): FakeDelay {
|
||||
return new FakeDelay(maxDelayTime)
|
||||
}
|
||||
createBuffer(numberOfChannels: number, length: number, sampleRate: number): FakeAudioBuffer {
|
||||
return new FakeAudioBuffer(numberOfChannels, length, sampleRate)
|
||||
}
|
||||
}
|
||||
|
||||
// ── query helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
function mark(): number {
|
||||
return created.length
|
||||
}
|
||||
|
||||
function since(from: number): FakeNode[] {
|
||||
return created.slice(from)
|
||||
}
|
||||
|
||||
function oscs(list: FakeNode[]): FakeOscillator[] {
|
||||
return list.filter((n) => n.kind === 'oscillator') as FakeOscillator[]
|
||||
}
|
||||
|
||||
function bufs(list: FakeNode[]): FakeBufferSource[] {
|
||||
return list.filter((n) => n.kind === 'bufferSource') as FakeBufferSource[]
|
||||
}
|
||||
|
||||
function gains(list: FakeNode[]): FakeGain[] {
|
||||
return list.filter((n) => n.kind === 'gain') as FakeGain[]
|
||||
}
|
||||
|
||||
function filters(list: FakeNode[]): FakeBiquadFilter[] {
|
||||
return list.filter((n) => n.kind === 'biquad') as FakeBiquadFilter[]
|
||||
}
|
||||
|
||||
function only<T>(list: T[]): T {
|
||||
expect(list).toHaveLength(1)
|
||||
return list[0]
|
||||
}
|
||||
|
||||
function ctx(): FakeAudioContext {
|
||||
return contexts[contexts.length - 1]
|
||||
}
|
||||
|
||||
function compressor(): FakeCompressor {
|
||||
return created.find((n) => n.kind === 'compressor') as FakeCompressor
|
||||
}
|
||||
|
||||
function masterGain(): FakeGain {
|
||||
const limiter = compressor()
|
||||
return gains(created).find((g) => g.connections.includes(limiter)) as FakeGain
|
||||
}
|
||||
|
||||
function reverbIn(): FakeGain {
|
||||
const convolver = created.find((n) => n.kind === 'convolver')
|
||||
return gains(created).find((g) => g.connections.includes(convolver)) as FakeGain
|
||||
}
|
||||
|
||||
/** How much of `node` is sent into the hall, or undefined when it stays dry. */
|
||||
function hallSend(node: FakeNode): number | undefined {
|
||||
const hall = reverbIn()
|
||||
const send = node.connections.find(
|
||||
(target) => target instanceof FakeGain && target.connections.includes(hall),
|
||||
)
|
||||
return (send as FakeGain | undefined)?.gain.value
|
||||
}
|
||||
|
||||
/** Start times of the heartbeat's sine bodies — its 58 Hz drop is the signature. */
|
||||
function thumpTimes(list: FakeNode[]): number[] {
|
||||
return oscs(list)
|
||||
.filter((o) => o.frequency.setValueAtTime.mock.calls.some((call) => call[0] === 58))
|
||||
.map((o) => o.started[0])
|
||||
}
|
||||
|
||||
function rms(data: Float32Array, from: number, to: number): number {
|
||||
let sum = 0
|
||||
for (let i = from; i < to; i++) sum += data[i] * data[i]
|
||||
return Math.sqrt(sum / (to - from))
|
||||
}
|
||||
|
||||
/** Advances the audio clock and the timer queue together, in interval-sized steps. */
|
||||
function advance(ms: number): void {
|
||||
let left = ms
|
||||
while (left > 0) {
|
||||
const step = Math.min(50, left)
|
||||
nowMs += step
|
||||
vi.advanceTimersByTime(step)
|
||||
left -= step
|
||||
}
|
||||
}
|
||||
|
||||
describe('NoFearAudio', () => {
|
||||
let audio: NoFearAudio
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
created = []
|
||||
contexts = []
|
||||
nowMs = 0
|
||||
behavior.state = 'running'
|
||||
behavior.resumeRejects = false
|
||||
behavior.suspendRejects = false
|
||||
behavior.closeRejects = false
|
||||
vi.stubGlobal('AudioContext', FakeAudioContext)
|
||||
vi.stubGlobal('webkitAudioContext', undefined)
|
||||
audio = new NoFearAudio()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('start', () => {
|
||||
it('FE-NOFEAR-AUD-001: routes master through a limiter into the destination', () => {
|
||||
audio.start()
|
||||
|
||||
const limiter = compressor()
|
||||
expect(limiter.threshold.value).toBe(-12)
|
||||
expect(limiter.knee.value).toBe(22)
|
||||
expect(limiter.ratio.value).toBe(12)
|
||||
expect(limiter.connections).toEqual([ctx().destination])
|
||||
expect(masterGain().gain.value).toBe(0.9)
|
||||
expect(masterGain().connections).toEqual([limiter])
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-AUD-002: is idempotent — a second call keeps the first context', () => {
|
||||
audio.start()
|
||||
audio.start()
|
||||
|
||||
expect(contexts).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-AUD-003: without a Web Audio constructor every entry point stays a no-op', () => {
|
||||
vi.stubGlobal('AudioContext', undefined)
|
||||
|
||||
audio.start()
|
||||
audio.setMuted(true)
|
||||
audio.resume()
|
||||
audio.setSuspended(true)
|
||||
audio.swell()
|
||||
audio.impact()
|
||||
audio.setAct('fear')
|
||||
audio.dispose()
|
||||
|
||||
expect(contexts).toHaveLength(0)
|
||||
expect(created).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-AUD-004: falls back to the prefixed webkitAudioContext', () => {
|
||||
vi.stubGlobal('AudioContext', undefined)
|
||||
vi.stubGlobal('webkitAudioContext', FakeAudioContext)
|
||||
|
||||
audio.start()
|
||||
|
||||
expect(contexts).toHaveLength(1)
|
||||
expect(masterGain().gain.value).toBe(0.9)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-AUD-005: re-arms a context the browser started suspended', () => {
|
||||
behavior.state = 'suspended'
|
||||
|
||||
audio.start()
|
||||
|
||||
expect(ctx().resume).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-AUD-006: swallows a rejected resume on start', async () => {
|
||||
behavior.state = 'suspended'
|
||||
behavior.resumeRejects = true
|
||||
|
||||
expect(() => audio.start()).not.toThrow()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(ctx().resume).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-AUD-007: builds a 3.4s stereo impulse response that decays', () => {
|
||||
audio.start()
|
||||
|
||||
const convolver = created.find((n) => n.kind === 'convolver') as FakeConvolver
|
||||
const ir = convolver.buffer as FakeAudioBuffer
|
||||
expect(ir.numberOfChannels).toBe(2)
|
||||
expect(ir.length).toBe(Math.floor(8000 * 3.4))
|
||||
const left = ir.getChannelData(0)
|
||||
expect(rms(left, 0, 400)).toBeGreaterThan(rms(left, left.length - 400, left.length))
|
||||
expect(rms(ir.getChannelData(1), 0, 400)).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-AUD-008: returns the hall through a 0.5 wet gain on the master bus', () => {
|
||||
audio.start()
|
||||
|
||||
const convolver = created.find((n) => n.kind === 'convolver') as FakeConvolver
|
||||
expect(reverbIn().connections).toEqual([convolver])
|
||||
const wet = gains(created).find((g) => convolver.connections.includes(g)) as FakeGain
|
||||
expect(wet.gain.value).toBe(0.5)
|
||||
expect(wet.connections).toEqual([masterGain()])
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-AUD-009: fills a 2s noise buffer that every texture reuses', () => {
|
||||
audio.start()
|
||||
const m = mark()
|
||||
audio.setAct('fear')
|
||||
|
||||
const noise = bufs(since(m))
|
||||
expect(noise.length).toBeGreaterThan(0)
|
||||
const buffer = noise[0].buffer as FakeAudioBuffer
|
||||
expect(buffer.length).toBe(8000 * 2)
|
||||
expect(buffer.numberOfChannels).toBe(1)
|
||||
const data = buffer.getChannelData(0)
|
||||
expect(rms(data, 0, 1000)).toBeGreaterThan(0.4)
|
||||
expect(Math.max(...data.slice(0, 1000))).toBeLessThan(1)
|
||||
expect(Math.min(...data.slice(0, 1000))).toBeGreaterThan(-1)
|
||||
// all textures share the one buffer
|
||||
expect(noise.every((n) => n.buffer === buffer)).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-AUD-010: starts silent when the show was muted before the gesture', () => {
|
||||
audio.setMuted(true)
|
||||
audio.start()
|
||||
|
||||
expect(masterGain().gain.value).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('transport', () => {
|
||||
it('FE-NOFEAR-AUD-011: setMuted ramps the master down and back up', () => {
|
||||
audio.start()
|
||||
const master = masterGain()
|
||||
|
||||
audio.setMuted(true)
|
||||
expect(master.gain.setTargetAtTime).toHaveBeenLastCalledWith(0, 0, 0.05)
|
||||
|
||||
advance(500)
|
||||
audio.setMuted(false)
|
||||
expect(master.gain.setTargetAtTime).toHaveBeenLastCalledWith(0.9, 0.5, 0.05)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-AUD-012: unmuting recovers a context the browser refused to start', () => {
|
||||
audio.start()
|
||||
ctx().state = 'suspended'
|
||||
|
||||
audio.setMuted(false)
|
||||
|
||||
expect(ctx().resume).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-AUD-013: muting never tries to resume', () => {
|
||||
audio.start()
|
||||
ctx().state = 'suspended'
|
||||
|
||||
audio.setMuted(true)
|
||||
|
||||
expect(ctx().resume).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-AUD-014: resume only touches a suspended context', () => {
|
||||
audio.start()
|
||||
|
||||
audio.resume()
|
||||
expect(ctx().resume).not.toHaveBeenCalled()
|
||||
|
||||
ctx().state = 'suspended'
|
||||
audio.resume()
|
||||
expect(ctx().resume).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-AUD-043: a browser that rejects resume/suspend never surfaces the error', async () => {
|
||||
behavior.resumeRejects = true
|
||||
behavior.suspendRejects = true
|
||||
audio.start()
|
||||
ctx().state = 'suspended'
|
||||
|
||||
audio.setMuted(false)
|
||||
audio.resume()
|
||||
audio.setSuspended(true)
|
||||
audio.setSuspended(false)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(ctx().resume).toHaveBeenCalledTimes(3)
|
||||
expect(ctx().suspend).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-AUD-015: setSuspended freezes and unfreezes with the show clock', () => {
|
||||
audio.start()
|
||||
|
||||
audio.setSuspended(true)
|
||||
expect(ctx().suspend).toHaveBeenCalledTimes(1)
|
||||
expect(ctx().resume).not.toHaveBeenCalled()
|
||||
|
||||
audio.setSuspended(false)
|
||||
expect(ctx().resume).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('one-shots', () => {
|
||||
it('FE-NOFEAR-AUD-016: swell blooms a 55 Hz sine over 1.6s and dies at +5', () => {
|
||||
audio.start()
|
||||
const m = mark()
|
||||
|
||||
audio.swell()
|
||||
|
||||
const o = only(oscs(since(m)))
|
||||
expect(o.type).toBe('sine')
|
||||
expect(o.frequency.value).toBe(55)
|
||||
expect(o.started[0]).toBeCloseTo(0.05)
|
||||
expect(o.stopped[0]).toBeCloseTo(5.25)
|
||||
const envelope = gains(since(m))[0]
|
||||
expect(o.connections).toEqual([envelope])
|
||||
const ramps = envelope.gain.exponentialRampToValueAtTime.mock.calls
|
||||
expect(ramps[0][0]).toBeCloseTo(0.22)
|
||||
expect(ramps[0][1]).toBeCloseTo(1.65)
|
||||
expect(ramps[1][0]).toBeCloseTo(0.0001)
|
||||
expect(ramps[1][1]).toBeCloseTo(5.05)
|
||||
expect(hallSend(envelope)).toBe(0.6)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-AUD-017: impact drops a sub from 82 to 28 Hz and scales with strength', () => {
|
||||
audio.start()
|
||||
const m = mark()
|
||||
|
||||
audio.impact(0.5)
|
||||
|
||||
const sub = oscs(since(m))[0]
|
||||
expect(sub.frequency.setValueAtTime.mock.calls[0][0]).toBe(82)
|
||||
const sweep = sub.frequency.exponentialRampToValueAtTime.mock.calls[0]
|
||||
expect(sweep[0]).toBe(28)
|
||||
expect(sweep[1]).toBeCloseTo(0.92)
|
||||
expect(sub.started[0]).toBeCloseTo(0.02)
|
||||
expect(sub.stopped[0]).toBeCloseTo(1.82)
|
||||
const body = gains(since(m))[0]
|
||||
expect(body.gain.exponentialRampToValueAtTime.mock.calls[0][0]).toBeCloseTo(0.45)
|
||||
expect(hallSend(body)).toBe(0.5)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-AUD-018: impact doubles the sub with a lowpassed noise burst', () => {
|
||||
audio.start()
|
||||
const m = mark()
|
||||
|
||||
audio.impact()
|
||||
|
||||
const burst = only(bufs(since(m)))
|
||||
expect(burst.started[0]).toBeCloseTo(0.02)
|
||||
expect(burst.stopped[0]).toBeCloseTo(0.82)
|
||||
const lp = only(filters(since(m)))
|
||||
expect(lp.type).toBe('lowpass')
|
||||
expect(lp.frequency.setValueAtTime.mock.calls[0][0]).toBe(900)
|
||||
expect(lp.frequency.exponentialRampToValueAtTime.mock.calls[0][0]).toBe(120)
|
||||
expect(burst.connections).toEqual([lp])
|
||||
const noiseGain = gains(since(m)).find((g) => lp.connections.includes(g)) as FakeGain
|
||||
expect(noiseGain.gain.exponentialRampToValueAtTime.mock.calls[0][0]).toBeCloseTo(0.3)
|
||||
expect(hallSend(noiseGain)).toBe(0.6)
|
||||
})
|
||||
})
|
||||
|
||||
describe('acts', () => {
|
||||
it('FE-NOFEAR-AUD-019: fear lays a sub, a drifting fifth and a breathing rumble', () => {
|
||||
audio.start()
|
||||
const m = mark()
|
||||
|
||||
audio.setAct('fear')
|
||||
|
||||
const list = since(m)
|
||||
const sub = oscs(list).find((o) => o.type === 'sine' && o.frequency.value === 55)
|
||||
expect(sub).toBeDefined()
|
||||
const fifth = oscs(list).find((o) => o.type === 'triangle' && o.frequency.value === 82.4)
|
||||
expect(fifth).toBeDefined()
|
||||
// the detune LFO drives the fifth's detune param, not its output
|
||||
const drift = gains(list).find((g) => g.connections.includes(fifth!.detune)) as FakeGain
|
||||
expect(drift.gain.value).toBe(6)
|
||||
// the rumble's gain breathes from a second LFO
|
||||
const rumble = bufs(list).find((n) => n.loop) as FakeBufferSource
|
||||
const rlp = filters(list).find((f) => rumble.connections.includes(f)) as FakeBiquadFilter
|
||||
expect(rlp.type).toBe('lowpass')
|
||||
expect(rlp.frequency.value).toBe(120)
|
||||
const rumbleGain = gains(list).find((g) => rlp.connections.includes(g)) as FakeGain
|
||||
const breathe = gains(list).find((g) => g.connections.includes(rumbleGain.gain)) as FakeGain
|
||||
expect(breathe.gain.value).toBe(0.25)
|
||||
// no grind semitone in the fear act
|
||||
expect(oscs(list).some((o) => o.frequency.value === 58.27)).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-AUD-020: fear opens the bed and the wind from silence', () => {
|
||||
audio.start()
|
||||
const m = mark()
|
||||
|
||||
audio.setAct('fear')
|
||||
|
||||
const list = since(m)
|
||||
const bed = gains(list)[0]
|
||||
expect(bed.gain.setValueAtTime.mock.calls[0][0]).toBe(0.0001)
|
||||
expect(bed.gain.exponentialRampToValueAtTime.mock.calls[0]).toEqual([0.16, 3])
|
||||
expect(hallSend(bed)).toBe(0.3)
|
||||
const bp = filters(list).find((f) => f.type === 'bandpass') as FakeBiquadFilter
|
||||
expect(bp.frequency.value).toBe(420)
|
||||
expect(bp.Q.value).toBe(0.6)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-AUD-021: the wind LFO modulates a series stage, never the release envelope', () => {
|
||||
audio.start()
|
||||
const m = mark()
|
||||
|
||||
audio.setAct('fear')
|
||||
|
||||
const list = since(m)
|
||||
const trem = gains(list).find((g) => g.gain.value === 0.75) as FakeGain
|
||||
expect(trem).toBeDefined()
|
||||
const lfoGain = gains(list).find((g) => g.connections.includes(trem.gain)) as FakeGain
|
||||
expect(lfoGain.gain.value).toBe(0.4)
|
||||
// the wind envelope itself must stay free of LFO input, otherwise the hard
|
||||
// cut into the silence act could never mute it
|
||||
const windEnv = gains(list).find((g) => trem.connections.includes(g)) as FakeGain
|
||||
expect(gains(list).some((g) => g.connections.includes(windEnv.gain))).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-AUD-022: dread adds the grinding semitone and a 14s riser', () => {
|
||||
audio.start()
|
||||
const m = mark()
|
||||
|
||||
audio.setAct('dread')
|
||||
|
||||
const list = since(m)
|
||||
expect(oscs(list).some((o) => o.frequency.value === 58.27)).toBe(true)
|
||||
const riser = only(oscs(list).filter((o) => o.type === 'sawtooth'))
|
||||
expect(riser.frequency.setValueAtTime.mock.calls[0][0]).toBe(180)
|
||||
expect(riser.frequency.exponentialRampToValueAtTime.mock.calls[0]).toEqual([820, 14])
|
||||
const riserBp = filters(list).find(
|
||||
(f) => f.type === 'bandpass' && f.Q.value === 8,
|
||||
) as FakeBiquadFilter
|
||||
expect(riserBp.frequency.exponentialRampToValueAtTime.mock.calls[0]).toEqual([1400, 14])
|
||||
const noiseSweep = filters(list).find(
|
||||
(f) => f.type === 'bandpass' && f.Q.value === 1.4,
|
||||
) as FakeBiquadFilter
|
||||
expect(noiseSweep.frequency.exponentialRampToValueAtTime.mock.calls[0]).toEqual([3200, 14])
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-AUD-023: setting the same act twice changes nothing', () => {
|
||||
audio.start()
|
||||
audio.setAct('fear')
|
||||
const m = mark()
|
||||
|
||||
audio.setAct('fear')
|
||||
|
||||
expect(since(m)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-AUD-024: silence pulls the fear act away fast and stops the beat', () => {
|
||||
audio.start()
|
||||
const m = mark()
|
||||
audio.setAct('fear')
|
||||
const bed = gains(since(m))[0]
|
||||
const bedOscs = oscs(since(m))
|
||||
advance(400)
|
||||
|
||||
const afterCut = mark()
|
||||
audio.setAct('silence')
|
||||
|
||||
// release 0.6 instead of the usual 1.6
|
||||
expect(bed.gain.cancelScheduledValues).toHaveBeenCalledWith(0)
|
||||
const target = bed.gain.setTargetAtTime.mock.calls[0]
|
||||
expect(target[0]).toBe(0)
|
||||
expect(target[1]).toBeCloseTo(0.9)
|
||||
expect(target[2]).toBe(0.25)
|
||||
expect(bedOscs[0].stopped[0]).toBeCloseTo(2.5)
|
||||
|
||||
advance(2000)
|
||||
expect(thumpTimes(since(afterCut))).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-AUD-025: a normal act change uses the slow 1.6s release', () => {
|
||||
audio.start()
|
||||
const m = mark()
|
||||
audio.setAct('fear')
|
||||
const bedOscs = oscs(since(m))
|
||||
|
||||
audio.setAct('dread')
|
||||
|
||||
expect(bedOscs[0].stopped[0]).toBeCloseTo(3.1)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-AUD-026: hope opens the progression without a sub root', () => {
|
||||
audio.start()
|
||||
const m = mark()
|
||||
|
||||
audio.setAct('hope')
|
||||
|
||||
const list = since(m)
|
||||
const pads = oscs(list).filter((o) => o.type === 'triangle')
|
||||
expect(pads).toHaveLength(10)
|
||||
expect(oscs(list).some((o) => o.type === 'sine')).toBe(false)
|
||||
const lp = only(filters(list))
|
||||
expect(lp.frequency.setValueAtTime.mock.calls[0][0]).toBeCloseTo(245)
|
||||
expect(lp.frequency.exponentialRampToValueAtTime.mock.calls[0]).toEqual([700, 7])
|
||||
const padGain = gains(list).find((g) => lp.connections.includes(g)) as FakeGain
|
||||
expect(padGain.gain.exponentialRampToValueAtTime.mock.calls[0]).toEqual([0.085, 3.2])
|
||||
expect(hallSend(padGain)).toBe(0.55)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-AUD-027: the pad is ten detuned voices on the opening D chord', () => {
|
||||
audio.start()
|
||||
const m = mark()
|
||||
|
||||
audio.setAct('hope')
|
||||
|
||||
const pads = oscs(since(m)).filter((o) => o.type === 'triangle')
|
||||
expect(pads.map((o) => o.frequency.value)).toEqual([
|
||||
73.42, 73.42, 110.0, 110.0, 146.83, 146.83, 185.0, 185.0, 293.66, 293.66,
|
||||
])
|
||||
expect(pads.map((o) => o.detune.value)).toEqual([-5, 5, -5, 5, -5, 5, -5, 5, -5, 5])
|
||||
expect(pads.every((o) => o.started[0] === 0)).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-AUD-028: anthem adds a dry sub root and opens the filter wide', () => {
|
||||
audio.start()
|
||||
const m = mark()
|
||||
|
||||
audio.setAct('anthem')
|
||||
|
||||
const list = since(m)
|
||||
const lp = filters(list).find((f) => f.type === 'lowpass') as FakeBiquadFilter
|
||||
expect(lp.frequency.setValueAtTime.mock.calls[0][0]).toBeCloseTo(840)
|
||||
const sub = oscs(list).find((o) => o.frequency.value === 36.71) as FakeOscillator
|
||||
expect(sub.type).toBe('sine')
|
||||
const subGain = gains(list).find((g) => sub.connections.includes(g)) as FakeGain
|
||||
expect(subGain.gain.exponentialRampToValueAtTime.mock.calls[0]).toEqual([0.16, 2.5])
|
||||
// low end stays out of the hall, otherwise it turns to mud
|
||||
expect(hallSend(subGain)).toBeUndefined()
|
||||
expect(subGain.connections).toEqual([masterGain()])
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-AUD-029: anthem sprinkles pentatonic pings through a feedback delay', () => {
|
||||
audio.start()
|
||||
const m = mark()
|
||||
|
||||
audio.setAct('anthem')
|
||||
|
||||
const list = since(m)
|
||||
const delay = created.find((n) => n.kind === 'delay') as FakeDelay
|
||||
expect(delay.delayTime.value).toBe(0.38)
|
||||
const feedback = gains(list).find((g) => delay.connections.includes(g)) as FakeGain
|
||||
expect(feedback.gain.value).toBe(0.35)
|
||||
expect(feedback.connections).toEqual([delay])
|
||||
expect(hallSend(delay)).toBe(0.8)
|
||||
|
||||
const pings = oscs(list).filter((o) => o.type === 'sine' && o.frequency.value !== 36.71)
|
||||
expect(pings).toHaveLength(14)
|
||||
const scale = [880, 1108.7, 1318.5, 1479.98, 1760]
|
||||
expect(pings.every((o) => scale.includes(o.frequency.value))).toBe(true)
|
||||
// one ping every ~0.8s, each ringing 2.1s
|
||||
expect(pings[0].started[0]).toBeGreaterThanOrEqual(0.8)
|
||||
expect(pings[13].started[0]).toBeGreaterThanOrEqual(0.8 + 13 * 0.8)
|
||||
expect(pings[0].stopped[0]).toBeCloseTo(pings[0].started[0] + 2.1)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-AUD-030: end fades the master out on a long tail', () => {
|
||||
audio.start()
|
||||
const master = masterGain()
|
||||
advance(1000)
|
||||
|
||||
audio.setAct('end')
|
||||
|
||||
const call = master.gain.setTargetAtTime.mock.calls[0]
|
||||
expect(call[0]).toBe(0)
|
||||
expect(call[1]).toBeCloseTo(3.5)
|
||||
expect(call[2]).toBe(1.2)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-AUD-031: acts before the first gesture are ignored', () => {
|
||||
audio.setAct('anthem')
|
||||
|
||||
expect(created).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('progression', () => {
|
||||
it('FE-NOFEAR-AUD-032: steps the pad onto the next chord every 4.4s', () => {
|
||||
audio.start()
|
||||
const m = mark()
|
||||
audio.setAct('anthem')
|
||||
const pads = oscs(since(m)).filter((o) => o.type === 'triangle')
|
||||
|
||||
advance(4400)
|
||||
|
||||
const expected = [82.41, 82.41, 110.0, 110.0, 164.81, 164.81, 220.0, 220.0, 277.18, 277.18]
|
||||
pads.forEach((o, i) => {
|
||||
const call = o.frequency.setTargetAtTime.mock.calls[0]
|
||||
expect(call[0]).toBe(expected[i])
|
||||
expect(call[2]).toBe(0.55)
|
||||
})
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-AUD-033: the sub root walks D–E–F#–E and wraps around', () => {
|
||||
audio.start()
|
||||
const m = mark()
|
||||
audio.setAct('anthem')
|
||||
const sub = oscs(since(m)).find((o) => o.frequency.value === 36.71) as FakeOscillator
|
||||
|
||||
advance(4400 * 4)
|
||||
|
||||
const roots = sub.frequency.setTargetAtTime.mock.calls.map((call) => call[0])
|
||||
expect(roots).toEqual([41.2, 46.25, 41.2, 36.71])
|
||||
expect(sub.frequency.setTargetAtTime.mock.calls[0][2]).toBe(0.5)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-AUD-034: hope has no sub, so only the pad glides', () => {
|
||||
audio.start()
|
||||
const m = mark()
|
||||
audio.setAct('hope')
|
||||
const opened = since(m)
|
||||
const pads = oscs(opened).filter((o) => o.type === 'triangle')
|
||||
expect(oscs(opened).some((o) => o.type === 'sine')).toBe(false)
|
||||
|
||||
advance(4400)
|
||||
|
||||
expect(pads[0].frequency.setTargetAtTime).toHaveBeenCalledTimes(1)
|
||||
expect(pads[0].frequency.setTargetAtTime.mock.calls[0][0]).toBe(82.41)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-AUD-035: leaving the act stops the chord clock', () => {
|
||||
audio.start()
|
||||
const m = mark()
|
||||
audio.setAct('hope')
|
||||
const pads = oscs(since(m)).filter((o) => o.type === 'triangle')
|
||||
|
||||
audio.setAct('end')
|
||||
advance(4400 * 2)
|
||||
|
||||
expect(pads[0].frequency.setTargetAtTime).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('heartbeat', () => {
|
||||
it('FE-NOFEAR-AUD-036: schedules a beat plus its echo ahead of the clock', () => {
|
||||
audio.start()
|
||||
const m = mark()
|
||||
|
||||
advance(1000)
|
||||
|
||||
const times = thumpTimes(since(m))
|
||||
expect(times).toHaveLength(4)
|
||||
expect(times[0]).toBeCloseTo(0.2)
|
||||
expect(times[1]).toBeCloseTo(0.39)
|
||||
expect(times[2] - times[0]).toBeCloseTo(60 / 76)
|
||||
expect(times[3] - times[2]).toBeCloseTo(0.19)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-AUD-037: a beat is a sine body plus a highpassed click', () => {
|
||||
audio.start()
|
||||
const m = mark()
|
||||
|
||||
advance(100)
|
||||
|
||||
const list = since(m)
|
||||
const body = oscs(list)[0]
|
||||
expect(body.type).toBe('sine')
|
||||
expect(body.frequency.exponentialRampToValueAtTime.mock.calls[0][0]).toBe(34)
|
||||
expect(body.stopped[0]).toBeCloseTo(0.52)
|
||||
const bodyGain = body.connections[0] as FakeGain
|
||||
expect(bodyGain.gain.exponentialRampToValueAtTime.mock.calls[0][0]).toBeCloseTo(0.55)
|
||||
expect(hallSend(bodyGain)).toBe(0.1)
|
||||
|
||||
const click = bufs(list)[0]
|
||||
const hp = filters(list)[0]
|
||||
expect(hp.type).toBe('highpass')
|
||||
expect(hp.frequency.value).toBe(1700)
|
||||
expect(click.connections).toEqual([hp])
|
||||
const clickGain = gains(list).find((g) => hp.connections.includes(g)) as FakeGain
|
||||
expect(clickGain.gain.exponentialRampToValueAtTime.mock.calls[0][0]).toBeCloseTo(0.165)
|
||||
// the click is close, not in the hall
|
||||
expect(clickGain.connections).toEqual([masterGain()])
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-AUD-038: dread doubles the tempo and hits harder', () => {
|
||||
audio.start()
|
||||
audio.setAct('dread')
|
||||
const m = mark()
|
||||
|
||||
advance(1000)
|
||||
|
||||
const times = thumpTimes(since(m))
|
||||
expect(times).toHaveLength(6)
|
||||
expect(times[2] - times[0]).toBeCloseTo(60 / 116)
|
||||
const first = oscs(since(m))[0].connections[0] as FakeGain
|
||||
expect(first.gain.exponentialRampToValueAtTime.mock.calls[0][0]).toBeCloseTo(0.7)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-AUD-039: clamps the beat clock instead of back-filling after the silence', () => {
|
||||
audio.start()
|
||||
audio.setAct('silence')
|
||||
advance(3000)
|
||||
const m = mark()
|
||||
|
||||
audio.setAct('hope')
|
||||
advance(100)
|
||||
|
||||
const times = thumpTimes(since(m))
|
||||
expect(times).toHaveLength(2)
|
||||
expect(times[0]).toBeCloseTo(3.2)
|
||||
expect(times[1]).toBeCloseTo(3.39)
|
||||
})
|
||||
})
|
||||
|
||||
describe('teardown', () => {
|
||||
it('FE-NOFEAR-AUD-040: dispose mutes immediately and closes the context late', async () => {
|
||||
audio.start()
|
||||
const master = masterGain()
|
||||
audio.setAct('fear')
|
||||
const context = ctx()
|
||||
const m = mark()
|
||||
|
||||
audio.dispose()
|
||||
|
||||
expect(master.gain.setTargetAtTime).toHaveBeenLastCalledWith(0, 0, 0.05)
|
||||
expect(context.close).not.toHaveBeenCalled()
|
||||
|
||||
advance(300)
|
||||
await Promise.resolve()
|
||||
expect(context.close).toHaveBeenCalledTimes(1)
|
||||
|
||||
// the heartbeat interval is gone with it
|
||||
advance(2000)
|
||||
expect(thumpTimes(since(m))).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-AUD-041: dispose swallows a rejected close and survives a second call', async () => {
|
||||
behavior.closeRejects = true
|
||||
audio.start()
|
||||
const context = ctx()
|
||||
|
||||
audio.dispose()
|
||||
advance(300)
|
||||
await Promise.resolve()
|
||||
expect(context.close).toHaveBeenCalledTimes(1)
|
||||
|
||||
const m = mark()
|
||||
expect(() => audio.dispose()).not.toThrow()
|
||||
advance(300)
|
||||
expect(context.close).toHaveBeenCalledTimes(1)
|
||||
audio.swell()
|
||||
audio.impact()
|
||||
audio.setAct('anthem')
|
||||
expect(since(m)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-AUD-042: a voice that refuses to stop does not break the act change', () => {
|
||||
audio.start()
|
||||
const m = mark()
|
||||
audio.setAct('anthem')
|
||||
const voices = [...oscs(since(m)), ...bufs(since(m))]
|
||||
for (const v of voices) v.throwOnStop = true
|
||||
|
||||
expect(() => audio.setAct('end')).not.toThrow()
|
||||
|
||||
expect(voices.every((v) => v.stop.mock.calls.length > 0)).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,609 @@
|
||||
// FE-NOFEAR-SCN-001 to FE-NOFEAR-SCN-028
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { server } from '../../../tests/helpers/msw/server'
|
||||
import { NoFearScene, type SceneState } from './noFearScene'
|
||||
|
||||
const GEO_URL = '/api/addons/atlas/countries/geo'
|
||||
|
||||
interface DrawImageRecord { image: unknown; args: number[]; globalAlpha: number; composite: string }
|
||||
interface FillRectRecord { args: number[]; fillStyle: string; globalAlpha: number; composite: string }
|
||||
interface StrokeRecord { strokeStyle: string; lineWidth: number; globalAlpha: number }
|
||||
interface ArcFillRecord { x: number; y: number; r: number; fillStyle: string; globalAlpha: number; composite: string }
|
||||
|
||||
interface FakeCtx {
|
||||
fillStyle: string
|
||||
strokeStyle: string
|
||||
lineWidth: number
|
||||
globalAlpha: number
|
||||
globalCompositeOperation: string
|
||||
clearRect: ReturnType<typeof vi.fn>
|
||||
save: ReturnType<typeof vi.fn>
|
||||
restore: ReturnType<typeof vi.fn>
|
||||
beginPath: ReturnType<typeof vi.fn>
|
||||
closePath: ReturnType<typeof vi.fn>
|
||||
moveTo: ReturnType<typeof vi.fn>
|
||||
lineTo: ReturnType<typeof vi.fn>
|
||||
arc: ReturnType<typeof vi.fn>
|
||||
fill: ReturnType<typeof vi.fn>
|
||||
stroke: ReturnType<typeof vi.fn>
|
||||
drawImage: ReturnType<typeof vi.fn>
|
||||
fillRect: ReturnType<typeof vi.fn>
|
||||
createRadialGradient: ReturnType<typeof vi.fn>
|
||||
getImageData: ReturnType<typeof vi.fn>
|
||||
images: DrawImageRecord[]
|
||||
rects: FillRectRecord[]
|
||||
strokes: StrokeRecord[]
|
||||
arcFills: ArcFillRecord[]
|
||||
rasters: number[][]
|
||||
points: number[][]
|
||||
}
|
||||
|
||||
// jsdom ships no canvas backend: every context is a recorder, getImageData
|
||||
// replays `alphaAt`, and Path2D is a call log so the baked border geometry
|
||||
// stays observable.
|
||||
let alphaAt: (x: number, y: number, w: number) => number = () => 0
|
||||
let contextAvailable = true
|
||||
let contexts: FakeCtx[] = []
|
||||
let path2dCount = 0
|
||||
let pathMoveTo = 0
|
||||
let pathLineTo = 0
|
||||
let pathClose = 0
|
||||
let rafCount = 0
|
||||
let onFrame: ((n: number) => void) | null = null
|
||||
|
||||
const originalGetContext = HTMLCanvasElement.prototype.getContext
|
||||
|
||||
function makeCtx(): FakeCtx {
|
||||
let lastArc: { x: number; y: number; r: number } | null = null
|
||||
const ctx: FakeCtx = {
|
||||
fillStyle: '',
|
||||
strokeStyle: '',
|
||||
lineWidth: 1,
|
||||
globalAlpha: 1,
|
||||
globalCompositeOperation: 'source-over',
|
||||
clearRect: vi.fn(),
|
||||
save: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
beginPath: vi.fn(),
|
||||
closePath: vi.fn(),
|
||||
moveTo: vi.fn((x: number, y: number) => { ctx.points.push([x, y]) }),
|
||||
lineTo: vi.fn((x: number, y: number) => { ctx.points.push([x, y]) }),
|
||||
arc: vi.fn((x: number, y: number, r: number) => { lastArc = { x, y, r } }),
|
||||
fill: vi.fn(() => {
|
||||
if (!lastArc) return
|
||||
ctx.arcFills.push({
|
||||
x: lastArc.x, y: lastArc.y, r: lastArc.r,
|
||||
fillStyle: ctx.fillStyle, globalAlpha: ctx.globalAlpha, composite: ctx.globalCompositeOperation,
|
||||
})
|
||||
}),
|
||||
stroke: vi.fn(() => {
|
||||
ctx.strokes.push({ strokeStyle: ctx.strokeStyle, lineWidth: ctx.lineWidth, globalAlpha: ctx.globalAlpha })
|
||||
}),
|
||||
drawImage: vi.fn((image: unknown, ...args: number[]) => {
|
||||
ctx.images.push({ image, args, globalAlpha: ctx.globalAlpha, composite: ctx.globalCompositeOperation })
|
||||
}),
|
||||
fillRect: vi.fn((...args: number[]) => {
|
||||
ctx.rects.push({ args, fillStyle: ctx.fillStyle, globalAlpha: ctx.globalAlpha, composite: ctx.globalCompositeOperation })
|
||||
}),
|
||||
createRadialGradient: vi.fn(() => ({ addColorStop: vi.fn() })),
|
||||
getImageData: vi.fn((_x: number, _y: number, w: number, h: number) => {
|
||||
ctx.rasters.push([w, h])
|
||||
const data = new Uint8ClampedArray(w * h * 4)
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++) data[(y * w + x) * 4 + 3] = alphaAt(x, y, w)
|
||||
}
|
||||
return { data }
|
||||
}),
|
||||
images: [],
|
||||
rects: [],
|
||||
strokes: [],
|
||||
arcFills: [],
|
||||
rasters: [],
|
||||
points: [],
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
class FakePath2D {
|
||||
moveTo(): void { pathMoveTo++ }
|
||||
lineTo(): void { pathLineTo++ }
|
||||
closePath(): void { pathClose++ }
|
||||
constructor() { path2dCount++ }
|
||||
}
|
||||
|
||||
/** mulberry32 — a fixed seed keeps arcs, dots and sparks identical across runs. */
|
||||
function seedRandom(seed: number): void {
|
||||
let a = seed >>> 0
|
||||
vi.spyOn(Math, 'random').mockImplementation(() => {
|
||||
a = (a + 0x6d2b79f5) >>> 0
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a)
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||
})
|
||||
}
|
||||
|
||||
const asCtx = (c: FakeCtx) => c as unknown as CanvasRenderingContext2D
|
||||
|
||||
const state = (over: Partial<SceneState> = {}): SceneState => ({
|
||||
land: 0, cityLife: 0, cityDeath: 0, borderHeat: 0, borderBurst: 0,
|
||||
web: 0, warmth: 0, personalGlow: 0, particles: 0, opacity: 1, ...over,
|
||||
})
|
||||
|
||||
type Ring = [number, number][]
|
||||
const ringA: Ring = [[-10, 50], [10, 50], [10, 40], [-10, 40], [-10, 50]]
|
||||
const ringB: Ring = [[100, -20], [120, -20], [120, -30], [100, -30], [100, -20]]
|
||||
const sliver: Ring = [[0, 0], [1, 1]]
|
||||
|
||||
const GEO = {
|
||||
features: [
|
||||
{ geometry: { type: 'Polygon', coordinates: [ringA] } },
|
||||
{ geometry: { type: 'MultiPolygon', coordinates: [[ringB], [sliver]] } },
|
||||
{},
|
||||
{ geometry: { type: 'Point', coordinates: [0, 0] } },
|
||||
],
|
||||
}
|
||||
|
||||
function serveGeo(body: unknown): void {
|
||||
server.use(http.get(GEO_URL, () => HttpResponse.json(body)))
|
||||
}
|
||||
|
||||
/** The land-dot raster is the only 168-wide getImageData in this module. */
|
||||
const isDotRaster = (r: number[]) => r[0] === 168 && r[1] === 84
|
||||
const dotRasters = () => contexts.flatMap(c => c.rasters).filter(isDotRaster)
|
||||
|
||||
/** The web layer is the only offscreen context stroked in the arc's amber. */
|
||||
const webContext = () => contexts.find(c => c.strokes.some(s => s.strokeStyle.startsWith('rgba(255, 176, 90')))
|
||||
|
||||
async function loadedScene(width = 800, height = 600): Promise<NoFearScene> {
|
||||
serveGeo(GEO)
|
||||
const scene = new NoFearScene()
|
||||
await scene.load(width, height)
|
||||
return scene
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
alphaAt = (x, y, w) => (w === 168 && x < 6 && y < 2 ? 255 : 0)
|
||||
contextAvailable = true
|
||||
contexts = []
|
||||
path2dCount = 0
|
||||
pathMoveTo = 0
|
||||
pathLineTo = 0
|
||||
pathClose = 0
|
||||
rafCount = 0
|
||||
onFrame = null
|
||||
seedRandom(12345)
|
||||
|
||||
HTMLCanvasElement.prototype.getContext = vi.fn(() => {
|
||||
if (!contextAvailable) return null
|
||||
const c = makeCtx()
|
||||
contexts.push(c)
|
||||
return c
|
||||
}) as unknown as HTMLCanvasElement['getContext']
|
||||
|
||||
vi.stubGlobal('Path2D', FakePath2D)
|
||||
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
|
||||
rafCount += 1
|
||||
onFrame?.(rafCount)
|
||||
cb(0)
|
||||
return rafCount
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
HTMLCanvasElement.prototype.getContext = originalGetContext
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('NoFearScene.load', () => {
|
||||
it('FE-NOFEAR-SCN-001: bakes border, dot and web layers from the Atlas bundle', async () => {
|
||||
const scene = await loadedScene()
|
||||
|
||||
expect(dotRasters()).toHaveLength(1)
|
||||
expect(path2dCount).toBe(1)
|
||||
// two surviving rings of five points each
|
||||
expect(pathMoveTo).toBe(2)
|
||||
expect(pathLineTo).toBe(8)
|
||||
expect(pathClose).toBe(2)
|
||||
|
||||
const ctx = makeCtx()
|
||||
scene.draw(asCtx(ctx), state({ land: 1, borderHeat: 1 }), 0)
|
||||
expect(ctx.images.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SCN-002: decimates rings against the global vertex budget', async () => {
|
||||
const dense: Ring = Array.from({ length: 14001 }, (_, i) => [i % 360 - 180, (i % 120) - 50])
|
||||
serveGeo({ features: [{ geometry: { type: 'Polygon', coordinates: [dense] } }] })
|
||||
|
||||
await new NoFearScene().load(800, 600)
|
||||
|
||||
// 14001 vertices → step 2 → 7001 kept, one moveTo + 7000 lineTo
|
||||
expect(pathMoveTo).toBe(1)
|
||||
expect(pathLineTo).toBe(7000)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SCN-003: drops rings too short to survive decimation', async () => {
|
||||
serveGeo({ features: [{ geometry: { type: 'Polygon', coordinates: [sliver] } }] })
|
||||
const scene = new NoFearScene()
|
||||
await scene.load(800, 600)
|
||||
|
||||
// border layer still baked, but with an empty path and no sparks
|
||||
expect(path2dCount).toBe(1)
|
||||
expect(pathMoveTo).toBe(0)
|
||||
|
||||
const ctx = makeCtx()
|
||||
scene.draw(asCtx(ctx), state({ borderBurst: 0.5 }), 0)
|
||||
expect(ctx.rects).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SCN-004: ignores a bundle without usable geometry', async () => {
|
||||
serveGeo({ features: [{}, { geometry: { type: 'Point', coordinates: [0, 0] } }] })
|
||||
await new NoFearScene().load(800, 600)
|
||||
|
||||
expect(path2dCount).toBe(0)
|
||||
expect(dotRasters()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SCN-005: ignores a bundle without a features array', async () => {
|
||||
serveGeo({})
|
||||
await new NoFearScene().load(800, 600)
|
||||
expect(path2dCount).toBe(0)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SCN-006: ignores a non-ok response', async () => {
|
||||
server.use(http.get(GEO_URL, () => HttpResponse.json({ error: 'off' }, { status: 404 })))
|
||||
await new NoFearScene().load(800, 600)
|
||||
expect(path2dCount).toBe(0)
|
||||
expect(dotRasters()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SCN-007: swallows a network failure and keeps the scene usable', async () => {
|
||||
server.use(http.get(GEO_URL, () => HttpResponse.error()))
|
||||
const scene = new NoFearScene()
|
||||
await expect(scene.load(800, 600)).resolves.toBeUndefined()
|
||||
expect(path2dCount).toBe(0)
|
||||
|
||||
const ctx = makeCtx()
|
||||
scene.draw(asCtx(ctx), state({ land: 1, borderHeat: 1 }), 0)
|
||||
expect(ctx.images).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SCN-008: aborts before the geometry pass', async () => {
|
||||
const controller = new AbortController()
|
||||
// The abort has to land between the response and the first frame yield,
|
||||
// which only a hand-rolled fetch can time precisely.
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => { controller.abort(); return GEO },
|
||||
})))
|
||||
|
||||
const scene = new NoFearScene()
|
||||
await scene.load(800, 600, controller.signal)
|
||||
|
||||
expect(dotRasters()).toHaveLength(0)
|
||||
expect(path2dCount).toBe(0)
|
||||
// no rings were stored, so a resize cannot rebake either
|
||||
scene.layout(1000, 700)
|
||||
expect(path2dCount).toBe(0)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SCN-009: aborts after the rings are decimated', async () => {
|
||||
const controller = new AbortController()
|
||||
onFrame = n => { if (n === 1) controller.abort() }
|
||||
serveGeo(GEO)
|
||||
|
||||
const scene = new NoFearScene()
|
||||
await scene.load(800, 600, controller.signal)
|
||||
|
||||
expect(dotRasters()).toHaveLength(0)
|
||||
expect(path2dCount).toBe(0)
|
||||
// the decimated rings survived, so a resize bakes them
|
||||
onFrame = null
|
||||
scene.layout(1000, 700)
|
||||
expect(path2dCount).toBe(1)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SCN-010: aborts after the land dots are sampled', async () => {
|
||||
const controller = new AbortController()
|
||||
onFrame = n => { if (n === 2) controller.abort() }
|
||||
serveGeo(GEO)
|
||||
|
||||
const scene = new NoFearScene()
|
||||
await scene.load(800, 600, controller.signal)
|
||||
|
||||
expect(dotRasters()).toHaveLength(1)
|
||||
expect(path2dCount).toBe(0)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SCN-011: survives a browser without a 2d context', async () => {
|
||||
contextAvailable = false
|
||||
serveGeo(GEO)
|
||||
const scene = new NoFearScene()
|
||||
await expect(scene.load(800, 600)).resolves.toBeUndefined()
|
||||
|
||||
const ctx = makeCtx()
|
||||
scene.draw(asCtx(ctx), state({ land: 1, borderHeat: 1 }), 0)
|
||||
// no dot layers and no border layer were baked
|
||||
expect(ctx.images).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('NoFearScene.layout', () => {
|
||||
it('FE-NOFEAR-SCN-012: rebakes the layers on resize but not on a no-op layout', async () => {
|
||||
const scene = await loadedScene(800, 600)
|
||||
expect(path2dCount).toBe(1)
|
||||
|
||||
scene.layout(1000, 700)
|
||||
expect(path2dCount).toBe(2)
|
||||
scene.layout(1000, 700)
|
||||
expect(path2dCount).toBe(2)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SCN-031: draws in raw lon/lat until the first layout', () => {
|
||||
const raw = makeCtx()
|
||||
new NoFearScene().draw(asCtx(raw), state({ web: 0.5 }), 0)
|
||||
|
||||
expect(raw.points.length).toBeGreaterThan(0)
|
||||
expect(raw.points.every(([x, y]) => x >= -180 && x <= 180 && y >= -90 && y <= 90)).toBe(true)
|
||||
|
||||
const scene = new NoFearScene()
|
||||
scene.layout(800, 600)
|
||||
const projected = makeCtx()
|
||||
scene.draw(asCtx(projected), state({ web: 0.5 }), 0)
|
||||
expect(projected.points.some(([x]) => x < -180 || x > 180)).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SCN-013: projects longitude and latitude into a cover-fitted equirectangular frame', async () => {
|
||||
const scene = await loadedScene(800, 600)
|
||||
const ctx = makeCtx()
|
||||
scene.draw(asCtx(ctx), state({ cityLife: 1 }), 0)
|
||||
|
||||
// scale = max(800/360, 600/136) ≈ 4.41; Berlin (13.4E) sits right of centre
|
||||
const scale = Math.max(800 / 360, 600 / 136)
|
||||
const ox = (800 - 360 * scale) / 2
|
||||
const oy = (600 - 136 * scale) / 2
|
||||
const berlin = ctx.images[0]
|
||||
expect(berlin.args[0]).toBeCloseTo(ox + (13.4 + 180) * scale - 8, 6)
|
||||
expect(berlin.args[1]).toBeCloseTo(oy + (78 - 52.52) * scale - 8, 6)
|
||||
})
|
||||
})
|
||||
|
||||
describe('NoFearScene.draw', () => {
|
||||
it('FE-NOFEAR-SCN-014: clears the canvas and stops at zero opacity', async () => {
|
||||
const scene = await loadedScene(800, 600)
|
||||
const ctx = makeCtx()
|
||||
scene.draw(asCtx(ctx), state({ land: 1, opacity: 0 }), 0)
|
||||
|
||||
expect(ctx.clearRect).toHaveBeenCalledWith(0, 0, 800, 600)
|
||||
expect(ctx.save).not.toHaveBeenCalled()
|
||||
expect(ctx.images).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SCN-015: blits one cold dot layer per twinkle group', async () => {
|
||||
const scene = await loadedScene(800, 600)
|
||||
const ctx = makeCtx()
|
||||
scene.draw(asCtx(ctx), state({ land: 1 }), 0)
|
||||
|
||||
expect(ctx.images).toHaveLength(3)
|
||||
// twinkle at t=0, g=0 sits at its 0.72 floor
|
||||
expect(ctx.images[0].globalAlpha).toBeCloseTo(0.6 * 0.72, 6)
|
||||
expect(ctx.images[1].globalAlpha).toBeCloseTo(0.6 * (0.72 + 0.28 * Math.sin(2.1)), 6)
|
||||
expect(ctx.globalAlpha).toBe(1)
|
||||
expect(ctx.restore).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SCN-016: crossfades the cold dot layers into the warm ones', async () => {
|
||||
const scene = await loadedScene(800, 600)
|
||||
|
||||
const cold = makeCtx()
|
||||
scene.draw(asCtx(cold), state({ land: 1, warmth: 0 }), 0)
|
||||
const warm = makeCtx()
|
||||
scene.draw(asCtx(warm), state({ land: 1, warmth: 1 }), 0)
|
||||
const both = makeCtx()
|
||||
scene.draw(asCtx(both), state({ land: 1, warmth: 0.5 }), 0)
|
||||
|
||||
expect(warm.images).toHaveLength(3)
|
||||
expect(both.images).toHaveLength(6)
|
||||
const coldLayers = cold.images.map(i => i.image)
|
||||
expect(warm.images.some(i => coldLayers.includes(i.image))).toBe(false)
|
||||
expect(both.images[0].globalAlpha).toBeCloseTo(0.6 * 0.72 * 0.5, 6)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SCN-017: lights every city in the opening act', async () => {
|
||||
const scene = await loadedScene(800, 600)
|
||||
const ctx = makeCtx()
|
||||
scene.draw(asCtx(ctx), state({ cityLife: 1 }), 0)
|
||||
|
||||
expect(ctx.images).toHaveLength(51)
|
||||
expect(ctx.images[0].args.slice(2)).toEqual([16, 16])
|
||||
expect(ctx.images[0].composite).toBe('lighter')
|
||||
expect(ctx.images[0].globalAlpha).toBeCloseTo(0.5, 6)
|
||||
expect(ctx.globalCompositeOperation).toBe('source-over')
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SCN-018: kills the city lights one by one during the fear act', async () => {
|
||||
const scene = await loadedScene(800, 600)
|
||||
|
||||
const half = makeCtx()
|
||||
scene.draw(asCtx(half), state({ cityLife: 1, cityDeath: 0.5 }), 0)
|
||||
expect(half.images.length).toBeGreaterThan(0)
|
||||
expect(half.images.length).toBeLessThan(51)
|
||||
|
||||
const dead = makeCtx()
|
||||
scene.draw(asCtx(dead), state({ cityLife: 1, cityDeath: 1 }), 0)
|
||||
expect(dead.images).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SCN-019: hands the cities over to the web layer once the web grows', async () => {
|
||||
const scene = await loadedScene(800, 600)
|
||||
const ctx = makeCtx()
|
||||
scene.draw(asCtx(ctx), state({ cityLife: 1, web: 0.01 }), 0)
|
||||
|
||||
// no 16x16 opening sprites — the web block owns the cities from here
|
||||
expect(ctx.images.filter(i => i.args[2] === 16)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SCN-020: pulses the baked border layer', async () => {
|
||||
const scene = await loadedScene(800, 600)
|
||||
|
||||
const hot = makeCtx()
|
||||
scene.draw(asCtx(hot), state({ borderHeat: 1 }), 0)
|
||||
expect(hot.images).toHaveLength(1)
|
||||
expect(hot.images[0].args).toEqual([0, 0])
|
||||
expect(hot.images[0].globalAlpha).toBeCloseTo(0.72, 6)
|
||||
|
||||
const halfLit = makeCtx()
|
||||
scene.draw(asCtx(halfLit), state({ borderHeat: 1, borderBurst: 0.5 }), 0)
|
||||
expect(halfLit.images[0].globalAlpha).toBeCloseTo(0.5 * 0.72, 6)
|
||||
|
||||
const gone = makeCtx()
|
||||
scene.draw(asCtx(gone), state({ borderHeat: 1, borderBurst: 1 }), 0)
|
||||
expect(gone.images).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SCN-021: shatters the borders into drifting sparks', async () => {
|
||||
const scene = await loadedScene(800, 600)
|
||||
const ctx = makeCtx()
|
||||
scene.draw(asCtx(ctx), state({ borderBurst: 0.5 }), 0)
|
||||
|
||||
expect(ctx.rects.length).toBeGreaterThan(0)
|
||||
expect(ctx.rects.length).toBeLessThanOrEqual(10)
|
||||
for (const r of ctx.rects) {
|
||||
expect(r.args.slice(2)).toEqual([1.6, 1.6])
|
||||
expect(['rgb(255, 150, 95)', 'rgb(255, 205, 130)']).toContain(r.fillStyle)
|
||||
expect(r.composite).toBe('lighter')
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SCN-022: drops sparks once they have burned out', async () => {
|
||||
const scene = await loadedScene(800, 600)
|
||||
|
||||
const spent = makeCtx()
|
||||
scene.draw(asCtx(spent), state({ borderBurst: 0.99 }), 0)
|
||||
expect(spent.rects).toHaveLength(0)
|
||||
|
||||
const over = makeCtx()
|
||||
scene.draw(asCtx(over), state({ borderBurst: 1 }), 0)
|
||||
expect(over.rects).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SCN-023: bakes finished arcs once and strokes only the growing ones', async () => {
|
||||
const scene = await loadedScene(800, 600)
|
||||
const ctx = makeCtx()
|
||||
scene.draw(asCtx(ctx), state({ web: 0.5 }), 0)
|
||||
|
||||
const web = webContext()
|
||||
expect(web).toBeDefined()
|
||||
const bakedStrokes = web!.strokes.length
|
||||
expect(bakedStrokes).toBeGreaterThan(0)
|
||||
// two passes (glow + core) per baked arc
|
||||
expect(bakedStrokes % 2).toBe(0)
|
||||
expect(web!.strokes[0].lineWidth).toBe(4.2)
|
||||
expect(web!.strokes[1].lineWidth).toBe(1.2)
|
||||
|
||||
// arcs still in flight are stroked on the live context instead
|
||||
expect(ctx.strokes.length).toBeGreaterThan(0)
|
||||
expect(ctx.images.some(i => i.args.length === 2)).toBe(true)
|
||||
|
||||
// a second frame at the same progress must not re-bake anything
|
||||
const again = makeCtx()
|
||||
scene.draw(asCtx(again), state({ web: 0.5 }), 0)
|
||||
expect(web!.strokes).toHaveLength(bakedStrokes)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SCN-024: strokes a full arc with a fractional tip while it grows', async () => {
|
||||
const scene = await loadedScene(800, 600)
|
||||
const ctx = makeCtx()
|
||||
scene.draw(asCtx(ctx), state({ web: 0.5 }), 0)
|
||||
|
||||
const growing = ctx.strokes.length / 2
|
||||
expect(growing).toBeGreaterThan(0)
|
||||
// per growing arc: one moveTo, floor(48*local) segments plus the tip
|
||||
expect(ctx.moveTo).toHaveBeenCalledTimes(growing)
|
||||
expect(ctx.lineTo.mock.calls.length).toBeGreaterThan(growing)
|
||||
expect(ctx.strokes[0].strokeStyle).toMatch(/^rgba\(255, 176, 90, /)
|
||||
expect(ctx.strokes[1].strokeStyle).toMatch(/^rgba\(255, 202, 122, /)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SCN-025: a fully grown web lives entirely in the baked layer', async () => {
|
||||
const scene = await loadedScene(800, 600)
|
||||
const ctx = makeCtx()
|
||||
scene.draw(asCtx(ctx), state({ web: 1 }), 0)
|
||||
|
||||
expect(ctx.strokes).toHaveLength(0)
|
||||
// one web-layer blit plus a halo and a core per city
|
||||
expect(ctx.images).toHaveLength(1 + 51 * 2)
|
||||
expect(ctx.images[0].args).toEqual([0, 0])
|
||||
expect(ctx.images[2].args.slice(2)).toEqual([6.4, 6.4])
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SCN-026: grows the web without any Atlas geometry', () => {
|
||||
const scene = new NoFearScene()
|
||||
scene.layout(800, 600)
|
||||
const ctx = makeCtx()
|
||||
scene.draw(asCtx(ctx), state({ web: 0.5 }), 0)
|
||||
|
||||
expect(ctx.strokes.length).toBeGreaterThan(0)
|
||||
expect(ctx.images).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SCN-027: ignites the user places in order with an overshoot pulse', async () => {
|
||||
const scene = await loadedScene(800, 600)
|
||||
scene.setPersonalPlaces([
|
||||
{ lat: 52.5, lng: 13.4 }, { lat: 48.8, lng: 2.3 },
|
||||
{ lat: -33.9, lng: 151.2 }, { lat: 40.7, lng: -74 },
|
||||
])
|
||||
|
||||
const full = makeCtx()
|
||||
scene.draw(asCtx(full), state({ personalGlow: 1 }), 0)
|
||||
expect(full.images).toHaveLength(8)
|
||||
expect(full.images[1].args.slice(2)).toEqual([5.2, 5.2])
|
||||
// fully lit → no overshoot left on the halo
|
||||
expect(full.images[0].args[2]).toBeCloseTo(14, 6)
|
||||
|
||||
const early = makeCtx()
|
||||
scene.draw(asCtx(early), state({ personalGlow: 0.1 }), 0)
|
||||
expect(early.images).toHaveLength(2)
|
||||
expect(early.images[0].args[2]).toBeGreaterThan(14)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SCN-028: caps the personal places at 400', async () => {
|
||||
const scene = await loadedScene(800, 600)
|
||||
scene.setPersonalPlaces(Array.from({ length: 450 }, (_, i) => ({ lat: (i % 80) - 40, lng: (i % 300) - 150 })))
|
||||
|
||||
const ctx = makeCtx()
|
||||
scene.draw(asCtx(ctx), state({ personalGlow: 1 }), 0)
|
||||
expect(ctx.images).toHaveLength(800)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SCN-029: skips the personal glow before the sprites are baked', () => {
|
||||
const scene = new NoFearScene()
|
||||
scene.layout(800, 600)
|
||||
scene.setPersonalPlaces([{ lat: 10, lng: 10 }])
|
||||
|
||||
const ctx = makeCtx()
|
||||
scene.draw(asCtx(ctx), state({ personalGlow: 1 }), 0)
|
||||
expect(ctx.images).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SCN-030: raises the anthem particles and wraps them around the viewport', () => {
|
||||
const scene = new NoFearScene()
|
||||
scene.layout(800, 600)
|
||||
|
||||
const start = makeCtx()
|
||||
scene.draw(asCtx(start), state({ particles: 1 }), 0)
|
||||
expect(start.arcFills).toHaveLength(90)
|
||||
for (const f of start.arcFills) {
|
||||
expect(f.fillStyle).toBe('rgb(255, 210, 150)')
|
||||
expect(f.composite).toBe('lighter')
|
||||
expect(f.y).toBeGreaterThanOrEqual(0)
|
||||
expect(f.y).toBeLessThanOrEqual(1.15 * 600)
|
||||
}
|
||||
|
||||
const later = makeCtx()
|
||||
scene.draw(asCtx(later), state({ particles: 1 }), 12)
|
||||
expect(later.arcFills.map(f => f.y)).not.toEqual(start.arcFills.map(f => f.y))
|
||||
expect(later.arcFills.every(f => f.y >= 0 && f.y <= 1.15 * 600)).toBe(true)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user