diff --git a/src/Modules/Text/Application/Services/TextPrintService.php b/src/Modules/Text/Application/Services/TextPrintService.php index dfc8a6bcf..a53913f83 100644 --- a/src/Modules/Text/Application/Services/TextPrintService.php +++ b/src/Modules/Text/Application/Services/TextPrintService.php @@ -462,7 +462,10 @@ public function getTextItemsForApi(int $textId): array /** * Get annotated text items formatted for API response. * - * Parses the stored annotation string into structured data. + * Parses the stored annotation string into structured data. Romanizations + * are resolved for the whole text in one query rather than per term — the + * annotated view renders every word at once, so a lookup per item would be + * hundreds of round trips on a normal text. * * @param int $textId Text ID * @@ -486,13 +489,24 @@ public function getAnnotationForApi(int $textId): ?array if ($items === false) { return null; } - $parsed = []; + $rows = []; foreach ($items as $item) { $vals = preg_split('/[\t]/u', $item); - if ($vals === false) { + // A row needs at least an order and a term. Splitting the stored + // annotation on newlines yields a blank entry for any trailing or + // repeated newline, and those would otherwise come back as words + // with empty text and render as empty elements. + if ($vals === false || count($vals) < 2) { continue; } + $rows[] = $vals; + } + + $romanizations = $this->romanizationsForRows($rows); + $parsed = []; + + foreach ($rows as $vals) { $order = isset($vals[0]) ? (int) $vals[0] : -1; $text = $vals[1] ?? ''; $wordId = (isset($vals[2]) && ctype_digit($vals[2])) ? (int) $vals[2] : null; @@ -509,6 +523,7 @@ public function getAnnotationForApi(int $textId): ?array 'text' => $text, 'wordId' => $wordId, 'translation' => $translation, + 'romanization' => $wordId === null ? '' : ($romanizations[$wordId] ?? ''), 'isWord' => $order > -1 ]; } @@ -516,6 +531,38 @@ public function getAnnotationForApi(int $textId): ?array return $parsed; } + /** + * Look up the romanization of every word referenced by the parsed rows. + * + * @param array> $rows Tab-split annotation rows + * + * @return array Romanization keyed by word ID + */ + private function romanizationsForRows(array $rows): array + { + $wordIds = []; + foreach ($rows as $vals) { + if (isset($vals[2]) && ctype_digit($vals[2])) { + $wordIds[(int) $vals[2]] = true; + } + } + if ($wordIds === []) { + return []; + } + + $records = QueryBuilder::table('words') + ->select(['WoID', 'WoRomanization']) + ->whereIn('WoID', array_keys($wordIds)) + ->getPrepared(); + + $byId = []; + foreach ($records as $record) { + $byId[(int) $record['WoID']] = (string) ($record['WoRomanization'] ?? ''); + } + + return $byId; + } + // =========================== // VIEW DATA PREPARATION // =========================== diff --git a/src/Modules/Text/Http/TextReadController.php b/src/Modules/Text/Http/TextReadController.php index d700643a4..35d13060e 100644 --- a/src/Modules/Text/Http/TextReadController.php +++ b/src/Modules/Text/Http/TextReadController.php @@ -147,13 +147,10 @@ public function display(?int $text = null): ?RedirectResponse return $this->redirect('/text/edit'); } - $annotatedText = $this->displayService->getAnnotatedText($textId); - if (strlen($annotatedText) <= 0) { - return $this->redirect('/text/edit'); - } - - $settings = $this->displayService->getTextDisplaySettings($textId); - if ($settings === null) { + // Still checked here: with no annotation there is nothing for the + // client to render, and sending the user to an empty shell is worse + // than putting them back on the text list. + if (strlen($this->displayService->getAnnotatedText($textId)) <= 0) { return $this->redirect('/text/edit'); } @@ -165,9 +162,10 @@ public function display(?int $text = null): ?RedirectResponse $title = $headerData['title']; $audio = $headerData['audio']; $sourceUri = $headerData['sourceUri']; - $textSize = $settings['textSize']; - $rtlScript = $settings['rtlScript']; + // Prev/next depends on the request's language filter, query and tag + // selection, which live in the session rather than behind an endpoint, + // so this chrome stays server-rendered. $textLinks = (new TextNavigationService())->getPreviousAndNextTextLinks( $textId, 'display_impr_text.php?text=', @@ -178,12 +176,10 @@ public function display(?int $text = null): ?RedirectResponse $mediaPlayerHtml = (new \Lwt\Modules\Admin\Application\Services\MediaService()) ->getMediaPlayerHtml($audio); - $annotations = $this->displayService->parseAnnotations($annotatedText); - $this->displayService->saveCurrentText($textId); PageLayoutHelper::renderPageStartNobody('Display'); - include self::MODULE_VIEWS . '/display_main.php'; + include self::MODULE_VIEWS . '/display_alpine.php'; PageLayoutHelper::renderPageEnd(); return null; diff --git a/src/Modules/Text/Views/display_alpine.php b/src/Modules/Text/Views/display_alpine.php new file mode 100644 index 000000000..3d5526629 --- /dev/null +++ b/src/Modules/Text/Views/display_alpine.php @@ -0,0 +1,140 @@ + + * @license Unlicense + * @link https://hugofara.github.io/lwt/developer/api + * @since 3.6.1 + * + * @psalm-suppress UndefinedGlobalVariable Variables are injected by including file + * + * @var int $textId + * @var string $title + * @var string|null $sourceUri + * @var string $textLinks + * @var string $mediaPlayerHtml + */ + +namespace Lwt\Views\Text; + +use Lwt\Shared\UI\Helpers\ConfigIsland; +use Lwt\Shared\UI\Helpers\IconHelper; + +// Type-safe variable extraction from controller context +assert(is_int($textId)); +/** + * @var string $titleTyped +*/ +$titleTyped = $title; +/** + * @var string|null $sourceUriTyped +*/ +$sourceUriTyped = $sourceUri; +/** + * @var string $textLinksTyped +*/ +$textLinksTyped = $textLinks; +assert(is_string($mediaPlayerHtml)); + +?> +
+
+

