mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-22 02:56:24 -07:00
Overlay 2.0 (#12)
This commit is contained in:
@@ -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教団の主力は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),
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user