Files
SubMiner/src/renderer/modals/media-timing-review.test.ts
T

158 lines
5.8 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildMediaTimingLineSelection,
buildMediaTimingWaveformPath,
constrainMediaTimingSelection,
createMediaTimingPreviewRequestGuard,
formatMediaTimingTimestamp,
mediaTimingTimeFromPointer,
slideMediaTimingSelection,
trimMediaTimingSelectionEnd,
} from './media-timing-review';
test('waveform path mirrors normalized peaks around its center line', () => {
const path = buildMediaTimingWaveformPath([0, 0.5, 1]);
assert.match(path, /^M 0\.00 50\.00 L 500\.00 28\.00 L 1000\.00 6\.00/);
assert.match(path, /1000\.00 94\.00 L 500\.00 72\.00 L 0\.00 50\.00 Z$/);
assert.equal(buildMediaTimingWaveformPath([1]), '');
});
test('formatMediaTimingTimestamp renders stable review readouts', () => {
assert.equal(formatMediaTimingTimestamp(65.4321), '01:05.432');
assert.equal(formatMediaTimingTimestamp(65.4321, false), '01:05');
assert.equal(formatMediaTimingTimestamp(-1), '00:00.000');
assert.equal(formatMediaTimingTimestamp(119.9999), '02:00.000');
assert.equal(formatMediaTimingTimestamp(119.6, false), '02:00');
});
test('selection constraints preserve the handle that did not move', () => {
assert.deepEqual(
constrainMediaTimingSelection({
nextStart: 3,
nextEnd: 2,
currentStart: 1,
timelineStart: 0,
timelineEnd: 10,
mediaEnd: 10,
maxMediaDuration: 30,
}),
{ start: 1.9, end: 2 },
);
assert.deepEqual(
constrainMediaTimingSelection({
nextStart: 1,
nextEnd: 0.5,
currentStart: 1,
timelineStart: 0,
timelineEnd: 10,
mediaEnd: 10,
maxMediaDuration: 30,
}),
{ start: 1, end: 1.1 },
);
});
test('pointer positions map onto the visible timeline and clamp past its edges', () => {
const track = { trackLeft: 100, trackWidth: 400, timelineStart: 10, timelineEnd: 20 };
assert.equal(mediaTimingTimeFromPointer({ clientX: 100, ...track }), 10);
assert.equal(mediaTimingTimeFromPointer({ clientX: 300, ...track }), 15);
assert.equal(mediaTimingTimeFromPointer({ clientX: -40, ...track }), 10);
assert.equal(mediaTimingTimeFromPointer({ clientX: 900, ...track }), 20);
assert.equal(mediaTimingTimeFromPointer({ ...track, clientX: 300, trackWidth: 0 }), 10);
});
test('sliding keeps the clip length and stops at the timeline and media bounds', () => {
const timeline = { span: 2, timelineStart: 4, timelineEnd: 12, mediaEnd: 10 };
assert.deepEqual(slideMediaTimingSelection({ nextStart: 6, ...timeline }), { start: 6, end: 8 });
assert.deepEqual(slideMediaTimingSelection({ nextStart: 1, ...timeline }), { start: 4, end: 6 });
assert.deepEqual(slideMediaTimingSelection({ nextStart: 99, ...timeline }), {
start: 8,
end: 10,
});
});
test('line selection combines adjacent lines around the mined one and tracks their range', () => {
const base = {
previousLines: [
{ text: '一行目', startTime: 0, endTime: 2 },
{ text: '二行目', startTime: 2.5, endTime: 4 },
],
nextLines: [
{ text: '四行目', startTime: 7.5, endTime: 9 },
{ text: '五行目', startTime: 9.5, endTime: 11 },
],
text: '採掘行',
originalStartTime: 5,
originalEndTime: 7,
};
const none = buildMediaTimingLineSelection({ ...base, previousCount: 0, nextCount: 0 });
assert.deepEqual(none.lineTexts, ['採掘行']);
assert.equal(none.currentLineIndex, 0);
assert.equal(none.rangeStart, 5);
assert.equal(none.rangeEnd, 7);
const expanded = buildMediaTimingLineSelection({ ...base, previousCount: 1, nextCount: 2 });
assert.deepEqual(expanded.lineTexts, ['二行目', '採掘行', '四行目', '五行目']);
assert.equal(expanded.currentLineIndex, 1);
assert.equal(expanded.sentence, '二行目 採掘行 四行目 五行目');
assert.equal(expanded.rangeStart, 2.5);
assert.equal(expanded.rangeEnd, 11);
});
test('preview request guard blocks overlap and invalidates stale responses', () => {
const guard = createMediaTimingPreviewRequestGuard();
const first = guard.begin();
assert.equal(typeof first, 'number');
assert.equal(guard.begin(), null);
assert.equal(guard.isCurrent(first!), true);
guard.invalidate();
assert.equal(guard.isCurrent(first!), false);
assert.equal(guard.isInFlight(), false);
const second = guard.begin();
assert.equal(typeof second, 'number');
guard.finish(first!);
assert.equal(guard.isCurrent(second!), true);
guard.finish(second!);
assert.equal(guard.isInFlight(), false);
});
test('trailing silence trim follows the last speech slice and keeps cut-off or silent lines', () => {
// 10 slices over a 10 s timeline: one slice per second, speech in seconds 2-4 only.
const peaks = [0, 0, 0.9, 0.8, 0.7, 0.1, 0.2, 0, 0, 0];
const base = { peaks, timelineStart: 0, timelineEnd: 10, lineStart: 2, lineEnd: 8 };
const trimmed = trimMediaTimingSelectionEnd({ ...base, selectionEnd: 8, endPadSeconds: 0 });
assert.ok(trimmed !== null && Math.abs(trimmed - 5.15) < 1e-9);
const padded = trimMediaTimingSelectionEnd({ ...base, selectionEnd: 8.5, endPadSeconds: 0.5 });
assert.ok(padded !== null && Math.abs(padded - 5.65) < 1e-9);
// Speech running through the line end means the subtitle cuts the audio off; keep it.
assert.equal(
trimMediaTimingSelectionEnd({ ...base, lineEnd: 5, selectionEnd: 5, endPadSeconds: 0 }),
null,
);
// No speech inside the line at all: keep the subtitle timing rather than guess.
assert.equal(
trimMediaTimingSelectionEnd({ ...base, lineStart: 6, selectionEnd: 8, endPadSeconds: 0 }),
null,
);
// A saving below the minimum trim is not worth moving the handle for.
assert.equal(
trimMediaTimingSelectionEnd({ ...base, lineEnd: 5.2, selectionEnd: 5.2, endPadSeconds: 0 }),
null,
);
assert.equal(
trimMediaTimingSelectionEnd({ ...base, peaks: [1], selectionEnd: 8, endPadSeconds: 0 }),
null,
);
});