+
+
+ + __('text.display.toggle_text_on'), + 'alt' => __('text.display.toggle_text_on'), + 'class' => 'click' + ]); + ?> + + + + __('text.display.toggle_annotation_on'), + 'alt' => __('text.display.toggle_annotation_on'), + 'class' => 'click' + ]); + ?> + + +
+
+ '; + $textSourceLabel = __('text.display.text_source'); + echo IconHelper::render('link', ['title' => $textSourceLabel, 'alt' => $textSourceLabel]); + echo ''; + } + echo $textLinksTyped; + ?> +
+
+ + $closeLabel, 'alt' => $closeLabel, 'class' => 'click'] + ); + ?> + +
+
+ +
+
+
+

+

+ +
+
+
+ + $textId]); ?> diff --git a/src/Modules/Text/Views/display_header.php b/src/Modules/Text/Views/display_header.php deleted file mode 100644 index 4ae860550..000000000 --- a/src/Modules/Text/Views/display_header.php +++ /dev/null @@ -1,121 +0,0 @@ - - * @license Unlicense - * @link https://hugofara.github.io/lwt/developer/api - * @since 3.0.0 - * - * @psalm-suppress UndefinedGlobalVariable Variables are injected by including file - * - * @var string $title - * @var int $textId - * @var string|null $sourceUri - * @var string $textLinks - * @var string $mediaPlayerHtml - */ - -namespace Lwt\Views\Text; - -// Variables injected from text_display_header.php: -// $title, $audio, $sourceUri, $textLinks - -use Lwt\Shared\UI\Helpers\IconHelper; - -// Type-safe variable extraction from controller context -/** - * @var string $titleTyped -*/ -$titleTyped = $title; -/** - * @var string|null $sourceUriTyped -*/ -$sourceUriTyped = $sourceUri; -/** - * @var string $textLinksTyped -*/ -$textLinksTyped = $textLinks; -/** - * @var string $mediaPlayerHtml -*/ -assert(is_string($mediaPlayerHtml)); -?> -

