Tooltips - New v-tooltip directive + TooltipHost floating panel replace the native title attribute across 95+ sites (topbar, timeline, sidebar, properties, stage overlays, fullscreen preview, export dialog, clipart drawer, modals). Styled dark card with a configurable label + optional help line for inline documentation; screen readers still get an aria-label when the panel is disabled. - Settings → Interface toggle (`ruwio:tooltipsEnabled`) hides the visual panel for users who know the app. IconButton now routes its title prop through v-tooltip so every existing call site is tooltip-aware without churn. Editor-hide + export filter - New `Layer.editorHidden` flag, eye toggle per layer row, skipped by the Konva stage and the mask-preview canvas path but honoured by exports and fullscreen preview (runtime untouched). - Export dialog gets a "Skip hidden tracks" switch that strips editor-hidden layers from every format (scene JS, runtime, example HTML, MP4, WebM) on opt-in. Disabled when the scene has no hidden layers. Button editor - `+` buttons appear on empty state slots in button-timeline rows and duplicate the nearest keyframe (prev by preference, next otherwise) onto that slot. New `duplicateKeyframeAt` store action carries props, easing, hold, and bezier handles across. Stage / interaction - Plain left-click on the empty content view pans the viewport, but only when the pointer isn't over a Konva shape — transformer handles, vertex dots, and selection boxes that extend past the canvas edge keep their drag. Settings reshuffle - Timeline tab merged into Document (Duration + FPS sit under a new "Timing" sub-heading). New Interface tab gathers three global prefs: Show tooltips, Show locked tracks, Asset thumbnails — driven by shared composables so the existing toolbar toggles stay in sync. Interface-tab CSS defensively resets the scoped label flex-column + uppercase rule that was making the toggle row look like an accident. Project identity + UX polish - Favicon wired up (moved to public/favicon.ico, linked from index.html); scripts/pack.sh packs the minimum Vue project (src, index.html, configs, public) into a tarball with the right version in the filename, and `npm run pack` drives it. - About dialog now credits Torsten Hoeltge. Test infra - Progress bar turns red mid-run on the first failure: runAll gets an onResult callback that TestOverlay uses to push each result into the reactive array incrementally. - mountComponent in the test harness registers v-tooltip on the isolated app instance so IconButton's aria-label contract actually fires under test. Regression tests - duplicateKeyframeAt: copy fidelity (props, easing, hold), collision refusal, unknown-id guards, separated-track targeting. - Shared composables: useTooltipPref and useAssetThumbsPref both return the same ref across calls and persist to localStorage. - Tooltip contract: IconButton ships `aria-label` (not title); live-DOM smoke tests match on aria-label selectors now. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
194 lines
5.3 KiB
TypeScript
194 lines
5.3 KiB
TypeScript
export interface TestResult {
|
|
suite: string
|
|
name: string
|
|
ok: boolean
|
|
error?: string
|
|
stack?: string
|
|
durationMs: number
|
|
}
|
|
|
|
interface SuiteDef {
|
|
name: string
|
|
tests: { name: string; fn: () => void | Promise<void> }[]
|
|
beforeEach?: () => void | Promise<void>
|
|
afterEach?: () => void | Promise<void>
|
|
}
|
|
|
|
const suites: SuiteDef[] = []
|
|
let current: SuiteDef | null = null
|
|
|
|
export function suite(name: string, fn: () => void): void {
|
|
const prev = current
|
|
current = { name, tests: [] }
|
|
try {
|
|
fn()
|
|
suites.push(current)
|
|
} finally {
|
|
current = prev
|
|
}
|
|
}
|
|
|
|
export function test(name: string, fn: () => void | Promise<void>): void {
|
|
if (!current) throw new Error(`test("${name}") called outside of suite()`)
|
|
current.tests.push({ name, fn })
|
|
}
|
|
|
|
export function beforeEach(fn: () => void | Promise<void>): void {
|
|
if (!current) throw new Error('beforeEach outside of suite()')
|
|
current.beforeEach = fn
|
|
}
|
|
|
|
export function afterEach(fn: () => void | Promise<void>): void {
|
|
if (!current) throw new Error('afterEach outside of suite()')
|
|
current.afterEach = fn
|
|
}
|
|
|
|
class Expectation {
|
|
constructor(private value: unknown) {}
|
|
|
|
toBe(expected: unknown): void {
|
|
if (!Object.is(this.value, expected)) {
|
|
throw new Error(`expected ${this.fmt(expected)}, got ${this.fmt(this.value)}`)
|
|
}
|
|
}
|
|
|
|
toEqual(expected: unknown): void {
|
|
const a = JSON.stringify(this.value)
|
|
const b = JSON.stringify(expected)
|
|
if (a !== b) throw new Error(`expected ${b}, got ${a}`)
|
|
}
|
|
|
|
toBeCloseTo(expected: number, precision = 4): void {
|
|
const v = this.value as number
|
|
const eps = Math.pow(10, -precision) / 2
|
|
if (Math.abs(v - expected) > eps) {
|
|
throw new Error(`expected ≈${expected} (p=${precision}), got ${v}`)
|
|
}
|
|
}
|
|
|
|
toBeTruthy(): void {
|
|
if (!this.value) throw new Error(`expected truthy, got ${this.fmt(this.value)}`)
|
|
}
|
|
|
|
toBeFalsy(): void {
|
|
if (this.value) throw new Error(`expected falsy, got ${this.fmt(this.value)}`)
|
|
}
|
|
|
|
toBeNull(): void {
|
|
if (this.value !== null) throw new Error(`expected null, got ${this.fmt(this.value)}`)
|
|
}
|
|
|
|
toBeDefined(): void {
|
|
if (this.value === undefined) throw new Error('expected defined value, got undefined')
|
|
}
|
|
|
|
toBeGreaterThan(n: number): void {
|
|
if (!((this.value as number) > n)) throw new Error(`expected > ${n}, got ${this.value}`)
|
|
}
|
|
|
|
toBeLessThan(n: number): void {
|
|
if (!((this.value as number) < n)) throw new Error(`expected < ${n}, got ${this.value}`)
|
|
}
|
|
|
|
toContain(sub: string): void {
|
|
const s = this.value as string
|
|
if (!s || !s.includes(sub)) {
|
|
throw new Error(`expected string to contain ${JSON.stringify(sub)}`)
|
|
}
|
|
}
|
|
|
|
toHaveLength(n: number): void {
|
|
const l = (this.value as { length: number }).length
|
|
if (l !== n) throw new Error(`expected length ${n}, got ${l}`)
|
|
}
|
|
|
|
private fmt(v: unknown): string {
|
|
if (v === undefined) return 'undefined'
|
|
try {
|
|
return JSON.stringify(v)
|
|
} catch {
|
|
return String(v)
|
|
}
|
|
}
|
|
}
|
|
|
|
export function expect(value: unknown): Expectation {
|
|
return new Expectation(value)
|
|
}
|
|
|
|
function cleanStack(stack: string | undefined): string | undefined {
|
|
if (!stack) return undefined
|
|
const lines = stack.split('\n')
|
|
const filtered = lines.filter((l) => {
|
|
if (l.includes('/testRunner.ts')) return false
|
|
if (l.includes('/testRunner.js')) return false
|
|
if (l.includes('node_modules')) return false
|
|
return true
|
|
})
|
|
return filtered.slice(0, 10).join('\n')
|
|
}
|
|
|
|
export interface RunOptions {
|
|
onProgress?: (done: number, total: number) => void
|
|
onTestStart?: (suite: string, name: string) => void
|
|
onResult?: (result: TestResult) => void
|
|
interTestDelayMs?: number
|
|
}
|
|
|
|
export async function runAll(
|
|
register: () => void,
|
|
opts: RunOptions = {},
|
|
): Promise<TestResult[]> {
|
|
suites.length = 0
|
|
register()
|
|
const total = suites.reduce((a, s) => a + s.tests.length, 0)
|
|
const results: TestResult[] = []
|
|
let done = 0
|
|
const delay = Math.max(0, opts.interTestDelayMs ?? 0)
|
|
for (const s of suites) {
|
|
for (const t of s.tests) {
|
|
opts.onTestStart?.(s.name, t.name)
|
|
await new Promise((r) => requestAnimationFrame(() => r(null)))
|
|
const start = performance.now()
|
|
let pushed: TestResult
|
|
try {
|
|
if (s.beforeEach) await s.beforeEach()
|
|
await t.fn()
|
|
if (s.afterEach) await s.afterEach()
|
|
pushed = {
|
|
suite: s.name,
|
|
name: t.name,
|
|
ok: true,
|
|
durationMs: performance.now() - start,
|
|
}
|
|
} catch (err) {
|
|
try {
|
|
if (s.afterEach) await s.afterEach()
|
|
} catch {
|
|
/* ignore cleanup errors if test already failed */
|
|
}
|
|
const e = err as Error
|
|
pushed = {
|
|
suite: s.name,
|
|
name: t.name,
|
|
ok: false,
|
|
error: e.message || String(err),
|
|
stack: cleanStack(e.stack),
|
|
durationMs: performance.now() - start,
|
|
}
|
|
}
|
|
results.push(pushed)
|
|
// Per-result callback fires before the progress tick so the UI
|
|
// can paint a failing-run indicator (e.g. flip the progress bar
|
|
// to red) the moment a test fails instead of only once runAll
|
|
// resolves.
|
|
opts.onResult?.(pushed)
|
|
done++
|
|
opts.onProgress?.(done, total)
|
|
if (delay > 0) await new Promise((r) => setTimeout(r, delay))
|
|
else await new Promise((r) => setTimeout(r, 0))
|
|
}
|
|
}
|
|
return results
|
|
}
|