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 { 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, ); });