-
-
- - __('text.display.toggle_text_on'), - 'alt' => __('text.display.toggle_text_on'), - 'class' => 'click' - ]); - ?> - - - - __('text.display.toggle_annotation_on'), - 'alt' => __('text.display.toggle_annotation_on'), - 'class' => 'click' - ]); - ?> - - -
-
- '; - $textSourceLabel = __('text.display.text_source'); - echo IconHelper::render('link', ['title' => $textSourceLabel, 'alt' => $textSourceLabel]); - echo ''; - } - echo $textLinksTyped; - ?> -
-
- - $closeLabel, 'alt' => $closeLabel, 'class' => 'click'] - ); - ?> - -
-
- diff --git a/src/Modules/Text/Views/display_main.php b/src/Modules/Text/Views/display_main.php deleted file mode 100644 index 596a4d938..000000000 --- a/src/Modules/Text/Views/display_main.php +++ /dev/null @@ -1,41 +0,0 @@ - - * @license Unlicense - * @link https://hugofara.github.io/lwt/developer/api - * @since 3.0.0 - * - * @psalm-suppress UndefinedGlobalVariable Variables are injected by including file - */ - -declare(strict_types=1); - -namespace Lwt\Views\Text; - -?> -
-
- -
-
-
- -
-
diff --git a/src/Modules/Text/Views/display_text.php b/src/Modules/Text/Views/display_text.php deleted file mode 100644 index 93834494e..000000000 --- a/src/Modules/Text/Views/display_text.php +++ /dev/null @@ -1,80 +0,0 @@ - - * @license Unlicense - * @link https://hugofara.github.io/lwt/developer/api - * @since 3.0.0 - * - * @psalm-suppress UndefinedGlobalVariable Variables are injected by including file - * - * @var array $annotations - * @var int $textSize - * @var bool $rtlScript - */ - -namespace Lwt\Views\Text; - -// Type-safe variable extraction from controller context -assert(is_array($annotations)); -/** - * @var array -*/ -$annotationsTyped = $annotations; -/** - * @var int -*/ -$textSizeTyped = $textSize; -/** - * @var bool -*/ -$rtlScriptTyped = $rtlScript; -?> -
> -

