diff --git a/CHANGELOG.md b/CHANGELOG.md index fa2b75c67..d3233a2c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ ones are marked like "v1.0.0-fork". ## [Unreleased] +### Changed + +* **Single `data_hex` word identity in the reading view** (#237): every word + occurrence now carries its term identity solely as a `data_hex` attribute, + and the redundant `TERM` CSS class has been dropped. The token is now a + short SHA-256-derived hex string (`StringUtils::toClassName()`) — pure + `[0-9a-f]`, so it is selector-safe and the occurrence-lookup regexes become + correct by construction. This replaces the original 2011 `¤`/hex encoder + whose per-byte vs. per-codepoint confusion PHP 8.5 surfaced by deprecating + `ord()` on multi-byte strings. There is no API wire-format change (the `hex` + field keeps its role, recomputed identically on the PHP and JS sides) and no + styling change (the `TERM` class carried no CSS rules). + ## [3.2.1-fork] - 2026-06-30 ### Fixed diff --git a/docs-src/developer/word-identity-data-hex.md b/docs-src/developer/word-identity-data-hex.md index 0da0ad51f..cc52960eb 100644 --- a/docs-src/developer/word-identity-data-hex.md +++ b/docs-src/developer/word-identity-data-hex.md @@ -5,12 +5,10 @@ description: Replace the reading-view TERM CSS class-as-index with a data_h # Proposal: `data_hex` Word Identity -**Status:** Proposed — deferred until after the next release (a major feature lands -first; this is a broad cross-cutting refactor we don't want to collide with it). +**Status:** Implemented (#237), on the post-3.2.1 development line. Tracked in [issue #237](https://github.com/HugoFara/lwt/issues/237). -A design proposal, not shipped work. It records the agreed approach so it isn't lost -between releases. +This document records the design and rationale of the change as it was carried out. ## Problem @@ -73,28 +71,31 @@ keeps its exact role, just a hash string. So there is **no wire-format ripple** The only thing given up is human readability of the token in devtools (`data_hex="3a7f9c2e1b0d4f88"` instead of a mostly-readable string) — accepted. -## Scope sketch - -When picked up post-release: +## What changed - **PHP token:** `src/Shared/Infrastructure/Utilities/StringUtils.php::toClassName()` - → hash. Keep `toHex()` (independent, tested utility). -- **PHP emit (5 spans, 2 files):** drop `'TERM' . toClassName(...)` from the `class` - and add `'data_hex' => toClassName(...)` in + → `substr(hash('sha256', $s), 0, 16)`. `toHex()` kept (independent, tested utility). +- **PHP emit (5 spans, 2 files):** dropped `'TERM' . toClassName(...)` from the `class` + and added `'data_hex' => toClassName(...)` in `Modules/Text/Application/Services/TextReadingService.php` (×3) and - `Modules/Vocabulary/Application/Services/ExpressionService.php` (×2). -- **JS emit:** remove the `TERM${word.hex}` push in - `modules/text/pages/reading/text_renderer.ts` (`data_hex` is already emitted). -- **JS selectors (~9):** `.TERM${hex}` → `[data_hex="${hex}"]` in - `modules/vocabulary/services/word_dom_updates.ts`, - `modules/vocabulary/pages/word_result_init.ts`, and `text_renderer.ts`. -- **JS extractors (4):** read `data_hex` instead of parsing the class in - `text_reader.ts`, `text_keyboard.ts`, `word_actions.ts`, `text_events.ts`. -- **Tests:** update `toClassName` assertions in - `tests/backend/Core/IntegrationTest.php` and `tests/backend/Core/Text/TextProcessingTest.php` - (assert hash shape, not the old `¤` output); migrate frontend fixtures from + `Modules/Vocabulary/Application/Services/ExpressionService.php` (×2). The + server-rendered spans previously carried the token *only* as the class, so this + is an add-`data_hex` change, not just a drop. +- **JS emit:** removed the `TERM${word.hex}` push in + `modules/text/pages/reading/text_renderer.ts` (`data_hex` was already emitted). +- **JS selectors (9):** `.TERM${hex}` → `[data_hex="${hex}"]` in + `modules/vocabulary/services/word_dom_updates.ts` (×5), + `modules/vocabulary/pages/word_result_init.ts` (×2), and `text_renderer.ts` (×2). +- **JS extractors (2):** dropped the `TERM([0-9A-Fa-f]+)` class-regex fallback and now + read `data_hex` directly in `text_reader.ts` and `text_keyboard.ts`. (The + `word_actions.ts` / `text_events.ts` extractors named in the original proposal had + already been refactored away by the time this landed.) +- **Tests:** rewrote the `toClassName` assertions in + `tests/backend/Core/IntegrationTest.php` to assert the hash shape (16-char lowercase + hex) instead of the old `¤` output; migrated the frontend fixtures from `class="… TERM"` + `.TERM` to `data_hex=""` + `[data_hex=""]` - (`tests/frontend/reading/*`, `tests/frontend/words/*`, `tests/frontend/texts/text_reader.test.ts`). + (`tests/frontend/reading/*`, `tests/frontend/words/*`, `tests/frontend/texts/text_reader.test.ts`) + and removed the now-obsolete class-name-fallback extractor test. ### Out of scope diff --git a/src/Modules/Text/Application/Services/TextReadingService.php b/src/Modules/Text/Application/Services/TextReadingService.php index ee4373552..9eb34eb61 100644 --- a/src/Modules/Text/Application/Services/TextReadingService.php +++ b/src/Modules/Text/Application/Services/TextReadingService.php @@ -83,10 +83,10 @@ public function echoTerm( [ $hidetag, "click", "mword", ($showAll ? 'mwsty' : 'wsty'), "order" . $ti2Order, - 'word' . $woId, 'status' . $woStatus, - 'TERM' . StringUtils::toClassName($tiTextLC) + 'word' . $woId, 'status' . $woStatus ] ), + 'data_hex' => StringUtils::toClassName($tiTextLC), 'data_pos' => $currcharcount, 'data_order' => $ti2Order, 'data_wid' => $woId, @@ -129,10 +129,10 @@ public function echoTerm( " ", [ $hidetag, "click", "word", "wsty", "word" . $woId, - 'status' . $woStatus, - 'TERM' . StringUtils::toClassName($tiTextLC) + 'status' . $woStatus ] ), + 'data_hex' => StringUtils::toClassName($tiTextLC), 'data_pos' => $currcharcount, 'data_order' => $ti2Order, 'data_wid' => $woId, @@ -152,10 +152,10 @@ public function echoTerm( 'class' => implode( " ", [ - $hidetag, "click", "word", "wsty", "status0", - "TERM" . StringUtils::toClassName($tiTextLC) + $hidetag, "click", "word", "wsty", "status0" ] ), + 'data_hex' => StringUtils::toClassName($tiTextLC), 'data_pos' => $currcharcount, 'data_order' => $ti2Order, 'data_trans' => '', diff --git a/src/Modules/Vocabulary/Application/Services/ExpressionService.php b/src/Modules/Vocabulary/Application/Services/ExpressionService.php index 3b887204f..cbd71872b 100644 --- a/src/Modules/Vocabulary/Application/Services/ExpressionService.php +++ b/src/Modules/Vocabulary/Application/Services/ExpressionService.php @@ -368,7 +368,8 @@ public function newMultiWordInteractable(string $hex, array $multiwords, int $wi $woStatus = (int)($record["WoStatus"] ?? 1); $attrs = [ - "class" => "click mword {$showType}wsty TERM$hex word$wid status" . $woStatus, + "class" => "click mword {$showType}wsty word$wid status" . $woStatus, + "data_hex" => $hex, "data_trans" => (string)($record["WoTranslation"] ?? ''), "data_rom" => (string)($record["WoRomanization"] ?? ''), "data_code" => $len, @@ -410,7 +411,8 @@ public function newExpressionInteractable2(string $hex, array $appendtext, int $ $woStatus = (int)($record["WoStatus"] ?? 1); $attrs = [ - "class" => "click mword {$showType}wsty TERM$hex word$wid status" . $woStatus, + "class" => "click mword {$showType}wsty word$wid status" . $woStatus, + "data_hex" => $hex, "data_trans" => (string)($record["WoTranslation"] ?? ''), "data_rom" => (string)($record["WoRomanization"] ?? ''), "data_code" => $len, diff --git a/src/Shared/Infrastructure/Utilities/StringUtils.php b/src/Shared/Infrastructure/Utilities/StringUtils.php index d6f13afcd..b134f1426 100644 --- a/src/Shared/Infrastructure/Utilities/StringUtils.php +++ b/src/Shared/Infrastructure/Utilities/StringUtils.php @@ -68,44 +68,22 @@ public static function toHex(string $string): string } /** - * Escape a string for use as a CSS class name. + * Derive an opaque identity token for a term's lowercase text. * - * Escapes everything to "¤xx" (where xx is hex) except: - * - 0-9 (ASCII 48-57) - * - a-z (ASCII 97-122) - * - A-Z (ASCII 65-90) - * - Unicode characters >= 165 (hex 00A5) + * Used in the reading view as the `data_hex` attribute so every occurrence of + * the same term can be restyled in one pass when its status changes. The token + * is computed per render and never stored or reversed back to text, so a short + * one-way hash is sufficient. A pure `[0-9a-f]` hash is also selector-safe — no + * `CSS.escape` needed — and is recomputed identically on both the PHP and JS + * sides, so the API `hex` field keeps its exact role. * - * @param string $string String to escape + * @param string $string String to derive the token from (typically WoTextLC) * - * @return string CSS-safe class name + * @return string 16-character (64-bit) lowercase hex token */ public static function toClassName(string $string): string { - $length = mb_strlen($string, 'UTF-8'); - $result = ''; - for ($i = 0; $i < $length; $i++) { - $char = mb_substr($string, $i, 1, 'UTF-8'); - // $char may be multi-byte; ord() only reads byte 0 (PHP 8.5 deprecates - // passing a multi-byte string). $char[0] makes that explicit and keeps - // the existing first-byte behavior. - // TODO(php8.5): consider mb_ord($char, 'UTF-8') for true codepoints — - // it would also escape codepoints 123-164 per this method's documented - // contract, which the first-byte approach currently lets through. That - // is a deliberate behavior change to CSS class names; defer until vetted. - $ord = ord($char[0]); - if ( - ($ord < 48) - || ($ord > 57 && $ord < 65) - || ($ord > 90 && $ord < 97) - || ($ord > 122 && $ord < 165) - ) { - $result .= '¤' . self::toHex($char); - } else { - $result .= $char; - } - } - return $result; + return substr(hash('sha256', $string), 0, 16); } /** diff --git a/src/frontend/js/modules/text/components/text_reader.ts b/src/frontend/js/modules/text/components/text_reader.ts index 3a439366d..0f1e11669 100644 --- a/src/frontend/js/modules/text/components/text_reader.ts +++ b/src/frontend/js/modules/text/components/text_reader.ts @@ -238,7 +238,7 @@ export function textReaderData(): TextReaderData { event.stopPropagation(); // Get word data from element (use getAttribute for underscore attributes) - const hex = wordEl.getAttribute('data_hex') || wordEl.className.match(/TERM([0-9A-F]+)/)?.[1] || ''; + const hex = wordEl.getAttribute('data_hex') || ''; const position = parseInt(wordEl.getAttribute('data_order') || wordEl.getAttribute('data_pos') || '0', 10); if (!hex) return; diff --git a/src/frontend/js/modules/text/pages/reading/text_keyboard.ts b/src/frontend/js/modules/text/pages/reading/text_keyboard.ts index 30ed77202..16e305006 100644 --- a/src/frontend/js/modules/text/pages/reading/text_keyboard.ts +++ b/src/frontend/js/modules/text/pages/reading/text_keyboard.ts @@ -273,8 +273,7 @@ export function handleTextKeydown(e: KeyboardEvent): boolean { const wid = getAttrElement(curr, 'data_wid'); const ord = getAttrElement(curr, 'data_order'); const stat = getAttrElement(curr, 'data_status'); - const hex = getAttrElement(curr, 'data_hex') || - curr.className.match(/TERM([a-f0-9]+)/)?.[1] || ''; + const hex = getAttrElement(curr, 'data_hex') || ''; const txt = curr.classList.contains('mwsty') ? getAttrElement(curr, 'data_text') : (curr.textContent || ''); diff --git a/src/frontend/js/modules/text/pages/reading/text_renderer.ts b/src/frontend/js/modules/text/pages/reading/text_renderer.ts index adda61a9e..4976cd258 100644 --- a/src/frontend/js/modules/text/pages/reading/text_renderer.ts +++ b/src/frontend/js/modules/text/pages/reading/text_renderer.ts @@ -111,9 +111,6 @@ function buildWordClasses(word: WordData, showAll: boolean): string { // Status class classes.push(`status${word.status}`); - // TERM class for hex lookup - classes.push(`TERM${word.hex}`); - return classes.join(' '); } @@ -429,7 +426,7 @@ export function updateWordStatusInDOM( newWordId: number | null = null, container: Element = document.body ): void { - const selector = `.TERM${hex}`; + const selector = `[data_hex="${hex}"]`; const elements = container.querySelectorAll(selector); elements.forEach(el => { @@ -462,7 +459,7 @@ export function updateWordTranslationInDOM( romanization: string, container: Element = document.body ): void { - const selector = `.TERM${hex}`; + const selector = `[data_hex="${hex}"]`; const elements = container.querySelectorAll(selector); elements.forEach(el => { diff --git a/src/frontend/js/modules/vocabulary/pages/expression_interactable.ts b/src/frontend/js/modules/vocabulary/pages/expression_interactable.ts index 642ffb2a1..a95f723b3 100644 --- a/src/frontend/js/modules/vocabulary/pages/expression_interactable.ts +++ b/src/frontend/js/modules/vocabulary/pages/expression_interactable.ts @@ -18,6 +18,7 @@ import { getTextId } from '@modules/text/stores/text_config'; */ interface TermAttrs { class: string; + data_hex: string; data_trans: string; data_rom: string; data_code: number; diff --git a/src/frontend/js/modules/vocabulary/pages/word_result_init.ts b/src/frontend/js/modules/vocabulary/pages/word_result_init.ts index fd27cd9d3..7e27df14e 100644 --- a/src/frontend/js/modules/vocabulary/pages/word_result_init.ts +++ b/src/frontend/js/modules/vocabulary/pages/word_result_init.ts @@ -222,7 +222,7 @@ function initHoverSaveResult(config: HoverSaveResultConfig): void { // Always generate tooltips (jQuery UI tooltips removed, now using native) const title = createWordTooltip(config.wordRaw, config.translation, '', String(config.status)); - context.querySelectorAll(`.TERM${config.hex}`).forEach(el => { + context.querySelectorAll(`[data_hex="${config.hex}"]`).forEach(el => { el.classList.remove('status0'); el.classList.add(`status${config.status}`, `word${config.wid}`); el.setAttribute('data_status', String(config.status)); @@ -248,7 +248,7 @@ function initAllWellKnownResult(config: AllWellKnownConfig): void { title = createWordTooltip(word.term, '*', '', String(word.status)); } - context.querySelectorAll(`.TERM${word.hex}`).forEach(el => { + context.querySelectorAll(`[data_hex="${word.hex}"]`).forEach(el => { el.classList.remove('status0'); el.classList.add(`status${word.status}`, `word${word.wid}`); el.setAttribute('data_status', String(word.status)); diff --git a/src/frontend/js/modules/vocabulary/services/word_dom_updates.ts b/src/frontend/js/modules/vocabulary/services/word_dom_updates.ts index 86d7f9de7..1ad3db3e1 100644 --- a/src/frontend/js/modules/vocabulary/services/word_dom_updates.ts +++ b/src/frontend/js/modules/vocabulary/services/word_dom_updates.ts @@ -87,7 +87,7 @@ export function updateNewWordInDOM(params: WordUpdateParams): void { const context = getParentContext(); const title = generateTooltip(text, translation, romanization, status); - context.querySelectorAll(`.TERM${hex}`).forEach(el => { + context.querySelectorAll(`[data_hex="${hex}"]`).forEach(el => { el.classList.remove('status0'); el.classList.add(`word${wid}`, `status${status}`); el.setAttribute('data_trans', translation); @@ -189,7 +189,7 @@ export function markWordWellKnownInDOM(wid: number, hex: string, term: string): const title = createWordTooltip(term, '*', '', '99'); - frameL.querySelectorAll(`.TERM${hex}`).forEach(el => { + frameL.querySelectorAll(`[data_hex="${hex}"]`).forEach(el => { el.classList.remove('status0'); el.classList.add('status99', `word${wid}`); el.setAttribute('data_status', '99'); @@ -211,7 +211,7 @@ export function markWordIgnoredInDOM(wid: number, hex: string, term: string): vo const title = createWordTooltip(term, '*', '', '98'); - frameL.querySelectorAll(`.TERM${hex}`).forEach(el => { + frameL.querySelectorAll(`[data_hex="${hex}"]`).forEach(el => { el.classList.remove('status0'); el.classList.add('status98', `word${wid}`); el.setAttribute('data_status', '98'); @@ -306,7 +306,7 @@ export interface BulkWordUpdateParams { export function updateBulkWordInDOM(term: BulkWordUpdateParams, useTooltip: boolean): void { const context = getParentContext(); - context.querySelectorAll(`.TERM${term.hex}`).forEach(el => { + context.querySelectorAll(`[data_hex="${term.hex}"]`).forEach(el => { el.classList.remove('status0'); el.classList.add(`status${term.WoStatus}`, `word${term.WoID}`); el.setAttribute('data_wid', String(term.WoID)); @@ -345,7 +345,7 @@ export function updateHoverSaveInDOM( const context = getParentContext(); const title = createWordTooltip(wordRaw, translation, '', String(status)); - context.querySelectorAll(`.TERM${hex}`).forEach(el => { + context.querySelectorAll(`[data_hex="${hex}"]`).forEach(el => { el.classList.remove('status0'); el.classList.add(`status${status}`, `word${wid}`); el.setAttribute('data_status', String(status)); diff --git a/tests/backend/Core/IntegrationTest.php b/tests/backend/Core/IntegrationTest.php index 68fbffa5d..320bbb3a5 100644 --- a/tests/backend/Core/IntegrationTest.php +++ b/tests/backend/Core/IntegrationTest.php @@ -227,16 +227,26 @@ public function testStrToHex(): void public function testStrToClassName(): void { - $this->assertEquals('hello', StringUtils::toClassName('hello')); - $this->assertEquals('test123', StringUtils::toClassName('test123')); + // The identity token is a 16-char (64-bit) lowercase-hex prefix of sha256. + $hello = StringUtils::toClassName('hello'); + $this->assertSame(substr(hash('sha256', 'hello'), 0, 16), $hello); + $this->assertMatchesRegularExpression('/^[0-9a-f]{16}$/', $hello); - // Space (ASCII 32) is outside allowed range, converted to ¤20 - $this->assertEquals('hello¤20world', StringUtils::toClassName('hello world')); + // Deterministic: the same input always yields the same token. + $this->assertSame($hello, StringUtils::toClassName('hello')); - // Non-ASCII should be converted to hex with ¤ prefix - $result = StringUtils::toClassName('hello 世界'); - $this->assertStringStartsWith('hello', $result); - $this->assertStringContainsString('¤', $result); + // Distinct inputs yield distinct tokens. + $this->assertNotSame($hello, StringUtils::toClassName('test123')); + + // Spaces and non-ASCII stay pure hex — selector-safe, no escaping needed. + $this->assertMatchesRegularExpression( + '/^[0-9a-f]{16}$/', + StringUtils::toClassName('hello world') + ); + $this->assertMatchesRegularExpression( + '/^[0-9a-f]{16}$/', + StringUtils::toClassName('hello 世界') + ); } public function testReplTabNl(): void diff --git a/tests/frontend/reading/text_renderer.test.ts b/tests/frontend/reading/text_renderer.test.ts index 39ce2e365..9e4707d06 100644 --- a/tests/frontend/reading/text_renderer.test.ts +++ b/tests/frontend/reading/text_renderer.test.ts @@ -99,11 +99,11 @@ describe('reading/text_renderer.ts', () => { expect(result).toContain('status3'); }); - it('includes hex class', () => { + it('includes hex as the data_hex attribute', () => { const word = createWordData({ hex: 'abc123' }); const result = renderWord(word, defaultSettings); - expect(result).toContain('TERMabc123'); + expect(result).toContain('data_hex="abc123"'); }); it('includes word ID class when present', () => { @@ -259,8 +259,8 @@ describe('reading/text_renderer.ts', () => { describe('updateWordStatusInDOM', () => { it('updates status class on matching elements', () => { - document.body.innerHTML = 'word'; - const element = document.querySelector('.TERM123') as HTMLElement; + document.body.innerHTML = 'word'; + const element = document.querySelector('[data_hex="123"]') as HTMLElement; updateWordStatusInDOM('123', 3); @@ -269,8 +269,8 @@ describe('reading/text_renderer.ts', () => { }); it('updates data_status attribute', () => { - document.body.innerHTML = 'word'; - const element = document.querySelector('.TERM123') as HTMLElement; + document.body.innerHTML = 'word'; + const element = document.querySelector('[data_hex="123"]') as HTMLElement; updateWordStatusInDOM('123', 5); @@ -279,21 +279,21 @@ describe('reading/text_renderer.ts', () => { it('updates multiple matching elements', () => { document.body.innerHTML = ` - word1 - word2 + word1 + word2 `; updateWordStatusInDOM('123', 2); - const elements = document.querySelectorAll('.TERM123'); + const elements = document.querySelectorAll('[data_hex="123"]'); elements.forEach(el => { expect(el.classList.contains('status2')).toBe(true); }); }); it('adds word ID class when provided', () => { - document.body.innerHTML = 'word'; - const element = document.querySelector('.TERM123') as HTMLElement; + document.body.innerHTML = 'word'; + const element = document.querySelector('[data_hex="123"]') as HTMLElement; updateWordStatusInDOM('123', 1, 456); @@ -301,8 +301,8 @@ describe('reading/text_renderer.ts', () => { }); it('sets data_wid when word ID provided', () => { - document.body.innerHTML = 'word'; - const element = document.querySelector('.TERM123') as HTMLElement; + document.body.innerHTML = 'word'; + const element = document.querySelector('[data_hex="123"]') as HTMLElement; updateWordStatusInDOM('123', 1, 789); @@ -310,8 +310,8 @@ describe('reading/text_renderer.ts', () => { }); it('removes data_wid when word ID is 0', () => { - document.body.innerHTML = 'word'; - const element = document.querySelector('.TERM123') as HTMLElement; + document.body.innerHTML = 'word'; + const element = document.querySelector('[data_hex="123"]') as HTMLElement; updateWordStatusInDOM('123', 0, 0); @@ -320,15 +320,15 @@ describe('reading/text_renderer.ts', () => { it('uses custom container when provided', () => { const container = document.createElement('div'); - container.innerHTML = 'word'; + container.innerHTML = 'word'; document.body.appendChild(container); - document.body.innerHTML += 'outside'; + document.body.innerHTML += 'outside'; updateWordStatusInDOM('123', 4, null, container); - const insideEl = container.querySelector('.TERM123') as HTMLElement; - const outsideEl = document.body.querySelector(':scope > .TERM123') as HTMLElement; + const insideEl = container.querySelector('[data_hex="123"]') as HTMLElement; + const outsideEl = document.body.querySelector(':scope > [data_hex="123"]') as HTMLElement; expect(insideEl.classList.contains('status4')).toBe(true); expect(outsideEl.classList.contains('status0')).toBe(true); @@ -341,8 +341,8 @@ describe('reading/text_renderer.ts', () => { describe('updateWordTranslationInDOM', () => { it('sets data_trans attribute', () => { - document.body.innerHTML = 'word'; - const element = document.querySelector('.TERM123') as HTMLElement; + document.body.innerHTML = 'word'; + const element = document.querySelector('[data_hex="123"]') as HTMLElement; updateWordTranslationInDOM('123', 'translation', ''); @@ -350,8 +350,8 @@ describe('reading/text_renderer.ts', () => { }); it('sets data_rom attribute', () => { - document.body.innerHTML = 'word'; - const element = document.querySelector('.TERM123') as HTMLElement; + document.body.innerHTML = 'word'; + const element = document.querySelector('[data_hex="123"]') as HTMLElement; updateWordTranslationInDOM('123', '', 'romanization'); @@ -359,8 +359,8 @@ describe('reading/text_renderer.ts', () => { }); it('removes data_trans when empty', () => { - document.body.innerHTML = 'word'; - const element = document.querySelector('.TERM123') as HTMLElement; + document.body.innerHTML = 'word'; + const element = document.querySelector('[data_hex="123"]') as HTMLElement; updateWordTranslationInDOM('123', '', ''); @@ -368,8 +368,8 @@ describe('reading/text_renderer.ts', () => { }); it('removes data_rom when empty', () => { - document.body.innerHTML = 'word'; - const element = document.querySelector('.TERM123') as HTMLElement; + document.body.innerHTML = 'word'; + const element = document.querySelector('[data_hex="123"]') as HTMLElement; updateWordTranslationInDOM('123', '', ''); @@ -378,13 +378,13 @@ describe('reading/text_renderer.ts', () => { it('updates multiple elements', () => { document.body.innerHTML = ` - word1 - word2 + word1 + word2 `; updateWordTranslationInDOM('123', 'new trans', 'new rom'); - const elements = document.querySelectorAll('.TERM123'); + const elements = document.querySelectorAll('[data_hex="123"]'); elements.forEach(el => { expect(el.getAttribute('data_trans')).toBe('new trans'); expect(el.getAttribute('data_rom')).toBe('new rom'); @@ -393,12 +393,12 @@ describe('reading/text_renderer.ts', () => { it('uses custom container when provided', () => { const container = document.createElement('div'); - container.innerHTML = 'word'; + container.innerHTML = 'word'; document.body.appendChild(container); updateWordTranslationInDOM('123', 'trans', 'rom', container); - const el = container.querySelector('.TERM123') as HTMLElement; + const el = container.querySelector('[data_hex="123"]') as HTMLElement; expect(el.getAttribute('data_trans')).toBe('trans'); }); }); diff --git a/tests/frontend/texts/text_reader.test.ts b/tests/frontend/texts/text_reader.test.ts index e77872521..d483d4de2 100644 --- a/tests/frontend/texts/text_reader.test.ts +++ b/tests/frontend/texts/text_reader.test.ts @@ -615,7 +615,7 @@ describe('text_reader.ts', () => { it('selects word when clicking .word element', () => { document.body.innerHTML = `
- hello + hello
`; @@ -632,7 +632,7 @@ describe('text_reader.ts', () => { it('selects word when clicking .mword element', () => { document.body.innerHTML = `
- multi word + multi word
`; @@ -646,23 +646,6 @@ describe('text_reader.ts', () => { expect(mockWordStore.selectWord).toHaveBeenCalledWith('DEF456', 10, wordEl); }); - it('extracts hex from class name when data_hex missing', () => { - document.body.innerHTML = ` -
- test -
- `; - - const component = textReaderData(); - const wordEl = document.querySelector('.word')!; - const event = new MouseEvent('click', { bubbles: true }); - Object.defineProperty(event, 'target', { value: wordEl }); - - component.handleWordClick(event); - - expect(mockWordStore.selectWord).toHaveBeenCalledWith('999AAA', 1, wordEl); - }); - it('does nothing when hex cannot be determined', () => { document.body.innerHTML = `
@@ -683,7 +666,7 @@ describe('text_reader.ts', () => { it('prevents default and stops propagation', () => { document.body.innerHTML = `
- hello + hello
`; @@ -703,7 +686,7 @@ describe('text_reader.ts', () => { it('handles click on child element of word', () => { document.body.innerHTML = `
- + helloannotation
diff --git a/tests/frontend/words/word_dom_updates.test.ts b/tests/frontend/words/word_dom_updates.test.ts index 7a84404db..af6aa05df 100644 --- a/tests/frontend/words/word_dom_updates.test.ts +++ b/tests/frontend/words/word_dom_updates.test.ts @@ -157,8 +157,8 @@ describe('word_dom_updates.ts', () => { describe('updateNewWordInDOM', () => { it('updates elements with matching hex class', () => { document.body.innerHTML = ` - hello - hello + hello + hello `; const params: WordUpdateParams = { @@ -172,7 +172,7 @@ describe('word_dom_updates.ts', () => { updateNewWordInDOM(params); - const elements = document.querySelectorAll('.TERM48454c4c4f'); + const elements = document.querySelectorAll('[data_hex="48454c4c4f"]'); elements.forEach(el => { expect(el.classList.contains('status0')).toBe(false); expect(el.classList.contains('status2')).toBe(true); @@ -184,7 +184,7 @@ describe('word_dom_updates.ts', () => { it('does nothing when hex is not provided', () => { document.body.innerHTML = ` - hello + hello `; const params: WordUpdateParams = { @@ -197,12 +197,12 @@ describe('word_dom_updates.ts', () => { updateNewWordInDOM(params); - expect(document.querySelector('.TERM48454c4c4f')!.classList.contains('status0')).toBe(true); + expect(document.querySelector('[data_hex="48454c4c4f"]')!.classList.contains('status0')).toBe(true); }); it('sets title attribute with generated tooltip', () => { document.body.innerHTML = ` - hello + hello `; const params: WordUpdateParams = { @@ -217,7 +217,7 @@ describe('word_dom_updates.ts', () => { updateNewWordInDOM(params); // generateTooltip returns formatted tooltip string - expect(document.querySelector('.TERM48454c4c4f')!.getAttribute('title')).toBe('hello|bonjour|bɔ̃ʒuʁ|2'); + expect(document.querySelector('[data_hex="48454c4c4f"]')!.getAttribute('title')).toBe('hello|bonjour|bɔ̃ʒuʁ|2'); }); }); @@ -349,13 +349,13 @@ describe('word_dom_updates.ts', () => { it('marks word as well-known (status 99)', () => { document.body.innerHTML = `
- hello + hello
`; markWordWellKnownInDOM(111, '48454c4c4f', 'hello'); - const element = document.querySelector('.TERM48454c4c4f')!; + const element = document.querySelector('[data_hex="48454c4c4f"]')!; expect(element.classList.contains('status0')).toBe(false); expect(element.classList.contains('status99')).toBe(true); expect(element.classList.contains('word111')).toBe(true); @@ -365,12 +365,12 @@ describe('word_dom_updates.ts', () => { it('does nothing when frame-l does not exist', () => { document.body.innerHTML = ` - hello + hello `; markWordWellKnownInDOM(111, '48454c4c4f', 'hello'); - expect(document.querySelector('.TERM48454c4c4f')!.classList.contains('status0')).toBe(true); + expect(document.querySelector('[data_hex="48454c4c4f"]')!.classList.contains('status0')).toBe(true); }); }); @@ -382,13 +382,13 @@ describe('word_dom_updates.ts', () => { it('marks word as ignored (status 98)', () => { document.body.innerHTML = `
- hello + hello
`; markWordIgnoredInDOM(222, '48454c4c4f', 'hello'); - const element = document.querySelector('.TERM48454c4c4f')!; + const element = document.querySelector('[data_hex="48454c4c4f"]')!; expect(element.classList.contains('status0')).toBe(false); expect(element.classList.contains('status98')).toBe(true); expect(element.classList.contains('word222')).toBe(true); @@ -461,7 +461,7 @@ describe('word_dom_updates.ts', () => { describe('updateBulkWordInDOM', () => { it('updates word from bulk translate', () => { document.body.innerHTML = ` - hello + hello `; const term: BulkWordUpdateParams = { @@ -474,7 +474,7 @@ describe('word_dom_updates.ts', () => { updateBulkWordInDOM(term, true); - const element = document.querySelector('.TERM48454c4c4f')!; + const element = document.querySelector('[data_hex="48454c4c4f"]')!; expect(element.classList.contains('status0')).toBe(false); expect(element.classList.contains('status3')).toBe(true); expect(element.classList.contains('word555')).toBe(true); @@ -484,7 +484,7 @@ describe('word_dom_updates.ts', () => { it('sets empty title when useTooltip is false', () => { document.body.innerHTML = ` - hello + hello `; const term: BulkWordUpdateParams = { @@ -497,7 +497,7 @@ describe('word_dom_updates.ts', () => { updateBulkWordInDOM(term, false); - expect(document.querySelector('.TERM48454c4c4f')!.getAttribute('title')).toBe(''); + expect(document.querySelector('[data_hex="48454c4c4f"]')!.getAttribute('title')).toBe(''); }); }); @@ -508,12 +508,12 @@ describe('word_dom_updates.ts', () => { describe('updateHoverSaveInDOM', () => { it('updates word after hover save operation', () => { document.body.innerHTML = ` - hello + hello `; updateHoverSaveInDOM(666, '48454c4c4f', 1, 'quick trans', 'hello'); - const element = document.querySelector('.TERM48454c4c4f')!; + const element = document.querySelector('[data_hex="48454c4c4f"]')!; expect(element.classList.contains('status0')).toBe(false); expect(element.classList.contains('status1')).toBe(true); expect(element.classList.contains('word666')).toBe(true); @@ -523,13 +523,13 @@ describe('word_dom_updates.ts', () => { it('sets title with generated tooltip', () => { document.body.innerHTML = ` - hello + hello `; updateHoverSaveInDOM(666, '48454c4c4f', 1, 'quick trans', 'hello'); // Title is set with formatted tooltip - expect(document.querySelector('.TERM48454c4c4f')!.getAttribute('title')).toBe('hello|quick trans||1'); + expect(document.querySelector('[data_hex="48454c4c4f"]')!.getAttribute('title')).toBe('hello|quick trans||1'); }); }); diff --git a/tests/frontend/words/word_result_init.test.ts b/tests/frontend/words/word_result_init.test.ts index b4c287e8b..4a5bcdcc4 100644 --- a/tests/frontend/words/word_result_init.test.ts +++ b/tests/frontend/words/word_result_init.test.ts @@ -391,8 +391,8 @@ describe('word_result_init.ts', () => { it('initializes from all wellknown result config', () => { document.body.innerHTML = ` - word1 - word2 + word1 + word2