0.10.2: audio waveforms + sidebar section polish

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.7 committed 2026-04-22 13:45:34 +02:00
1 parent c5b1dd566a
commit 87de1344c4
6 files changed
+587 -39

No files matched your search

+135
View File
@@ -47,6 +47,12 @@ import Timeline from '@/components/Timeline.vue'
import AssetSidebar from '@/components/AssetSidebar.vue'
import FullscreenPreview from '@/components/FullscreenPreview.vue'
import { createAudioRuntime } from '@/composables/useAudioRuntime'
import {
clearWaveformCache,
drawWaveform,
getWaveformSlot,
slicePeaksForClip,
} from '@/composables/useAudioWaveform'
import { vTooltip } from '@/directives/tooltip'
import { expect, suite, test } from './testRunner'
@@ -4646,6 +4652,87 @@ export function registerAllTests(): void {
handle.unmount()
})
test('sections are expanded by default and collapse on header click', async () => {
try {
localStorage.removeItem('animatr:assetSectionsCollapsed')
} catch {
/* ignore */
}
const s = makeFreshStore()
s.addAudioAsset({ name: 'a', src: 'data:audio/mp3;x', duration: 1 })
const handle = mountComponent(AssetSidebar, {})
await nextTick()
// All four sections present in the header order.
const titles = Array.from(handle.host.querySelectorAll('.section-head-row .section-title'))
.map((el) => el.textContent?.trim())
expect(titles).toEqual(['Images', 'Clips', 'Buttons', 'Audio'])
// Default: audio list is visible.
const audioList = handle.host.querySelector('.audio-asset')
expect(audioList).toBeTruthy()
// Click the Audio section header → list hidden.
const audioHead = Array.from(handle.host.querySelectorAll('.section-head-row .section-head'))
.find((el) => el.querySelector('.section-title')?.textContent?.trim() === 'Audio') as HTMLButtonElement
expect(audioHead).toBeTruthy()
audioHead.click()
await nextTick()
const audioRowAfter = handle.host.querySelector('.audio-asset') as HTMLElement | null
expect(audioRowAfter).toBeTruthy()
expect(audioRowAfter!.offsetParent).toBeNull()
audioHead.click()
await nextTick()
expect((handle.host.querySelector('.audio-asset') as HTMLElement).offsetParent !== null).toBe(true)
handle.unmount()
})
test('section count badge is the last element in every section row (right-aligned)', async () => {
// Both actionless rows (Images / Audio) and action-bearing rows
// (Clips / Buttons) must end with their count badge so the right
// column stays visually aligned across the whole sidebar.
try {
localStorage.removeItem('animatr:assetSectionsCollapsed')
} catch {
/* ignore */
}
makeFreshStore()
const handle = mountComponent(AssetSidebar, {})
await nextTick()
const rows = handle.host.querySelectorAll('.section-head-row')
expect(rows.length).toBe(4)
for (const row of Array.from(rows)) {
const last = row.lastElementChild as HTMLElement
expect(last.classList.contains('section-count')).toBe(true)
}
handle.unmount()
})
test('Library label is gone; "Thumbs" slide switch sits in the Assets header', async () => {
makeFreshStore()
const handle = mountComponent(AssetSidebar, {})
await nextTick()
// Nothing in the sidebar still says "Library".
expect(handle.host.textContent?.includes('Library')).toBe(false)
// The slide switch lives in `.assets-header` next to the <h3> —
// pin that structure so the layout doesn't drift back.
const header = handle.host.querySelector('.assets-header')
expect(header).toBeTruthy()
const h3 = header!.querySelector('h3')
expect(h3?.textContent?.trim()).toBe('Assets')
const label = header!.querySelector('.thumbs-switch .thumbs-switch-label')
expect(label?.textContent?.trim()).toBe('Thumbs')
// And the underlying checkbox input toggles showThumbs.
const input = header!.querySelector('.thumbs-switch-input') as HTMLInputElement
expect(input).toBeTruthy()
const initial = input.checked
input.checked = !initial
input.dispatchEvent(new Event('change', { bubbles: true }))
await nextTick()
// Re-read from a fresh query — the binding updates the checkbox
// via its `:checked` attribute on re-render.
const input2 = handle.host.querySelector('.thumbs-switch-input') as HTMLInputElement
expect(input2.checked).toBe(!initial)
handle.unmount()
})
test('dragstart on an audio row sets the audio-asset DnD payload', async () => {
const s = makeFreshStore()
const asset = s.addAudioAsset({ name: 'beep', src: 'data:audio/mp3;x', duration: 1 })
@@ -6009,6 +6096,54 @@ export function registerAllTests(): void {
expect(created[0].paused).toBe(true)
})
test('getWaveformSlot returns pending the first time, caches subsequent calls', () => {
clearWaveformCache()
// Un-decodable src — the promise rejects, but the immediate
// synchronous return is what we're testing here.
const slot1 = getWaveformSlot('asset-x', 'not-a-real-url')
expect(slot1?.state).toBe('pending')
// Second call while still pending returns the same pending slot
// (no new decode kicked off).
const slot2 = getWaveformSlot('asset-x', 'not-a-real-url')
expect(slot2).toBe(slot1)
clearWaveformCache()
})
test('slicePeaksForClip returns only the audible range when trimmed', () => {
const full = new Float32Array([0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8])
// Asset is 8s long; trimStart=2 → drop first 25%; trimEnd=2 →
// drop last 25%. Expect roughly the middle half of the peaks.
const slice = slicePeaksForClip(full, 8, 2, 2)
expect(slice.length < full.length).toBe(true)
// Every value in the slice must be within the middle portion.
for (let i = 0; i < slice.length; i++) {
expect(slice[i] >= 0.3 - 1e-6 && slice[i] <= 0.6 + 1e-6).toBe(true)
}
})
test('slicePeaksForClip with no trim returns the whole array', () => {
const full = new Float32Array([0.1, 0.2, 0.3])
const slice = slicePeaksForClip(full, 3, 0, 0)
expect(slice.length).toBe(full.length)
})
test('drawWaveform paints something onto the canvas (no throw on empty)', () => {
const canvas = document.createElement('canvas')
canvas.width = 50
canvas.height = 20
// Non-empty peaks — expect SOME non-zero pixels after drawing.
drawWaveform(canvas, new Float32Array([0.5, 0.8, 0.3]), { pixelRatio: 1 })
const ctx = canvas.getContext('2d')!
const data = ctx.getImageData(0, 0, canvas.width, canvas.height).data
let nonZero = 0
for (let i = 0; i < data.length; i += 4) {
if (data[i + 3] > 0) nonZero++
}
expect(nonZero > 0).toBe(true)
// And a zero-length peaks array must not throw.
drawWaveform(canvas, new Float32Array(0))
})
test('useAudioRuntime composable wires/unwires cleanly inside a component lifecycle', async () => {
// The Vue wrapper is a thin shell over createAudioRuntime — mount
// it via a harness component and expect no throw at mount /