- -1) { - // Regular word with annotation - $romTitle = ($item['rom'] ?? '') === '' - ? '' - : ' title="' . \htmlspecialchars($item['rom'] ?? '', ENT_QUOTES, 'UTF-8') . '"'; - $textEscaped = \htmlspecialchars($item['text'] ?? '', ENT_QUOTES, 'UTF-8'); - $transEscaped = \htmlspecialchars($item['trans'] ?? '', ENT_QUOTES, 'UTF-8'); - echo ' - - ' . - $textEscaped . - ' - - - ' . $transEscaped . ' - - '; - } else { - // Punctuation or paragraph marker - echo str_replace( - "¶", - '

-

', - " " . \htmlspecialchars($item['text'] ?? '', ENT_QUOTES, 'UTF-8') - ); - } -} -?> -

-
diff --git a/src/frontend/js/modules/text/api/texts_api.ts b/src/frontend/js/modules/text/api/texts_api.ts index 2b27cf6d5..1953270e7 100644 --- a/src/frontend/js/modules/text/api/texts_api.ts +++ b/src/frontend/js/modules/text/api/texts_api.ts @@ -292,6 +292,8 @@ export interface AnnotationItem { text: string; wordId: number | null; translation: string; + /** Reading of the term, shown as a tooltip; empty when the word has none. */ + romanization: string; isWord: boolean; } diff --git a/src/frontend/js/modules/text/index.ts b/src/frontend/js/modules/text/index.ts index cfbb0a746..1921585c0 100644 --- a/src/frontend/js/modules/text/index.ts +++ b/src/frontend/js/modules/text/index.ts @@ -43,4 +43,5 @@ import './pages/file_import'; import './pages/text_check_display'; import './pages/text_check_form'; import './pages/text_print_app'; +import './pages/text_display_app'; import './pages/text_suggestions'; diff --git a/src/frontend/js/modules/text/pages/reading/annotation_toggle.ts b/src/frontend/js/modules/text/pages/reading/annotation_toggle.ts index c01d24535..0fa5aeae6 100644 --- a/src/frontend/js/modules/text/pages/reading/annotation_toggle.ts +++ b/src/frontend/js/modules/text/pages/reading/annotation_toggle.ts @@ -1,7 +1,7 @@ /** * Annotation Toggle - Show/hide translations and annotations in text display. * - * Extracted from Views/Text/display_header.php + * Drives the toggle buttons in the annotated display and print views. * * @license unlicense * @since 3.0.0 diff --git a/src/frontend/js/modules/text/pages/text_display_app.ts b/src/frontend/js/modules/text/pages/text_display_app.ts new file mode 100644 index 000000000..13778e1b1 --- /dev/null +++ b/src/frontend/js/modules/text/pages/text_display_app.ts @@ -0,0 +1,174 @@ +/** + * Text Display App - renders the annotated ("improved") text view. + * + * Replaces the server-rendered display_text.php: the page now ships a shell + * and fetches the annotation from /api/v1/texts/{id}/annotation. + * + * The markup produced here is deliberately identical to what PHP emitted — + * `.anntermruby` for the term and `.anntransruby2` for its translation, inside + * a — because annotation_toggle.ts drives the show/hide buttons by + * querying those classes at click time. + * + * @license Unlicense + * @since 3.6.1 + */ + +import Alpine from 'alpinejs'; +import { readPageConfig } from '@shared/utils/page_config'; +import { escapeHtml } from '@shared/utils/html_utils'; +import { TextsApi, type AnnotationItem } from '@modules/text/api/texts_api'; + +/** + * Paragraph marker in the stored annotation. + */ +const PARAGRAPH_MARKER = '¶'; + +/** + * Page configuration emitted by display_alpine.php. + */ +interface TextDisplayConfig { + textId: number; +} + +/** + * Alpine component state for the annotated display page. + */ +export interface TextDisplayData { + loading: boolean; + error: string; + textId: number; + init(): Promise; + isReady(): boolean; + render(items: AnnotationItem[], textSize: number, rtlScript: boolean): void; +} + +/** + * Build the opening tag of a paragraph at the configured text size. + * + * @param textSize Percentage font size from the language settings + * @param leading line-height for this paragraph + * + * @returns The opening

tag + */ +function paragraphTag(textSize: number, leading: string): string { + return `

`; +} + +/** + * Render one annotated term as ruby markup. + * + * @param item Annotation item for a word + * + * @returns HTML for the term and its translation + */ +function renderTerm(item: AnnotationItem): string { + const rom = item.romanization ?? ''; + const romTitle = rom === '' ? '' : ` title="${escapeHtml(rom)}"`; + return ( + ' ' + + '' + + `` + + escapeHtml(item.text) + + '' + + '' + + '' + + `${escapeHtml(item.translation)}` + + '' + + ' ' + ); +} + +/** + * Render a non-word item: punctuation, whitespace, or a paragraph break. + * + * A paragraph marker closes the current

and opens the next one, which is + * how the stored annotation encodes line structure. + * + * @param item Annotation item that is not a word + * @param textSize Percentage font size from the language settings + * + * @returns HTML for the item + */ +function renderNonWord(item: AnnotationItem, textSize: number): string { + const escaped = ' ' + escapeHtml(item.text); + return escaped.split(PARAGRAPH_MARKER).join( + '

' + paragraphTag(textSize, '1.3') + ); +} + +/** + * Create the annotated display component. + * + * @returns Alpine component data + */ +export function textDisplayData(): TextDisplayData { + const config = readPageConfig('text-display-config', { + textId: 0 + }); + + return { + loading: true, + error: '', + textId: config.textId, + + async init(): Promise { + if (this.textId === 0) { + this.loading = false; + this.error = 'No text selected.'; + return; + } + + const response = await TextsApi.getAnnotation(this.textId); + const items = response.data?.items; + + if (!response.data || !items) { + this.loading = false; + this.error = response.error || 'Could not load the annotated text.'; + return; + } + + this.render( + items, + response.data.config.textSize, + response.data.config.rtlScript + ); + this.loading = false; + }, + + isReady(): boolean { + return !this.loading && this.error === ''; + }, + + render(items: AnnotationItem[], textSize: number, rtlScript: boolean): void { + const container = document.getElementById('print'); + if (!container) { + return; + } + + if (rtlScript) { + container.setAttribute('dir', 'rtl'); + } else { + container.removeAttribute('dir'); + } + + const parts = [paragraphTag(textSize, '1.35')]; + for (const item of items) { + parts.push( + item.isWord ? renderTerm(item) : renderNonWord(item, textSize) + ); + } + parts.push('

'); + + container.innerHTML = parts.join(''); + } + }; +} + +/** + * Register the component with Alpine. + */ +export function initTextDisplayAlpine(): void { + Alpine.data('textDisplay', textDisplayData); +} + +initTextDisplayAlpine(); diff --git a/tests/backend/Services/TextPrintServiceTest.php b/tests/backend/Services/TextPrintServiceTest.php index 397f5fcba..2dfccfb9b 100644 --- a/tests/backend/Services/TextPrintServiceTest.php +++ b/tests/backend/Services/TextPrintServiceTest.php @@ -485,6 +485,150 @@ public function testPrepareAnnotatedPrintDataReturnsNullForNonExistentText(): vo $this->assertNull($data); } + // ===== getAnnotationForApi() tests ===== + + /** + * Link a word to the test text so the annotation rebuild picks it up. + * + * getAnnotationForApi() regenerates the annotation from word_occurrences + * rather than trusting the stored string, so a fixture that only writes + * TxAnnotatedText produces items with no word behind them. + * + * @param string $term Surface form + * @param string $romanization Reading to store on the word + * + * @return array{0: int, 1: int} Word ID and sentence ID, for cleanup + */ + private function linkWordToTestText(string $term, string $romanization): array + { + Connection::query( + "INSERT INTO sentences (SeLgID, SeTxID, SeOrder, SeFirstPos, SeText) VALUES (" + . self::$testLangId . ", " . self::$testTextId . ", 1, 1, '" . $term . ".')" + ); + $sentenceId = (int) Connection::fetchValue("SELECT LAST_INSERT_ID() AS value"); + + Connection::query( + "INSERT INTO words (WoLgID, WoText, WoTextLC, WoStatus, WoTranslation, " + . "WoRomanization, WoSentence, WoStatusChanged) VALUES (" + . self::$testLangId . ", '" . $term . "', '" . $term . "', 1, 'meaning', '" + . $romanization . "', '', NOW())" + ); + $wordId = (int) Connection::fetchValue("SELECT LAST_INSERT_ID() AS value"); + + Connection::query( + "INSERT INTO word_occurrences " + . "(Ti2WoID, Ti2LgID, Ti2TxID, Ti2SeID, Ti2Order, Ti2WordCount, Ti2Text) VALUES (" + . $wordId . ", " . self::$testLangId . ", " . self::$testTextId . ", " + . $sentenceId . ", 1, 1, '" . $term . "')" + ); + Connection::query( + "INSERT INTO word_occurrences " + . "(Ti2WoID, Ti2LgID, Ti2TxID, Ti2SeID, Ti2Order, Ti2WordCount, Ti2Text) VALUES (" + . "NULL, " . self::$testLangId . ", " . self::$testTextId . ", " + . $sentenceId . ", 2, 0, '.')" + ); + + return [$wordId, $sentenceId]; + } + + /** + * Remove the fixture rows created by linkWordToTestText(). + * + * @param int $wordId Word to drop + * @param int $sentenceId Sentence to drop + * + * @return void + */ + private function unlinkWordFromTestText(int $wordId, int $sentenceId): void + { + Connection::query("DELETE FROM word_occurrences WHERE Ti2SeID = " . $sentenceId); + Connection::query("DELETE FROM words WHERE WoID = " . $wordId); + Connection::query("DELETE FROM sentences WHERE SeID = " . $sentenceId); + } + + public function testGetAnnotationForApiCarriesRomanization(): void + { + if (!self::$dbConnected) { + $this->markTestSkipped('Database connection required'); + } + + [$wordId, $sentenceId] = $this->linkWordToTestText('annrom', 'ro-man'); + + try { + $items = (new TextPrintService())->getAnnotationForApi(self::$testTextId); + + $this->assertIsArray($items); + $this->assertNotEmpty($items); + + $words = array_values(array_filter($items, fn(array $i): bool => $i['isWord'])); + $this->assertNotEmpty($words, 'Expected the linked word in the annotation'); + $this->assertSame($wordId, $words[0]['wordId']); + $this->assertSame('ro-man', $words[0]['romanization']); + + // Items with no word behind them carry an empty reading, never null, + // so the client can render them without a presence check. + $nonWords = array_values(array_filter($items, fn(array $i): bool => !$i['isWord'])); + $this->assertNotEmpty($nonWords); + $this->assertSame('', $nonWords[0]['romanization']); + } finally { + $this->unlinkWordFromTestText($wordId, $sentenceId); + } + } + + public function testGetAnnotationForApiReturnsEmptyRomanizationWhenWordHasNone(): void + { + if (!self::$dbConnected) { + $this->markTestSkipped('Database connection required'); + } + + [$wordId, $sentenceId] = $this->linkWordToTestText('annnorom', ''); + + try { + $items = (new TextPrintService())->getAnnotationForApi(self::$testTextId); + + $this->assertIsArray($items); + $words = array_values(array_filter($items, fn(array $i): bool => $i['isWord'])); + $this->assertNotEmpty($words); + $this->assertSame('', $words[0]['romanization']); + } finally { + $this->unlinkWordFromTestText($wordId, $sentenceId); + } + } + + public function testGetAnnotationForApiSkipsBlankAnnotationRows(): void + { + if (!self::$dbConnected) { + $this->markTestSkipped('Database connection required'); + } + + [$wordId, $sentenceId] = $this->linkWordToTestText('annblank', ''); + + try { + $items = (new TextPrintService())->getAnnotationForApi(self::$testTextId); + + $this->assertIsArray($items); + // A trailing or repeated newline in the stored annotation splits to a + // blank row. Reported as a word it would render an empty , which + // is what the server-rendered display view used to filter out. + foreach ($items as $item) { + if ($item['isWord']) { + $this->assertNotSame('', $item['text'], 'Blank row reported as a word'); + } + } + } finally { + $this->unlinkWordFromTestText($wordId, $sentenceId); + } + } + + public function testGetAnnotationForApiReturnsNullWithoutAnnotation(): void + { + if (!self::$dbConnected) { + $this->markTestSkipped('Database connection required'); + } + + $this->assertNull((new TextPrintService())->getAnnotationForApi(999999)); + } + // ===== Constants tests ===== public function testAnnotationConstants(): void diff --git a/tests/frontend/texts/text_display.test.ts b/tests/frontend/texts/text_display.test.ts new file mode 100644 index 000000000..34b67f6f8 --- /dev/null +++ b/tests/frontend/texts/text_display.test.ts @@ -0,0 +1,191 @@ +/** + * Tests for text_display_app.ts — the annotated ("improved") text view. + * + * The markup asserted here is the contract the server-rendered + * display_text.php used to produce: annotation_toggle.ts finds terms and + * translations by the .anntermruby / .anntransruby2 classes at click time, so + * a rename here silently breaks the show/hide buttons. + */ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +const getAnnotation = vi.fn(); + +vi.mock('alpinejs', () => ({ + default: { data: vi.fn() } +})); + +vi.mock('../../../src/frontend/js/modules/text/api/texts_api', () => ({ + TextsApi: { + get getAnnotation() { + return getAnnotation; + } + } +})); + +import { textDisplayData } from '../../../src/frontend/js/modules/text/pages/text_display_app'; + +/** Build an annotation item with sane defaults. */ +function item(overrides: Record = {}) { + return { + order: 0, + text: 'Hallo', + wordId: 1, + translation: 'hello', + romanization: '', + isWord: true, + ...overrides + }; +} + +/** Respond to getAnnotation with these items and config. */ +function respond(items: unknown[], config: Record = {}) { + getAnnotation.mockResolvedValue({ + data: { + items, + config: { + textId: 7, + title: 'T', + sourceUri: '', + audioUri: '', + langId: 1, + textSize: 150, + rtlScript: false, + hasAnnotation: true, + ttsClass: null, + ...config + } + } + }); +} + +/** Install the page shell the view renders, with the config island. */ +function setupDom(textId: number = 7): void { + document.body.innerHTML = + '
' + + ``; +} + +describe('textDisplayData', () => { + beforeEach(() => { + getAnnotation.mockReset(); + setupDom(); + }); + + it('reads the text id from the config island', () => { + setupDom(42); + expect(textDisplayData().textId).toBe(42); + }); + + it('renders a word as ruby with the toggle classes intact', async () => { + respond([item({ text: 'Hallo', translation: 'hello' })]); + const app = textDisplayData(); + + await app.init(); + + const html = document.getElementById('print')!.innerHTML; + expect(html).toContain('anntermruby'); + expect(html).toContain('anntransruby2'); + expect(html).toContain('Hallo'); + expect(html).toContain('hello'); + expect(app.loading).toBe(false); + expect(app.error).toBe(''); + }); + + it('puts the romanization in a title attribute, and omits it when empty', async () => { + respond([item({ romanization: 'ha-lo' })]); + await textDisplayData().init(); + expect(document.getElementById('print')!.innerHTML).toContain('title="ha-lo"'); + + setupDom(); + respond([item({ romanization: '' })]); + await textDisplayData().init(); + expect(document.getElementById('print')!.innerHTML).not.toContain('title='); + }); + + it('escapes markup in terms, translations and romanizations', async () => { + respond([ + item({ + text: 'x', + translation: '"quoted" & y', + romanization: '