diff --git a/package.json b/package.json index 9f573c7..8c6ea61 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "test:signal": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/audio/signalShare.test.mts src/audio/signalShareIntent.test.mts src/audio/signalScanGeometry.test.mts src/audio/signalLocalMatch.test.mts", "test:eq-math": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/audio/eq.test.mts", "test:audio-startup": "node --experimental-strip-types --test src/audio/dspStartupCoordinator.test.mts src/audio/dspStartupGain.test.mts", + "test:seek-bar": "node --experimental-strip-types --test src/audio/playbackClock.test.mts src/components/waveformScrubDetents.test.mts", "test:lyrics": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/lyrics/parsing.test.mts src/lyrics/presentation.test.mts src/lyrics/displaySettings.test.mts src/lyrics/embedded.test.mts src/lyrics/resolver.test.mts", "test:sleep": "node --experimental-strip-types --test src/audio/sleepTimerState.test.mts", "test:troubleshooting": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/db/libraryMaintenance.test.mts src/lib/cacheInvalidation.test.mts", diff --git a/src/app/settings/haptics-lab.tsx b/src/app/settings/haptics-lab.tsx index e5b57d3..002a6c1 100644 --- a/src/app/settings/haptics-lab.tsx +++ b/src/app/settings/haptics-lab.tsx @@ -31,6 +31,7 @@ const SEMANTIC_EVENTS: { { event: 'toggleOff', label: 'Toggle off', description: 'A setting leaves its active state.' }, { event: 'selection', label: 'Selection', description: 'A discrete choice changes.' }, { event: 'frequentStep', label: 'Frequent step', description: 'A repeated row or letter crossing.' }, + { event: 'scrubStep', label: 'Scrub step', description: 'A fine seek detent passes under the finger.' }, { event: 'threshold', label: 'Threshold', description: 'A gesture becomes armed.' }, { event: 'action', label: 'Action', description: 'A direct control commits.' }, { event: 'dragStart', label: 'Drag start', description: 'An item is picked up.' }, diff --git a/src/audio/playbackClock.test.mts b/src/audio/playbackClock.test.mts new file mode 100644 index 0000000..97650e4 --- /dev/null +++ b/src/audio/playbackClock.test.mts @@ -0,0 +1,48 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + applyPlaybackSnapshot, + createPlaybackClock, + projectPlaybackClock, + setPlaybackClockRunning, +} from './playbackClock.ts'; + +test('projects elapsed time only while playback is running', () => { + const clock = createPlaybackClock(10, 120, true, 1_000); + assert.equal(projectPlaybackClock(clock, 120, 2_500), 11.5); +}); + +test('freezes on pause and excludes a long paused interval on resume', () => { + const playing = createPlaybackClock(10, 120, true, 1_000); + const paused = setPlaybackClockRunning(playing, false, 120, 2_500); + + assert.equal(projectPlaybackClock(paused, 120, 62_500), 11.5); + + const resumed = setPlaybackClockRunning(paused, true, 120, 62_500); + assert.equal(projectPlaybackClock(resumed, 120, 63_500), 12.5); +}); + +test('accepts authoritative progress snapshots while playing or paused', () => { + const playing = createPlaybackClock(10, 120, true, 1_000); + const sought = applyPlaybackSnapshot(playing, 75, 120, 2_000); + assert.equal(projectPlaybackClock(sought, 120, 2_500), 75.5); + + const paused = setPlaybackClockRunning(sought, false, 120, 2_500); + const pausedSeek = applyPlaybackSnapshot(paused, 25, 120, 50_000); + assert.equal(projectPlaybackClock(pausedSeek, 120, 90_000), 25); +}); + +test('clamps projections and duration changes to valid track bounds', () => { + const nearEnd = createPlaybackClock(119, 120, true, 1_000); + assert.equal(projectPlaybackClock(nearEnd, 120, 10_000), 120); + assert.equal(projectPlaybackClock(nearEnd, 60, 10_000), 60); + + const invalid = applyPlaybackSnapshot(nearEnd, Number.NaN, 120, 10_000); + assert.equal(projectPlaybackClock(invalid, 120, 10_000), 0); +}); + +test('a track reset replaces the previous interpolation anchor', () => { + const previousTrack = createPlaybackClock(80, 240, true, 1_000); + const nextTrack = applyPlaybackSnapshot(previousTrack, 0, 180, 5_000); + assert.equal(projectPlaybackClock(nextTrack, 180, 5_500), 0.5); +}); diff --git a/src/audio/playbackClock.ts b/src/audio/playbackClock.ts new file mode 100644 index 0000000..50cafda --- /dev/null +++ b/src/audio/playbackClock.ts @@ -0,0 +1,65 @@ +export interface PlaybackClock { + anchorTime: number; + anchorTimestampMs: number; + isPlaying: boolean; +} + +export function clampPlaybackTime(value: number, duration: number): number { + if (!Number.isFinite(value)) return 0; + if (duration <= 0) return Math.max(0, value); + return Math.min(duration, Math.max(0, value)); +} + +export function createPlaybackClock( + currentTime: number, + duration: number, + isPlaying: boolean, + nowMs = 0 +): PlaybackClock { + return { + anchorTime: clampPlaybackTime(currentTime, duration), + anchorTimestampMs: nowMs, + isPlaying, + }; +} + +export function projectPlaybackClock( + clock: PlaybackClock, + duration: number, + nowMs: number +): number { + if (!clock.isPlaying) return clampPlaybackTime(clock.anchorTime, duration); + const elapsedSeconds = Math.max(0, nowMs - clock.anchorTimestampMs) / 1000; + return clampPlaybackTime(clock.anchorTime + elapsedSeconds, duration); +} + +/** Re-anchor to an authoritative RNTP/store progress snapshot. */ +export function applyPlaybackSnapshot( + clock: PlaybackClock, + currentTime: number, + duration: number, + nowMs: number +): PlaybackClock { + return { + anchorTime: clampPlaybackTime(currentTime, duration), + anchorTimestampMs: nowMs, + isPlaying: clock.isPlaying, + }; +} + +/** + * Freeze the projected clock when playback stops, or resume from that frozen + * point without counting any time spent paused. + */ +export function setPlaybackClockRunning( + clock: PlaybackClock, + isPlaying: boolean, + duration: number, + nowMs: number +): PlaybackClock { + return { + anchorTime: projectPlaybackClock(clock, duration, nowMs), + anchorTimestampMs: nowMs, + isPlaying, + }; +} diff --git a/src/audio/useSmoothPlaybackTime.ts b/src/audio/useSmoothPlaybackTime.ts index 0da6e02..7b11e5e 100644 --- a/src/audio/useSmoothPlaybackTime.ts +++ b/src/audio/useSmoothPlaybackTime.ts @@ -1,13 +1,14 @@ import { useEffect, useRef, useState } from 'react'; +import { + applyPlaybackSnapshot, + clampPlaybackTime, + createPlaybackClock, + projectPlaybackClock, + setPlaybackClockRunning, +} from './playbackClock'; const DISPLAY_FRAME_MS = 66; -function clampTime(value: number, duration: number): number { - if (!Number.isFinite(value)) return 0; - if (duration <= 0) return Math.max(0, value); - return Math.min(duration, Math.max(0, value)); -} - /** * Interpolates displayed playback time between RNTP progress snapshots. RNTP * remains authoritative; this only makes the visible timeline move smoothly @@ -18,21 +19,41 @@ export function useSmoothPlaybackTime( duration: number, isPlaying: boolean ): number { - const [displayTime, setDisplayTime] = useState(() => clampTime(currentTime, duration)); - const anchorRef = useRef({ - time: clampTime(currentTime, duration), - timestamp: 0, - }); + const [displayTime, setDisplayTime] = useState(() => + clampPlaybackTime(currentTime, duration) + ); + const clockRef = useRef(createPlaybackClock(currentTime, duration, isPlaying)); useEffect(() => { - const next = clampTime(currentTime, duration); - anchorRef.current = { time: next, timestamp: Date.now() }; + const now = Date.now(); + const nextClock = applyPlaybackSnapshot( + clockRef.current, + currentTime, + duration, + now + ); + clockRef.current = nextClock; + const next = projectPlaybackClock(nextClock, duration, now); const raf = requestAnimationFrame(() => setDisplayTime(next)); return () => cancelAnimationFrame(raf); }, [currentTime, duration]); useEffect(() => { - if (!isPlaying || duration <= 0) return; + const now = Date.now(); + const nextClock = setPlaybackClockRunning( + clockRef.current, + isPlaying, + duration, + now + ); + clockRef.current = nextClock; + + if (!isPlaying || duration <= 0) { + const frozen = projectPlaybackClock(nextClock, duration, now); + const freezeRaf = requestAnimationFrame(() => setDisplayTime(frozen)); + return () => cancelAnimationFrame(freezeRaf); + } + let raf = 0; let lastPaint = 0; @@ -40,9 +61,7 @@ export function useSmoothPlaybackTime( raf = requestAnimationFrame(tick); if (frameTime - lastPaint < DISPLAY_FRAME_MS) return; lastPaint = frameTime; - const anchor = anchorRef.current; - const elapsed = (Date.now() - anchor.timestamp) / 1000; - setDisplayTime(clampTime(anchor.time + elapsed, duration)); + setDisplayTime(projectPlaybackClock(clockRef.current, duration, Date.now())); }; raf = requestAnimationFrame(tick); diff --git a/src/components/WaveformSeekBar.tsx b/src/components/WaveformSeekBar.tsx index 80160a8..f6a5f1b 100644 --- a/src/components/WaveformSeekBar.tsx +++ b/src/components/WaveformSeekBar.tsx @@ -13,6 +13,7 @@ import { Canvas, Group, Path, + Rect, Skia, rect } from '@shopify/react-native-skia'; @@ -24,11 +25,17 @@ import { downsampleWaveform, getWaveform } from '@/scope/waveform'; import { useSmoothPlaybackTime } from '@/audio/useSmoothPlaybackTime'; import { usePlayerStore } from '@/stores/playerStore'; import { playHaptic } from '@/lib/haptics'; +import { + beginScrubDetents, + updateScrubDetents, + type ScrubDetentState, +} from './waveformScrubDetents'; const CANVAS_HEIGHT = 58; const BAR_WIDTH = 3; const BAR_GAP = 2; const MIN_BAR = 0.05; // floor so silent/idle sections still show a sliver +const PLAYHEAD_WIDTH = 2; type WaveformQuality = 'preview' | 'accurate'; interface WaveformSeekBarProps { @@ -81,6 +88,7 @@ export function WaveformSeekBar({ const widthRef = useRef(0); const scrubRef = useRef(null); const grantRef = useRef({ fraction: 0, pageX: 0 }); + const detentRef = useRef(null); const smoothTime = useSmoothPlaybackTime(currentTime, duration, isPlaying); // Load (cache-first) the offline peaks whenever the track changes. @@ -124,27 +132,44 @@ export function WaveformSeekBar({ const handleGrant = (event: GestureResponderEvent) => { const fraction = clamp(event.nativeEvent.locationX / Math.max(1, widthRef.current)); grantRef.current = { fraction, pageX: event.nativeEvent.pageX }; + detentRef.current = beginScrubDetents(fraction * widthRef.current, widthRef.current); setScrub(fraction); - playHaptic('threshold'); }; const handleMove = (event: GestureResponderEvent) => { const delta = (event.nativeEvent.pageX - grantRef.current.pageX) / Math.max(1, widthRef.current); - setScrub(clamp(grantRef.current.fraction + delta)); + const fraction = clamp(grantRef.current.fraction + delta); + setScrub(fraction); + + const detents = detentRef.current; + if (!detents) return; + const update = updateScrubDetents( + detents, + fraction * widthRef.current, + widthRef.current, + Date.now() + ); + detentRef.current = update.state; + if (update.shouldTick) playHaptic('scrubStep'); }; const handleRelease = () => { const fraction = scrubRef.current ?? grantRef.current.fraction; const target = fraction * duration; + detentRef.current = null; onSeek(target); setScrub(null); }; + const handleTerminate = () => { + detentRef.current = null; + setScrub(null); + }; + // Displayed position: scrub > pending seek target > live progress. The player // store clears pendingSeek only after native progress acknowledges the target // or the guard times out, so stale RNTP progress cannot bounce the UI back. - const liveTime = isPlaying ? smoothTime : currentTime; - const liveFraction = duration > 0 ? Math.min(1, liveTime / duration) : 0; + const liveFraction = duration > 0 ? Math.min(1, smoothTime / duration) : 0; const heldFraction = pendingSeek && duration > 0 ? clamp(pendingSeek.target / duration) : null; const fraction = scrubFraction ?? heldFraction ?? liveFraction; const shownTime = fraction * duration; @@ -171,6 +196,10 @@ export function WaveformSeekBar({ }, [source, barCount, barWidth, height]); const splitX = fraction * barWidth; + const playheadX = Math.min( + Math.max(0, barWidth - PLAYHEAD_WIDTH), + Math.max(0, splitX - PLAYHEAD_WIDTH / 2) + ); return ( @@ -183,7 +212,7 @@ export function WaveformSeekBar({ onResponderGrant={handleGrant} onResponderMove={handleMove} onResponderRelease={handleRelease} - onResponderTerminate={() => setScrub(null)} + onResponderTerminate={handleTerminate} accessibilityRole="adjustable" accessibilityLabel="Seek" accessibilityValue={{ min: 0, max: Math.round(duration), now: Math.round(shownTime) }} @@ -195,6 +224,15 @@ export function WaveformSeekBar({ + {barWidth > 0 ? ( + + ) : null} diff --git a/src/components/waveformScrubDetents.test.mts b/src/components/waveformScrubDetents.test.mts new file mode 100644 index 0000000..ccf1368 --- /dev/null +++ b/src/components/waveformScrubDetents.test.mts @@ -0,0 +1,112 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + SCRUB_DETENT_SPACING_DP, + SCRUB_TICK_ACTIVATION_DISTANCE_DP, + SCRUB_TICK_MIN_INTERVAL_MS, + beginScrubDetents, + updateScrubDetents, +} from './waveformScrubDetents.ts'; + +test('begins silently and stays silent within one detent or while stationary', () => { + const initial = beginScrubDetents(4, 120); + assert.deepEqual(initial, { + detentIndex: 0, + lastTickAtMs: null, + startPositionDp: 4, + activated: false, + }); + + const movedInside = updateScrubDetents(initial, 11, 120, 1_000); + assert.equal(movedInside.shouldTick, false); + const held = updateScrubDetents(movedInside.state, 11, 120, 10_000); + assert.equal(held.shouldTick, false); +}); + +test('keeps tap jitter silent even when it crosses a detent boundary', () => { + const initial = beginScrubDetents(11, 120); + const jitter = updateScrubDetents( + initial, + 11 + SCRUB_TICK_ACTIVATION_DISTANCE_DP - 1, + 120, + 1_000 + ); + assert.equal(jitter.state.detentIndex, 1); + assert.equal(jitter.state.activated, false); + assert.equal(jitter.shouldTick, false); + + const held = updateScrubDetents(jitter.state, 16, 120, 10_000); + assert.equal(held.shouldTick, false); +}); + +test('emits one step whenever slow movement crosses a spatial detent', () => { + const initial = beginScrubDetents(0, 120); + const first = updateScrubDetents(initial, SCRUB_DETENT_SPACING_DP, 120, 1_000); + assert.equal(first.shouldTick, true); + const second = updateScrubDetents( + first.state, + SCRUB_DETENT_SPACING_DP * 2, + 120, + 1_000 + SCRUB_TICK_MIN_INTERVAL_MS + ); + assert.equal(second.shouldTick, true); +}); + +test('rate-limits fast crossings without queuing a catch-up tick', () => { + const initial = beginScrubDetents(0, 120); + const first = updateScrubDetents(initial, SCRUB_DETENT_SPACING_DP, 120, 1_000); + const skipped = updateScrubDetents( + first.state, + SCRUB_DETENT_SPACING_DP * 3, + 120, + 1_020 + ); + + assert.equal(skipped.shouldTick, false); + assert.equal(skipped.state.detentIndex, 3); + + const heldAfterLimit = updateScrubDetents( + skipped.state, + SCRUB_DETENT_SPACING_DP * 3, + 120, + 2_000 + ); + assert.equal(heldAfterLimit.shouldTick, false); + + const nextMovement = updateScrubDetents( + heldAfterLimit.state, + SCRUB_DETENT_SPACING_DP * 4, + 120, + 2_001 + ); + assert.equal(nextMovement.shouldTick, true); +}); + +test('direction reversals tick only when they cross a different detent', () => { + const initial = beginScrubDetents(30, 120); + const forward = updateScrubDetents(initial, 48, 120, 1_000); + assert.equal(forward.shouldTick, true); + + const sameDetent = updateScrubDetents(forward.state, 49, 120, 1_100); + assert.equal(sameDetent.shouldTick, false); + + const reverse = updateScrubDetents(sameDetent.state, 35, 120, 1_100); + assert.equal(reverse.shouldTick, true); +}); + +test('clamps out-of-bounds and invalid positions to the waveform', () => { + const beforeStart = beginScrubDetents(-100, 120); + assert.equal(beforeStart.detentIndex, 0); + + const pastEnd = updateScrubDetents(beforeStart, 999, 120, 1_000); + assert.equal(pastEnd.state.detentIndex, Math.floor(120 / SCRUB_DETENT_SPACING_DP)); + assert.equal(pastEnd.shouldTick, true); + + const invalid = beginScrubDetents(Number.NaN, 0); + assert.deepEqual(invalid, { + detentIndex: 0, + lastTickAtMs: null, + startPositionDp: 0, + activated: false, + }); +}); diff --git a/src/components/waveformScrubDetents.ts b/src/components/waveformScrubDetents.ts new file mode 100644 index 0000000..b3c6c2f --- /dev/null +++ b/src/components/waveformScrubDetents.ts @@ -0,0 +1,72 @@ +export const SCRUB_DETENT_SPACING_DP = 16; +export const SCRUB_TICK_MIN_INTERVAL_MS = 90; +export const SCRUB_TICK_ACTIVATION_DISTANCE_DP = 6; + +export interface ScrubDetentState { + detentIndex: number; + lastTickAtMs: number | null; + startPositionDp: number; + activated: boolean; +} + +export interface ScrubDetentUpdate { + state: ScrubDetentState; + shouldTick: boolean; +} + +function clampPosition(positionDp: number, widthDp: number): number { + if (!Number.isFinite(positionDp) || !Number.isFinite(widthDp) || widthDp <= 0) return 0; + return Math.min(widthDp, Math.max(0, positionDp)); +} + +function detentIndex(positionDp: number, widthDp: number): number { + return Math.floor(clampPosition(positionDp, widthDp) / SCRUB_DETENT_SPACING_DP); +} + +/** Begin silently at the detent under the initial touch. */ +export function beginScrubDetents(positionDp: number, widthDp: number): ScrubDetentState { + const startPositionDp = clampPosition(positionDp, widthDp); + return { + detentIndex: detentIndex(startPositionDp, widthDp), + lastTickAtMs: null, + startPositionDp, + activated: false, + }; +} + +/** + * Advance to the detent under the finger. The index always advances even when + * rate-limited, so skipped ticks never catch up after movement stops. + */ +export function updateScrubDetents( + state: ScrubDetentState, + positionDp: number, + widthDp: number, + nowMs: number +): ScrubDetentUpdate { + const nextPositionDp = clampPosition(positionDp, widthDp); + const nextIndex = detentIndex(nextPositionDp, widthDp); + const activated = + state.activated || + Math.abs(nextPositionDp - state.startPositionDp) >= SCRUB_TICK_ACTIVATION_DISTANCE_DP; + if (!activated || nextIndex === state.detentIndex) { + return { + state: { ...state, detentIndex: nextIndex, activated }, + shouldTick: false, + }; + } + + const canTick = + state.lastTickAtMs == null || + nowMs - state.lastTickAtMs >= SCRUB_TICK_MIN_INTERVAL_MS; + + return { + state: { + detentIndex: nextIndex, + lastTickAtMs: canTick ? nowMs : state.lastTickAtMs, + startPositionDp: state.startPositionDp, + activated, + }, + shouldTick: canTick, + }; +} diff --git a/src/lib/hapticCatalog.ts b/src/lib/hapticCatalog.ts index 1c98c0d..472f58e 100644 --- a/src/lib/hapticCatalog.ts +++ b/src/lib/hapticCatalog.ts @@ -3,6 +3,7 @@ export type HapticEvent = | 'toggleOff' | 'selection' | 'frequentStep' + | 'scrubStep' | 'threshold' | 'thresholdExit' | 'action' @@ -52,6 +53,11 @@ export const HAPTIC_DEFINITIONS: Readonly> toggleOff: { semantic: 'toggle-off', fallback: 'selection', recipeId: 'toggleOffB' }, selection: { semantic: 'segment-tick', fallback: 'selection' }, frequentStep: { semantic: 'segment-frequent-tick', fallback: 'selection' }, + scrubStep: { + semantic: 'segment-frequent-tick', + fallback: 'selection', + recipeId: 'scrubStepA', + }, threshold: { semantic: 'gesture-start', fallback: 'lightImpact' }, thresholdExit: { semantic: 'gesture-end', diff --git a/src/lib/hapticRecipes.ts b/src/lib/hapticRecipes.ts index 4afb8dd..80de0dc 100644 --- a/src/lib/hapticRecipes.ts +++ b/src/lib/hapticRecipes.ts @@ -210,6 +210,16 @@ export const HAPTIC_RECIPE_SECTIONS: readonly HapticRecipeSection[] = [ { id: 'dragPlacementB', label: 'B · soft landing', steps: recipe(['quickFall', 0.55], ['lowTick', 0.6, HAPTIC_GAPS_MS.neutral]) }, ], }, + { + id: 'scrubStep', + label: 'Scrub detent', + description: 'A spatial detent passes beneath the seek finger.', + leadingCandidateId: 'scrubStepA', + selectionStatus: 'selected', + candidates: [ + { id: 'scrubStepA', label: 'A · crisp linear click', steps: recipe(['click', 0.65]) }, + ], + }, ], }, { diff --git a/src/lib/haptics.test.mts b/src/lib/haptics.test.mts index 52cec6b..b617e80 100644 --- a/src/lib/haptics.test.mts +++ b/src/lib/haptics.test.mts @@ -24,6 +24,7 @@ const expectedSemantics: Record = { toggleOff: 'toggle-off', selection: 'segment-tick', frequentStep: 'segment-frequent-tick', + scrubStep: 'segment-frequent-tick', threshold: 'gesture-start', thresholdExit: 'gesture-end', action: 'virtual-key', @@ -44,6 +45,7 @@ const expectedFallbacks: Record = { toggleOff: 'selection', selection: 'selection', frequentStep: 'selection', + scrubStep: 'selection', threshold: 'lightImpact', thresholdExit: 'lightImpact', action: 'lightImpact', @@ -114,13 +116,14 @@ test('maps every application event to an Android semantic haptic', () => { test('keeps all tuning candidates within the native recipe contract', () => { assert.equal(HAPTIC_RECIPE_SECTIONS.length, 6); - assert.equal(HAPTIC_RECIPE_GROUPS.length, 16); + assert.equal(HAPTIC_RECIPE_GROUPS.length, 17); assert.deepEqual( HAPTIC_RECIPE_SECTIONS.flatMap((section) => section.groups), HAPTIC_RECIPE_GROUPS ); for (const group of HAPTIC_RECIPE_GROUPS) { - assert.equal(group.candidates.length, group.id.startsWith('timing') ? 4 : 2); + const candidateCount = group.id.startsWith('timing') ? 4 : group.id === 'scrubStep' ? 1 : 2; + assert.equal(group.candidates.length, candidateCount); for (const candidate of group.candidates) { assert.equal(validateHapticRecipe(candidate.steps), true, candidate.id); } @@ -159,6 +162,7 @@ test('records the retimed vote and keeps every composition articulated', () => { toggleOff: 'toggleOffB', dragPickup: 'dragPickupB', dragPlacement: 'dragPlacementA', + scrubStep: 'scrubStepA', confirm: 'confirmA', reject: 'rejectB', thresholdExit: 'thresholdExitA', @@ -212,6 +216,19 @@ test('offers two articulated reject rhythms for a tactile no', () => { ); }); +test('keeps seek detents crisp, strong, and capability-gated', () => { + const scrubStep = hapticRecipeCandidate('scrubStepA'); + assert.ok(scrubStep); + assert.deepEqual(scrubStep.steps, [ + { primitive: 'click', scale: 0.65, delayMs: 0 }, + ]); + assert.equal(canPlayHapticRecipe(scrubStep.steps, capabilities()), true); + assert.equal(canPlayHapticRecipe(scrubStep.steps, capabilities(['tick'])), false); + assert.deepEqual(unsupportedRecipePrimitives(scrubStep.steps, capabilities(['tick'])), [ + 'click', + ]); +}); + test('rejects invalid scale, delay, and empty recipes', () => { assert.equal(validateHapticRecipe([]), false); assert.equal(validateHapticRecipe([{ primitive: 'click', scale: 0 }]), false);