mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-27 18:22:41 -08:00
423 lines
14 KiB
TypeScript
423 lines
14 KiB
TypeScript
import test from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import fs from 'node:fs';
|
||
import path from 'node:path';
|
||
|
||
import type { MergedToken } from '../types';
|
||
import { PartOfSpeech } from '../types.js';
|
||
import {
|
||
alignTokensToSourceText,
|
||
buildInvisibleTokenHoverRanges,
|
||
computeWordClass,
|
||
normalizeSubtitle,
|
||
sanitizeSubtitleHoverTokenColor,
|
||
shouldRenderTokenizedSubtitle,
|
||
} from './subtitle-render.js';
|
||
|
||
function createToken(overrides: Partial<MergedToken>): MergedToken {
|
||
return {
|
||
surface: '',
|
||
reading: '',
|
||
headword: '',
|
||
startPos: 0,
|
||
endPos: 0,
|
||
partOfSpeech: PartOfSpeech.other,
|
||
isMerged: true,
|
||
isKnown: false,
|
||
isNPlusOneTarget: false,
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
function extractClassBlock(cssText: string, selector: string): string {
|
||
const ruleRegex = /([^{}]+)\{([^}]*)\}/g;
|
||
let match: RegExpExecArray | null = null;
|
||
let fallbackBlock = '';
|
||
|
||
while ((match = ruleRegex.exec(cssText)) !== null) {
|
||
const selectorsBlock = match[1]?.trim() ?? '';
|
||
const selectorBlock = match[2] ?? '';
|
||
|
||
const selectors = selectorsBlock
|
||
.split(',')
|
||
.map((entry) => entry.trim())
|
||
.filter((entry) => entry.length > 0);
|
||
|
||
if (selectors.includes(selector)) {
|
||
if (selectors.length === 1) {
|
||
return selectorBlock;
|
||
}
|
||
|
||
if (!fallbackBlock) {
|
||
fallbackBlock = selectorBlock;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (fallbackBlock) {
|
||
return fallbackBlock;
|
||
}
|
||
|
||
return '';
|
||
}
|
||
|
||
test('computeWordClass preserves known and n+1 classes while adding JLPT classes', () => {
|
||
const knownJlpt = createToken({
|
||
isKnown: true,
|
||
jlptLevel: 'N1',
|
||
surface: '猫',
|
||
});
|
||
const nPlusOneJlpt = createToken({
|
||
isNPlusOneTarget: true,
|
||
jlptLevel: 'N2',
|
||
surface: '犬',
|
||
});
|
||
|
||
assert.equal(computeWordClass(knownJlpt), 'word word-known word-jlpt-n1');
|
||
assert.equal(computeWordClass(nPlusOneJlpt), 'word word-n-plus-one word-jlpt-n2');
|
||
});
|
||
|
||
test('computeWordClass does not add frequency class to known or N+1 terms', () => {
|
||
const known = createToken({
|
||
isKnown: true,
|
||
frequencyRank: 10,
|
||
surface: '既知',
|
||
});
|
||
const nPlusOne = createToken({
|
||
isNPlusOneTarget: true,
|
||
frequencyRank: 10,
|
||
surface: '目標',
|
||
});
|
||
const frequency = createToken({
|
||
frequencyRank: 10,
|
||
surface: '頻度',
|
||
});
|
||
|
||
assert.equal(
|
||
computeWordClass(known, {
|
||
enabled: true,
|
||
topX: 100,
|
||
mode: 'single',
|
||
singleColor: '#000000',
|
||
bandedColors: ['#000000', '#000000', '#000000', '#000000', '#000000'] as const,
|
||
}),
|
||
'word word-known',
|
||
);
|
||
assert.equal(
|
||
computeWordClass(nPlusOne, {
|
||
enabled: true,
|
||
topX: 100,
|
||
mode: 'single',
|
||
singleColor: '#000000',
|
||
bandedColors: ['#000000', '#000000', '#000000', '#000000', '#000000'] as const,
|
||
}),
|
||
'word word-n-plus-one',
|
||
);
|
||
assert.equal(
|
||
computeWordClass(frequency, {
|
||
enabled: true,
|
||
topX: 100,
|
||
mode: 'single',
|
||
singleColor: '#000000',
|
||
bandedColors: ['#000000', '#000000', '#000000', '#000000', '#000000'] as const,
|
||
}),
|
||
'word word-frequency-single',
|
||
);
|
||
});
|
||
|
||
test('computeWordClass adds frequency class for single mode when rank is within topX', () => {
|
||
const token = createToken({
|
||
surface: '猫',
|
||
frequencyRank: 50,
|
||
});
|
||
|
||
const actual = computeWordClass(token, {
|
||
enabled: true,
|
||
topX: 100,
|
||
mode: 'single',
|
||
singleColor: '#000000',
|
||
bandedColors: ['#000000', '#000000', '#000000', '#000000', '#000000'] as const,
|
||
});
|
||
|
||
assert.equal(actual, 'word word-frequency-single');
|
||
});
|
||
|
||
test('computeWordClass adds frequency class when rank equals topX', () => {
|
||
const token = createToken({
|
||
surface: '水',
|
||
frequencyRank: 100,
|
||
});
|
||
|
||
const actual = computeWordClass(token, {
|
||
enabled: true,
|
||
topX: 100,
|
||
mode: 'single',
|
||
singleColor: '#000000',
|
||
bandedColors: ['#000000', '#000000', '#000000', '#000000', '#000000'] as const,
|
||
});
|
||
|
||
assert.equal(actual, 'word word-frequency-single');
|
||
});
|
||
|
||
test('computeWordClass adds frequency class for banded mode', () => {
|
||
const token = createToken({
|
||
surface: '犬',
|
||
frequencyRank: 250,
|
||
});
|
||
|
||
const actual = computeWordClass(token, {
|
||
enabled: true,
|
||
topX: 1000,
|
||
mode: 'banded',
|
||
singleColor: '#000000',
|
||
bandedColors: ['#111111', '#222222', '#333333', '#444444', '#555555'] as const,
|
||
});
|
||
|
||
assert.equal(actual, 'word word-frequency-band-2');
|
||
});
|
||
|
||
test('computeWordClass uses configured band count for banded mode', () => {
|
||
const token = createToken({
|
||
surface: '犬',
|
||
frequencyRank: 2,
|
||
});
|
||
|
||
const actual = computeWordClass(token, {
|
||
enabled: true,
|
||
topX: 4,
|
||
mode: 'banded',
|
||
singleColor: '#000000',
|
||
bandedColors: ['#111111', '#222222', '#333333', '#444444', '#555555'],
|
||
} as any);
|
||
|
||
assert.equal(actual, 'word word-frequency-band-3');
|
||
});
|
||
|
||
test('computeWordClass skips frequency class when rank is out of topX', () => {
|
||
const token = createToken({
|
||
surface: '犬',
|
||
frequencyRank: 1200,
|
||
});
|
||
|
||
const actual = computeWordClass(token, {
|
||
enabled: true,
|
||
topX: 1000,
|
||
mode: 'single',
|
||
singleColor: '#000000',
|
||
bandedColors: ['#000000', '#000000', '#000000', '#000000', '#000000'] as const,
|
||
});
|
||
|
||
assert.equal(actual, 'word');
|
||
});
|
||
|
||
test('sanitizeSubtitleHoverTokenColor falls back for pure black values', () => {
|
||
assert.equal(sanitizeSubtitleHoverTokenColor('#000000'), '#f4dbd6');
|
||
assert.equal(sanitizeSubtitleHoverTokenColor('000000'), '#f4dbd6');
|
||
assert.equal(sanitizeSubtitleHoverTokenColor('#0000'), '#f4dbd6');
|
||
});
|
||
|
||
test('sanitizeSubtitleHoverTokenColor keeps non-black color values', () => {
|
||
assert.equal(sanitizeSubtitleHoverTokenColor('#ff00ff'), '#ff00ff');
|
||
assert.equal(sanitizeSubtitleHoverTokenColor(undefined), '#f4dbd6');
|
||
});
|
||
|
||
test('alignTokensToSourceText preserves newline separators between adjacent token surfaces', () => {
|
||
const tokens = [
|
||
createToken({ surface: 'キリキリと', reading: 'きりきりと', headword: 'キリキリと' }),
|
||
createToken({ surface: 'かかってこい', reading: 'かかってこい', headword: 'かかってこい' }),
|
||
];
|
||
|
||
const segments = alignTokensToSourceText(tokens, 'キリキリと\nかかってこい');
|
||
assert.deepEqual(
|
||
segments.map((segment) => (segment.kind === 'text' ? `text:${segment.text}` : 'token')),
|
||
['token', 'text:\n', 'token'],
|
||
);
|
||
});
|
||
|
||
test('alignTokensToSourceText treats whitespace-only token surfaces as plain text separators', () => {
|
||
const tokens = [
|
||
createToken({ surface: '常人が使えば' }),
|
||
createToken({ surface: ' ' }),
|
||
createToken({ surface: 'その圧倒的な力に' }),
|
||
createToken({ surface: '\n' }),
|
||
createToken({ surface: '体が耐えきれず死に至るが…' }),
|
||
];
|
||
|
||
const segments = alignTokensToSourceText(tokens, '常人が使えば その圧倒的な力に\n体が耐えきれず死に至るが…');
|
||
assert.deepEqual(
|
||
segments.map((segment) => (segment.kind === 'text' ? `text:${segment.text}` : 'token')),
|
||
['token', 'text: ', 'token', 'text:\n', 'token'],
|
||
);
|
||
});
|
||
|
||
test('alignTokensToSourceText avoids duplicate tail when later token surface does not match source', () => {
|
||
const tokens = [
|
||
createToken({ surface: '君たちが潰した拠点に' }),
|
||
createToken({ surface: '教団の主力は1人もいない' }),
|
||
];
|
||
|
||
const segments = alignTokensToSourceText(
|
||
tokens,
|
||
'君たちが潰した拠点に\n教団の主力は1人もいない',
|
||
);
|
||
assert.deepEqual(
|
||
segments.map((segment) => (segment.kind === 'text' ? `text:${segment.text}` : 'token')),
|
||
['token', 'text:\n教団の主力は1人もいない'],
|
||
);
|
||
});
|
||
|
||
test('buildInvisibleTokenHoverRanges tracks token offsets across text separators', () => {
|
||
const tokens = [
|
||
createToken({ surface: 'キリキリと' }),
|
||
createToken({ surface: 'かかってこい' }),
|
||
];
|
||
|
||
const ranges = buildInvisibleTokenHoverRanges(tokens, 'キリキリと\nかかってこい');
|
||
assert.deepEqual(ranges, [
|
||
{ start: 0, end: 5, tokenIndex: 0 },
|
||
{ start: 6, end: 12, tokenIndex: 1 },
|
||
]);
|
||
});
|
||
|
||
test('buildInvisibleTokenHoverRanges ignores unmatched token surfaces', () => {
|
||
const tokens = [
|
||
createToken({ surface: '君たちが潰した拠点に' }),
|
||
createToken({ surface: '教団の主力は1人もいない' }),
|
||
];
|
||
|
||
const ranges = buildInvisibleTokenHoverRanges(tokens, '君たちが潰した拠点に\n教団の主力は1人もいない');
|
||
assert.deepEqual(ranges, [{ start: 0, end: 10, tokenIndex: 0 }]);
|
||
});
|
||
|
||
test('normalizeSubtitle collapses explicit line breaks when collapseLineBreaks is enabled', () => {
|
||
assert.equal(
|
||
normalizeSubtitle('常人が使えば\\Nその圧倒的な力に\\n体が耐えきれず死に至るが…', true, true),
|
||
'常人が使えば その圧倒的な力に 体が耐えきれず死に至るが…',
|
||
);
|
||
});
|
||
|
||
test('shouldRenderTokenizedSubtitle enables token rendering when tokens exist', () => {
|
||
assert.equal(shouldRenderTokenizedSubtitle(5), true);
|
||
assert.equal(shouldRenderTokenizedSubtitle(0), false);
|
||
});
|
||
|
||
test('JLPT CSS rules use underline-only styling in renderer stylesheet', () => {
|
||
const distCssPath = path.join(process.cwd(), 'dist', 'renderer', 'style.css');
|
||
const srcCssPath = path.join(process.cwd(), 'src', 'renderer', 'style.css');
|
||
|
||
const cssPath = fs.existsSync(srcCssPath) ? srcCssPath : distCssPath;
|
||
if (!fs.existsSync(cssPath)) {
|
||
assert.fail(
|
||
'JLPT CSS file missing. Run `bun run build` first, or ensure src/renderer/style.css exists.',
|
||
);
|
||
}
|
||
|
||
const cssText = fs.readFileSync(cssPath, 'utf-8');
|
||
|
||
for (let level = 1; level <= 5; level += 1) {
|
||
const block = extractClassBlock(cssText, `#subtitleRoot .word.word-jlpt-n${level}`);
|
||
assert.ok(block.length > 0, `word-jlpt-n${level} class should exist`);
|
||
assert.match(block, /text-decoration-line:\s*underline;/);
|
||
assert.match(block, /text-decoration-thickness:\s*2px;/);
|
||
assert.match(block, /text-underline-offset:\s*4px;/);
|
||
assert.match(block, /color:\s*inherit;/);
|
||
}
|
||
|
||
for (let band = 1; band <= 5; band += 1) {
|
||
const block = extractClassBlock(
|
||
cssText,
|
||
band === 1
|
||
? '#subtitleRoot .word.word-frequency-single'
|
||
: `#subtitleRoot .word.word-frequency-band-${band}`,
|
||
);
|
||
assert.ok(
|
||
block.length > 0,
|
||
`frequency class word-frequency-${band === 1 ? 'single' : `band-${band}`} should exist`,
|
||
);
|
||
assert.match(block, /color:\s*var\(/);
|
||
}
|
||
|
||
const visibleMacBlock = extractClassBlock(
|
||
cssText,
|
||
'body.platform-macos.layer-visible #subtitleRoot',
|
||
);
|
||
assert.match(visibleMacBlock, /--visible-sub-line-height:\s*1\.64;/);
|
||
assert.match(visibleMacBlock, /--visible-sub-line-gap:\s*0\.54em;/);
|
||
|
||
const subtitleRootBlock = extractClassBlock(cssText, '#subtitleRoot');
|
||
assert.match(
|
||
subtitleRootBlock,
|
||
/--subtitle-hover-token-color:\s*#f4dbd6;/,
|
||
);
|
||
assert.match(
|
||
subtitleRootBlock,
|
||
/--subtitle-hover-token-background-color:\s*rgba\(54,\s*58,\s*79,\s*0\.84\);/,
|
||
);
|
||
assert.match(subtitleRootBlock, /-webkit-text-fill-color:\s*currentColor;/);
|
||
|
||
const charBlock = extractClassBlock(cssText, '#subtitleRoot .c');
|
||
assert.match(charBlock, /-webkit-text-fill-color:\s*currentColor\s*!important;/);
|
||
|
||
const wordBlock = extractClassBlock(cssText, '#subtitleRoot .word');
|
||
assert.match(wordBlock, /-webkit-text-fill-color:\s*currentColor\s*!important;/);
|
||
|
||
assert.match(
|
||
cssText,
|
||
/#subtitleRoot \.word:not\(\.word-known\):not\(\.word-n-plus-one\):not\(\.word-frequency-single\):not\(\s*\.word-frequency-band-1\s*\):not\(\.word-frequency-band-2\):not\(\.word-frequency-band-3\):not\(\.word-frequency-band-4\):not\(\s*\.word-frequency-band-5\s*\):hover\s*\{[\s\S]*?background:\s*var\(--subtitle-hover-token-background-color,\s*rgba\(54,\s*58,\s*79,\s*0\.84\)\);[\s\S]*?color:\s*var\(--subtitle-hover-token-color,\s*#f4dbd6\)\s*!important;[\s\S]*?-webkit-text-fill-color:\s*var\(--subtitle-hover-token-color,\s*#f4dbd6\)\s*!important;/,
|
||
);
|
||
|
||
const coloredWordHoverBlock = extractClassBlock(cssText, '#subtitleRoot .word.word-known:hover');
|
||
assert.match(
|
||
coloredWordHoverBlock,
|
||
/background:\s*var\(--subtitle-hover-token-background-color,\s*rgba\(54,\s*58,\s*79,\s*0\.84\)\);/,
|
||
);
|
||
assert.match(coloredWordHoverBlock, /border-radius:\s*3px;/);
|
||
assert.match(coloredWordHoverBlock, /font-weight:\s*800;/);
|
||
assert.doesNotMatch(coloredWordHoverBlock, /color:\s*var\(--subtitle-hover-token-color/);
|
||
assert.doesNotMatch(coloredWordHoverBlock, /-webkit-text-fill-color:\s*var\(--subtitle-hover-token-color/);
|
||
|
||
const coloredWordSelectionBlock = extractClassBlock(cssText, '#subtitleRoot .word.word-known::selection');
|
||
assert.match(
|
||
coloredWordSelectionBlock,
|
||
/color:\s*var\(--subtitle-known-word-color,\s*#a6da95\)\s*!important;/,
|
||
);
|
||
assert.match(
|
||
coloredWordSelectionBlock,
|
||
/-webkit-text-fill-color:\s*var\(--subtitle-known-word-color,\s*#a6da95\)\s*!important;/,
|
||
);
|
||
|
||
const coloredCharHoverBlock = extractClassBlock(cssText, '#subtitleRoot .word.word-known .c:hover');
|
||
assert.match(coloredCharHoverBlock, /background:\s*transparent;/);
|
||
assert.match(coloredCharHoverBlock, /color:\s*inherit\s*!important;/);
|
||
|
||
const selectionBlock = extractClassBlock(cssText, '#subtitleRoot::selection');
|
||
assert.match(
|
||
selectionBlock,
|
||
/background:\s*var\(--subtitle-hover-token-background-color,\s*rgba\(54,\s*58,\s*79,\s*0\.84\)\);/,
|
||
);
|
||
assert.match(selectionBlock, /color:\s*var\(--subtitle-hover-token-color,\s*#f4dbd6\)\s*!important;/);
|
||
assert.match(
|
||
selectionBlock,
|
||
/-webkit-text-fill-color:\s*var\(--subtitle-hover-token-color,\s*#f4dbd6\)\s*!important;/,
|
||
);
|
||
|
||
const descendantSelectionBlock = extractClassBlock(cssText, '#subtitleRoot *::selection');
|
||
assert.match(
|
||
descendantSelectionBlock,
|
||
/background:\s*var\(--subtitle-hover-token-background-color,\s*rgba\(54,\s*58,\s*79,\s*0\.84\)\)\s*!important;/,
|
||
);
|
||
assert.match(
|
||
descendantSelectionBlock,
|
||
/color:\s*var\(--subtitle-hover-token-color,\s*#f4dbd6\)\s*!important;/,
|
||
);
|
||
assert.match(
|
||
descendantSelectionBlock,
|
||
/-webkit-text-fill-color:\s*var\(--subtitle-hover-token-color,\s*#f4dbd6\)\s*!important;/,
|
||
);
|
||
|
||
assert.doesNotMatch(
|
||
cssText,
|
||
/body\.layer-visible\s+#secondarySubContainer\s*\{[^}]*display:\s*none/i,
|
||
);
|
||
});
|