Overlay 2.0 (#12)

This commit is contained in:
2026-03-01 02:36:51 -08:00
committed by GitHub
parent 45df3c466b
commit 44c7761c7c
397 changed files with 15139 additions and 7127 deletions

View File

@@ -5,7 +5,16 @@ import path from 'node:path';
import type { MergedToken } from '../types';
import { PartOfSpeech } from '../types.js';
import { alignTokensToSourceText, computeWordClass, normalizeSubtitle } from './subtitle-render.js';
import {
alignTokensToSourceText,
buildSubtitleTokenHoverRanges,
computeWordClass,
getFrequencyRankLabelForToken,
getJlptLevelLabelForToken,
normalizeSubtitle,
sanitizeSubtitleHoverTokenColor,
shouldRenderTokenizedSubtitle,
} from './subtitle-render.js';
function createToken(overrides: Partial<MergedToken>): MergedToken {
return {
@@ -70,7 +79,7 @@ test('computeWordClass preserves known and n+1 classes while adding JLPT classes
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', () => {
test('computeWordClass keeps known/N+1 color classes exclusive over frequency classes', () => {
const known = createToken({
isKnown: true,
frequencyRank: 10,
@@ -203,6 +212,48 @@ test('computeWordClass skips frequency class when rank is out of topX', () => {
assert.equal(actual, 'word');
});
test('getFrequencyRankLabelForToken returns rank only for frequency-colored tokens', () => {
const settings = {
enabled: true,
topX: 100,
mode: 'single' as const,
singleColor: '#000000',
bandedColors: ['#000000', '#000000', '#000000', '#000000', '#000000'] as [
string,
string,
string,
string,
string,
],
};
const frequencyToken = createToken({ surface: '頻度', frequencyRank: 20 });
const knownToken = createToken({ surface: '既知', isKnown: true, frequencyRank: 20 });
const outOfRangeToken = createToken({ surface: '圏外', frequencyRank: 1000 });
assert.equal(getFrequencyRankLabelForToken(frequencyToken, settings), '20');
assert.equal(getFrequencyRankLabelForToken(knownToken, settings), '20');
assert.equal(getFrequencyRankLabelForToken(outOfRangeToken, settings), null);
});
test('getJlptLevelLabelForToken returns level when token has jlpt metadata', () => {
const jlptToken = createToken({ surface: '語彙', jlptLevel: 'N2' });
const noJlptToken = createToken({ surface: '語彙' });
assert.equal(getJlptLevelLabelForToken(jlptToken), 'N2');
assert.equal(getJlptLevelLabelForToken(noJlptToken), null);
});
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: 'キリキリと' }),
@@ -225,7 +276,10 @@ test('alignTokensToSourceText treats whitespace-only token surfaces as plain tex
createToken({ surface: '体が耐えきれず死に至るが…' }),
];
const segments = alignTokensToSourceText(tokens, '常人が使えば その圧倒的な力に\n体が耐えきれず死に至るが…');
const segments = alignTokensToSourceText(
tokens,
'常人が使えば その圧倒的な力に\n体が耐えきれず死に至るが…',
);
assert.deepEqual(
segments.map((segment) => (segment.kind === 'text' ? `text:${segment.text}` : 'token')),
['token', 'text: ', 'token', 'text:\n', 'token'],
@@ -248,6 +302,29 @@ test('alignTokensToSourceText avoids duplicate tail when later token surface doe
);
});
test('buildSubtitleTokenHoverRanges tracks token offsets across text separators', () => {
const tokens = [createToken({ surface: 'キリキリと' }), createToken({ surface: 'かかってこい' })];
const ranges = buildSubtitleTokenHoverRanges(tokens, 'キリキリと\nかかってこい');
assert.deepEqual(ranges, [
{ start: 0, end: 5, tokenIndex: 0 },
{ start: 6, end: 12, tokenIndex: 1 },
]);
});
test('buildSubtitleTokenHoverRanges ignores unmatched token surfaces', () => {
const tokens = [
createToken({ surface: '君たちが潰した拠点に' }),
createToken({ surface: '教団の主力は1人もいない' }),
];
const ranges = buildSubtitleTokenHoverRanges(
tokens,
'君たちが潰した拠点に\n教団の主力は人もいない',
);
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),
@@ -255,11 +332,16 @@ test('normalizeSubtitle collapses explicit line breaks when collapseLineBreaks i
);
});
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(distCssPath) ? distCssPath : srcCssPath;
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.',
@@ -274,7 +356,7 @@ test('JLPT CSS rules use underline-only styling in renderer stylesheet', () => {
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;/);
assert.doesNotMatch(block, /(?:^|\n)\s*color\s*:/m);
}
for (let band = 1; band <= 5; band += 1) {
@@ -290,4 +372,134 @@ test('JLPT CSS rules use underline-only styling in renderer stylesheet', () => {
);
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;/);
const frequencyTooltipBaseBlock = extractClassBlock(
cssText,
'#subtitleRoot .word[data-frequency-rank]::before',
);
assert.match(frequencyTooltipBaseBlock, /content:\s*attr\(data-frequency-rank\);/);
assert.match(frequencyTooltipBaseBlock, /opacity:\s*0;/);
assert.match(frequencyTooltipBaseBlock, /pointer-events:\s*none;/);
const frequencyTooltipHoverBlock = extractClassBlock(
cssText,
'#subtitleRoot .word[data-frequency-rank]:hover::before',
);
assert.match(frequencyTooltipHoverBlock, /opacity:\s*1;/);
const jlptTooltipBaseBlock = extractClassBlock(
cssText,
'#subtitleRoot .word[data-jlpt-level]::after',
);
assert.match(jlptTooltipBaseBlock, /content:\s*attr\(data-jlpt-level\);/);
assert.match(jlptTooltipBaseBlock, /bottom:\s*-\s*0\.42em;/);
assert.match(jlptTooltipBaseBlock, /opacity:\s*0;/);
assert.match(jlptTooltipBaseBlock, /pointer-events:\s*none;/);
const jlptTooltipHoverBlock = extractClassBlock(
cssText,
'#subtitleRoot .word[data-jlpt-level]:hover::after',
);
assert.match(jlptTooltipHoverBlock, /opacity:\s*1;/);
assert.match(
cssText,
/#subtitleRoot\s+\.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;/);
assert.match(
cssText,
/\.word:is\(\.word-jlpt-n1,\s*\.word-jlpt-n2,\s*\.word-jlpt-n3,\s*\.word-jlpt-n4,\s*\.word-jlpt-n5\):not\(\s*\.word-known\s*\):not\(\.word-n-plus-one\):not\(\.word-frequency-single\):not\(\.word-frequency-band-1\):not\(\s*\.word-frequency-band-2\s*\):not\(\.word-frequency-band-3\):not\(\.word-frequency-band-4\):not\(\.word-frequency-band-5\):hover\s*\{[\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;/,
);
assert.match(
cssText,
/\.word:is\(\.word-jlpt-n1,\s*\.word-jlpt-n2,\s*\.word-jlpt-n3,\s*\.word-jlpt-n4,\s*\.word-jlpt-n5\):not\(\s*\.word-known\s*\):not\(\.word-n-plus-one\):not\(\.word-frequency-single\):not\(\.word-frequency-band-1\):not\(\s*\.word-frequency-band-2\s*\):not\(\.word-frequency-band-3\):not\(\.word-frequency-band-4\):not\(\.word-frequency-band-5\)::selection[\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 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,
);
});