test(4-0): cover the KEINE ANGST show

This commit is contained in:
Maurice
2026-07-29 09:43:58 +02:00
committed by Maurice
parent c884ddf527
commit c071901e55
5 changed files with 2937 additions and 0 deletions
@@ -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 dont 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 DEF#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)
})
})