mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-13 01:55:50 -07:00
196 lines
7.5 KiB
TypeScript
196 lines
7.5 KiB
TypeScript
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
assOverrideSignature,
|
|
assToPlainText,
|
|
collectAssOverrideCommands,
|
|
extractAssOverrideBlocks,
|
|
hasAssTemporalOverride,
|
|
isAnimatedAssEffectKind,
|
|
isAssTemporalCommand,
|
|
normalizePlainSubtitleText,
|
|
parseAssEffectField,
|
|
} from './ass-text';
|
|
|
|
test('assToPlainText drops vector drawing runs', () => {
|
|
assert.equal(
|
|
assToPlainText(
|
|
'{\\an5\\pos(730,1042)\\p1\\blur1}m 20 0 b 10 0 0 10 0 20 b 0 31 10 40 20 40 {\\p0}',
|
|
),
|
|
'',
|
|
);
|
|
});
|
|
|
|
test('assToPlainText keeps text around drawing runs on the same event', () => {
|
|
assert.equal(
|
|
assToPlainText('{\\p1}m 0 0 l 10 10{\\p0}本文{\\p1}m 5 5 l 6 6{\\p0}続き'),
|
|
'本文続き',
|
|
);
|
|
});
|
|
|
|
test('assToPlainText leaves \\pos alone when no drawing mode is active', () => {
|
|
assert.equal(assToPlainText('{\\pos(960,1068)\\bord3}位置指定'), '位置指定');
|
|
});
|
|
|
|
test('assToPlainText does not read \\pos as a drawing tag', () => {
|
|
assert.equal(assToPlainText('{\\p1\\pos(1,2)}m 0 0 l 5 5'), '');
|
|
});
|
|
|
|
test('assToPlainText resolves line-break and space escapes', () => {
|
|
assert.equal(assToPlainText('一行目\\N二行目'), '一行目\n二行目');
|
|
assert.equal(assToPlainText('一行目\\n二行目'), '一行目\n二行目');
|
|
assert.equal(assToPlainText('一行目\\N二行目', ' '), '一行目 二行目');
|
|
assert.equal(assToPlainText('間\\h隔'), '間 隔');
|
|
});
|
|
|
|
test('assToPlainText matches mpv on brace and backslash sequences', () => {
|
|
// mpv has no `\{` / `\}` / `\\` escapes: the backslashes are literal text and the
|
|
// braces still open and close an override block.
|
|
assert.equal(assToPlainText('\\{注\\}'), '\\');
|
|
assert.equal(assToPlainText('\\\\N'), '\\\n');
|
|
});
|
|
|
|
test('assToPlainText renders an unclosed override block verbatim', () => {
|
|
// mpv shows the stray brace; guessing where the block ended can eat a whole line.
|
|
assert.equal(assToPlainText('本文{\\pos(1,2)'), '本文{\\pos(1,2)');
|
|
});
|
|
|
|
test('assToPlainText is idempotent', () => {
|
|
const samples = [
|
|
'{\\an5\\p1}m 0 0 l 5 5{\\p0}本文',
|
|
'\\{注\\}',
|
|
'\\\\N',
|
|
'本文{\\pos(1,2)',
|
|
'一行目\\N二行目\\h終わり',
|
|
];
|
|
|
|
for (const sample of samples) {
|
|
const once = assToPlainText(sample);
|
|
assert.equal(assToPlainText(once), once, sample);
|
|
}
|
|
});
|
|
|
|
test('assToPlainText normalizes CRLF before converting', () => {
|
|
assert.equal(assToPlainText('一行目\r\n二行目'), '一行目\n二行目');
|
|
});
|
|
|
|
test('normalizePlainSubtitleText settles whitespace without decoding ASS', () => {
|
|
// A brace reaching this layer is literal text mpv chose to show, not markup.
|
|
assert.equal(normalizePlainSubtitleText('本文{\\pos(1,2)'), '本文{\\pos(1,2)');
|
|
assert.equal(normalizePlainSubtitleText('一行目\\N二行目'), '一行目\n二行目');
|
|
assert.equal(
|
|
normalizePlainSubtitleText('一行目\\N二行目', { collapseLineBreaks: true }),
|
|
'一行目 二行目',
|
|
);
|
|
assert.equal(normalizePlainSubtitleText(' 余白 ', { trim: false }), ' 余白 ');
|
|
});
|
|
|
|
test('normalizePlainSubtitleText is idempotent', () => {
|
|
for (const sample of ['一行目\\N二行目', '間\\h隔', '本文{\\pos(1,2)', ' 余白 ']) {
|
|
const once = normalizePlainSubtitleText(sample);
|
|
assert.equal(normalizePlainSubtitleText(once), once, sample);
|
|
}
|
|
});
|
|
|
|
test('extractAssOverrideBlocks returns block contents', () => {
|
|
assert.deepEqual(extractAssOverrideBlocks('{\\an8}上{\\fad(200,200)}下'), [
|
|
'\\an8',
|
|
'\\fad(200,200)',
|
|
]);
|
|
assert.deepEqual(extractAssOverrideBlocks('括弧なし'), []);
|
|
});
|
|
|
|
test('collectAssOverrideCommands captures names and arguments from blocks only', () => {
|
|
const commands = collectAssOverrideCommands('{\\pos(1,2)\\1c&HFFFFFF&\\kf30}歌詞');
|
|
|
|
assert.deepEqual(commands, [
|
|
{ name: 'pos', args: '1,2', animated: false },
|
|
{ name: '1c', args: '&HFFFFFF&', animated: false },
|
|
{ name: 'kf', args: '30', animated: false },
|
|
]);
|
|
|
|
// A `\pos(...)` sitting in visible text is not typesetting markup.
|
|
assert.deepEqual(collectAssOverrideCommands('\\pos(730,1042) と書いてある'), []);
|
|
});
|
|
|
|
test('collectAssOverrideCommands marks tags animated by a wrapping \\t', () => {
|
|
const commands = collectAssOverrideCommands('{\\clip(0,0,10,10)\\t(0,500,\\frz30)}文字');
|
|
|
|
assert.deepEqual(
|
|
commands.map((command) => [command.name, command.animated]),
|
|
[
|
|
['clip', false],
|
|
['t', false],
|
|
['frz', true],
|
|
],
|
|
);
|
|
assert.equal(hasAssTemporalOverride(commands), true);
|
|
});
|
|
|
|
test('collectAssOverrideCommands stops descending into deeply nested \\t tags', () => {
|
|
// Nested far past the recursion cap. Uncapped, this recurses once per level, and a
|
|
// pathological line (real files reach one or two levels) overflows the stack.
|
|
const nesting = 32;
|
|
const block = `{${'\\t(0,500,'.repeat(nesting)}\\frz30${')'.repeat(nesting)}}文字`;
|
|
|
|
const commands = collectAssOverrideCommands(block);
|
|
|
|
// The outer `\t` plus one per allowed recursion level, and nothing from below the cap.
|
|
assert.equal(commands.length, 9);
|
|
assert.deepEqual(new Set(commands.map((command) => command.name)), new Set(['t']));
|
|
assert.equal(hasAssTemporalOverride(commands), true);
|
|
});
|
|
|
|
test('hasAssTemporalOverride ignores static placement and shape tags', () => {
|
|
assert.equal(
|
|
hasAssTemporalOverride(collectAssOverrideCommands('{\\pos(1,2)\\clip(m 1 1)\\blur2}文字')),
|
|
false,
|
|
);
|
|
assert.equal(hasAssTemporalOverride(collectAssOverrideCommands('{\\move(1,2,3,4)}文字')), true);
|
|
});
|
|
|
|
test('isAssTemporalCommand covers only intrinsically animated tags', () => {
|
|
for (const command of ['t', 'move', 'k', 'kf', 'ko', 'K']) {
|
|
assert.equal(isAssTemporalCommand(command), true, command);
|
|
}
|
|
for (const command of ['clip', 'iclip', 'frz', 'fscx', 'blur', 'be', 'pos', 'fad']) {
|
|
assert.equal(isAssTemporalCommand(command), false, command);
|
|
}
|
|
});
|
|
|
|
test('assOverrideSignature distinguishes events by their override values', () => {
|
|
const first = assOverrideSignature(collectAssOverrideCommands('{\\clip(m 1 1)}歌詞'));
|
|
const second = assOverrideSignature(collectAssOverrideCommands('{\\clip(m 2 2)}歌詞'));
|
|
const repeat = assOverrideSignature(collectAssOverrideCommands('{\\clip(m 1 1)}別の行'));
|
|
|
|
assert.notEqual(first, second);
|
|
assert.equal(first, repeat);
|
|
});
|
|
|
|
test('parseAssEffectField classifies the event-level Effect column', () => {
|
|
assert.equal(parseAssEffectField(''), 'none');
|
|
assert.equal(parseAssEffectField(' '), 'none');
|
|
assert.equal(parseAssEffectField('Banner;20;1;0'), 'banner');
|
|
assert.equal(parseAssEffectField('Scroll up;0;0;30;10'), 'scroll');
|
|
assert.equal(parseAssEffectField('Scroll down;0;0;30;10'), 'scroll');
|
|
assert.equal(parseAssEffectField('Karaoke'), 'karaoke');
|
|
assert.equal(parseAssEffectField('fx-template'), 'other');
|
|
});
|
|
|
|
test('parseAssEffectField matches stock effect names exactly', () => {
|
|
// Custom effect names that merely start with a stock name are not stock effects.
|
|
assert.equal(parseAssEffectField('scrolling-credit'), 'other');
|
|
assert.equal(parseAssEffectField('bannerfx;1'), 'other');
|
|
assert.equal(parseAssEffectField('karaoke-template'), 'other');
|
|
assert.equal(parseAssEffectField('Scroll'), 'other');
|
|
});
|
|
|
|
test('isAnimatedAssEffectKind covers the stock animated effects only', () => {
|
|
assert.equal(isAnimatedAssEffectKind('karaoke'), true);
|
|
assert.equal(isAnimatedAssEffectKind('banner'), true);
|
|
assert.equal(isAnimatedAssEffectKind('scroll'), true);
|
|
// Typesetting groups put static template names in this column too.
|
|
assert.equal(isAnimatedAssEffectKind('other'), false);
|
|
assert.equal(isAnimatedAssEffectKind('none'), false);
|
|
});
|