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:
1 parent
c5b1dd566a
commit
87de1344c4
6 files changed
+587
-39
No files matched your search
@@ -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 /
|
||||
|
||||
Reference in new issue
Block a user