diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e876dde0..7bb833de0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,22 @@ ones are marked like "v1.0.0-fork". ## [Unreleased] +### Fixed in Unreleased + +* **New languages lost their settings.** The language wizard filled the form + on screen but not the form's state, so Save reported a missing name, and + retyping the name stored the language without its dictionary, language + codes, parsing rules or parser. The wizard now also fills in the translator + URL, which it never did. Languages already saved this way keep their + settings: repair them from the language's edit page. + +* **Term status changes and term edits failed on multi-user installs.** Both + returned an HTTP 500, and a failed status change showed the user nothing. + +* **Starter vocabulary stayed invisible to the reader.** Imported words were + shown as unknown in every text, and they are now linked to existing texts + and to new ones. A migration repairs earlier imports. + ## [3.8.0-fork] - 2026-11-03 ### Added in 3.8.0-fork diff --git a/cypress/e2e/04-languages.cy.ts b/cypress/e2e/04-languages.cy.ts index 705861ac0..8e0964119 100644 --- a/cypress/e2e/04-languages.cy.ts +++ b/cypress/e2e/04-languages.cy.ts @@ -187,28 +187,35 @@ describe('Languages Management', () => { }); it('should persist wizard-derived settings after saving', () => { + // The language keeps the name the wizard gave it, so clear what an + // interrupted run left behind. + const deleteDanish = () => + cy.request('/api/v1/languages').then((response) => { + const languages = response.body.languages as Array<{ id: number; name: string }>; + languages.filter((l) => l.name === 'Danish').forEach((l) => { + cy.apiRequest({ method: 'DELETE', url: `/api/v1/languages/${l.id}` }); + }); + }); + deleteDanish(); + cy.visit('/languages/new'); cy.wait(500); + cy.get('input#l1').closest('.searchable-select').within(() => { + cy.get('.searchable-select__trigger').click(); + cy.get('.searchable-select__dropdown input[type="text"]').type('English'); + cy.get('.searchable-select__options li').contains('English').click(); + }); + cy.get('input#l2').closest('.searchable-select').within(() => { cy.get('.searchable-select__trigger').click(); cy.get('.searchable-select__dropdown input[type="text"]').type('Danish'); + cy.get('.searchable-select__options li').contains('Danish').click(); }); - cy.get('.searchable-select__options li').contains('Danish').click(); cy.wait(300); - const uniqueLangName = `Danish Test ${Date.now()}`; - cy.get('input[name="LgName"]').clear().type(uniqueLangName); - - // The embedded wizard fills the parsing settings but not the dictionary - // URI, which is required — supply it the way a user would before saving. - cy.contains('Advanced Settings').click(); - cy.get('input[name="LgDict1URI"]').then(($input) => { - if (!$input.val()) { - cy.wrap($input).type('https://example.com/###'); - } - }); - + // Saved exactly as the wizard filled it in: retyping any field here + // would hide a wizard write that never reached the form's state. cy.get('form[name="lg_form"] button[type="submit"]').click(); // Creating a language now hands off to the starter-vocabulary step. @@ -218,15 +225,21 @@ describe('Languages Management', () => { // Alpine root and then for the card itself rather than a fixed delay. cy.visit('/languages'); cy.get('[x-data="languageList"]').should('exist'); - cy.contains(LANG_ROW, uniqueLangName, { timeout: 10000 }) + cy.contains(LANG_ROW, 'Danish', { timeout: 10000 }) .within(() => { cy.get('a[href*="/edit"]').click(); }); - cy.get('input[name="LgName"]').should('have.value', uniqueLangName); + cy.get('input[name="LgName"]').should('have.value', 'Danish'); cy.contains('Advanced Settings').click(); + cy.get('input[name="LgDict1URI"]').should('have.value', 'https://glosbe.com/da/en/lwt_term'); + cy.get('input[name="LgGoogleTranslateURI"]').invoke('val').should('contain', 'translate.google.com'); + cy.get('input[name="LgSourceLang"]').should('have.value', 'da'); + cy.get('input[name="LgTargetLang"]').should('have.value', 'en'); cy.get('input[name="LgRightToLeft"]').should('not.be.checked'); cy.get('input[name="LgRegexpWordCharacters"]').invoke('val').should('not.be.empty'); + + deleteDanish(); }); }); diff --git a/db/migrations/20261104_120000_link_frequency_imported_terms.sql b/db/migrations/20261104_120000_link_frequency_imported_terms.sql new file mode 100644 index 000000000..ae04cae52 --- /dev/null +++ b/db/migrations/20261104_120000_link_frequency_imported_terms.sql @@ -0,0 +1,28 @@ +-- Link the terms a starter-vocabulary import created to the occurrences they +-- match. +-- +-- FrequencyImportService inserted its words with WoWordCount at 0, "not +-- counted yet", and never touched word_occurrences. Text parsing only links +-- terms counted as 1, so the words stayed unknown in every text, those parsed +-- after the import included. This is the same failure as dictionary imports +-- (see 20260830_120000_link_dictionary_imported_terms.sql). +-- +-- The import fixes its own rows now. This repairs the installs that already +-- ran it. The counts are filled in by Migrations::computeMissingWordCounts(), +-- which runs after this file, because they come from each language's parsing +-- rules. Linking does not need them, so the order does not matter here. +-- +-- Narrowed to languages holding a term that was never counted. The join cannot +-- use an index on word_occurrences, and on an install with nothing to repair +-- this touches no occurrence at all. +-- +-- Single-word occurrences only, and no user filter, for the reasons given in +-- the dictionary migration. + +UPDATE word_occurrences o +JOIN words w + ON LOWER(o.Ti2Text) = w.WoTextLC AND o.Ti2LgID = w.WoLgID +SET o.Ti2WoID = w.WoID +WHERE o.Ti2WoID IS NULL + AND o.Ti2WordCount = 1 + AND o.Ti2LgID IN (SELECT DISTINCT WoLgID FROM words WHERE WoWordCount = 0); diff --git a/src/Modules/Dictionary/Application/Services/LocalDictionaryService.php b/src/Modules/Dictionary/Application/Services/LocalDictionaryService.php index 827df2032..6a4b44804 100644 --- a/src/Modules/Dictionary/Application/Services/LocalDictionaryService.php +++ b/src/Modules/Dictionary/Application/Services/LocalDictionaryService.php @@ -580,42 +580,11 @@ public function createVocabularyFromEntries(int $dictId, int $languageId): int // to the routine that owns them; it only visits rows still at 0. Maintenance::initWordCount(); - $this->linkOccurrencesForLanguage($languageId); + Maintenance::linkOccurrencesToTerms($languageId); return $created; } - /** - * Point this language's unlinked occurrences at the terms matching them. - * - * Single-word occurrences only: a multi-word occurrence is an expression, - * matched by its own mechanism rather than through Ti2WoID. - * - * Nothing here filters by user, and it does not need to — the join pins - * occurrence and term to the same LgID, and a `languages` row has exactly - * one owner, so the statement cannot reach across users. This is the same - * confinement {@see \Lwt\Modules\Vocabulary\Application\Services\WordLinkingService} - * relies on, and the caller has already passed assertOwnsDictionary(). - * - * @param int $languageId Language ID - * - * @return void - */ - private function linkOccurrencesForLanguage(int $languageId): void - { - $occurrencesTable = 'word_occurrences'; - $wordsTable = 'words'; - - Connection::preparedExecute( - "UPDATE {$occurrencesTable} o - JOIN {$wordsTable} w - ON LOWER(o.Ti2Text) = w.WoTextLC AND o.Ti2LgID = w.WoLgID - SET o.Ti2WoID = w.WoID - WHERE o.Ti2WoID IS NULL AND o.Ti2WordCount = 1 AND o.Ti2LgID = ?", - [$languageId] - ); - } - /** * Auto-enable local dictionary mode if currently set to online-only. * diff --git a/src/Modules/Vocabulary/Application/Services/FrequencyImportService.php b/src/Modules/Vocabulary/Application/Services/FrequencyImportService.php index 95cdc7b36..9107472d0 100644 --- a/src/Modules/Vocabulary/Application/Services/FrequencyImportService.php +++ b/src/Modules/Vocabulary/Application/Services/FrequencyImportService.php @@ -6,6 +6,7 @@ use Lwt\Shared\Infrastructure\Database\Connection; use Lwt\Shared\Infrastructure\Database\DB; +use Lwt\Shared\Infrastructure\Database\Maintenance; use Lwt\Shared\Infrastructure\Database\UserScopedQuery; /** @@ -102,11 +103,40 @@ public function importWords(int $langId, string $languageName, int $count): arra throw $e; } + if ($inserted > 0) { + $this->makeVisibleToReader($langId); + } + $skipped = $total - $inserted; return ['imported' => $inserted, 'skipped' => $skipped, 'total' => $total]; } + /** + * Make freshly imported terms visible to the reader. + * + * The rows go in with WoWordCount = 0, "not counted yet": text parsing + * only links terms counted as 1, so without this an imported word stays + * unknown in every text. The count comes from the language's own parsing + * rules, MeCab included, so it cannot be a constant in the INSERT. + * + * The words are already committed, so a failure here, such as a missing + * tokenizer, is logged rather than reported as a failed import. + * + * @param int $langId Language ID + * + * @return void + */ + private function makeVisibleToReader(int $langId): void + { + try { + Maintenance::initWordCount(); + Maintenance::linkOccurrencesToTerms($langId); + } catch (\Throwable $e) { + error_log('Could not link imported starter words: ' . $e->getMessage()); + } + } + /** * Parse the FrequencyWords text format. * diff --git a/src/Modules/Vocabulary/Http/TermCrudApiHandler.php b/src/Modules/Vocabulary/Http/TermCrudApiHandler.php index 86c3a7fb1..f5c002ea9 100644 --- a/src/Modules/Vocabulary/Http/TermCrudApiHandler.php +++ b/src/Modules/Vocabulary/Http/TermCrudApiHandler.php @@ -930,7 +930,7 @@ public function updateTermFull(int $termId, array $data): array WoStatusChanged = NOW() WHERE WoID = ?" . UserScopedQuery::forTablePrepared('words', $bindings), - [$text, $translation, $romanization, $sentence, $notes, $lemma, $lemmaLc, $status, $termId] + $bindings ); // A term whose occurrences were never linked stays invisible to the diff --git a/src/Modules/Vocabulary/Http/TermStatusApiHandler.php b/src/Modules/Vocabulary/Http/TermStatusApiHandler.php index 1dd6d2678..38e8d9f91 100644 --- a/src/Modules/Vocabulary/Http/TermStatusApiHandler.php +++ b/src/Modules/Vocabulary/Http/TermStatusApiHandler.php @@ -217,7 +217,7 @@ public function setWordStatus(int $wid, int $status): int SET WoStatus = ?, WoStatusChanged = NOW() WHERE WoID = ?" . UserScopedQuery::forTablePrepared('words', $bindings), - [$status, $wid] + $bindings ); } diff --git a/src/Shared/Infrastructure/Database/Maintenance.php b/src/Shared/Infrastructure/Database/Maintenance.php index 7e5958124..237c465ce 100644 --- a/src/Shared/Infrastructure/Database/Maintenance.php +++ b/src/Shared/Infrastructure/Database/Maintenance.php @@ -180,6 +180,33 @@ public static function updateJapaneseWordCount(int $japid): void } } + /** + * Point a language's unlinked occurrences at the terms matching them. + * + * Single-word occurrences only: a multi-word occurrence is an expression, + * matched by its own mechanism rather than through Ti2WoID. + * + * Nothing here filters by user, and it does not need to: the join pins + * occurrence and term to the same LgID, and a `languages` row has exactly + * one owner, so the statement cannot reach across users. Callers must have + * established that the language is the current user's. + * + * @param int $languageId Language ID + * + * @return void + */ + public static function linkOccurrencesToTerms(int $languageId): void + { + Connection::preparedExecute( + "UPDATE word_occurrences o + JOIN words w + ON LOWER(o.Ti2Text) = w.WoTextLC AND o.Ti2LgID = w.WoLgID + SET o.Ti2WoID = w.WoID + WHERE o.Ti2WoID IS NULL AND o.Ti2WordCount = 1 AND o.Ti2LgID = ?", + [$languageId] + ); + } + /** * Initiate the number of words in terms for all languages. * diff --git a/src/frontend/js/modules/language/pages/bound_fields.ts b/src/frontend/js/modules/language/pages/bound_fields.ts new file mode 100644 index 000000000..8ad46c256 --- /dev/null +++ b/src/frontend/js/modules/language/pages/bound_fields.ts @@ -0,0 +1,39 @@ +/** + * Write to language form fields that are bound with Alpine's `x-model`. + * + * `x-model` reads from the DOM only when an input or change event fires. + * Assigning `.value` alone updates what the user sees but leaves the form's + * state holding the old value, so the write is lost the moment the form saves. + * + * @license Unlicense + * @since 3.8.1 + */ + +/** + * Set a bound field's value and tell Alpine about it. + * + * @param input Field to write to, if it exists + * @param value Value to set + */ +export function setBoundValue( + input: HTMLInputElement | HTMLSelectElement | null, + value: string +): void { + if (!input) return; + input.value = value; + input.dispatchEvent(new Event('input', { bubbles: true })); + // Alpine binds a on change, not input - input.dispatchEvent(new Event('change', { bubbles: true })); -} - -/** - * Check or uncheck a bound checkbox and tell Alpine about it. - * - * @param input Checkbox to write to, if it exists - * @param checked Desired state - */ -function setBoundChecked(input: HTMLInputElement | null, checked: boolean): void { - if (!input) return; - input.checked = checked; - input.dispatchEvent(new Event('change', { bubbles: true })); -} - /** * Apply wizard preset data to the form. */ diff --git a/src/frontend/js/modules/language/pages/language_wizard.ts b/src/frontend/js/modules/language/pages/language_wizard.ts index f96b22f97..bdc761443 100644 --- a/src/frontend/js/modules/language/pages/language_wizard.ts +++ b/src/frontend/js/modules/language/pages/language_wizard.ts @@ -17,6 +17,7 @@ import { onDomReady } from '@shared/utils/dom_ready'; import { saveSetting } from '@shared/utils/ajax_utilities'; import { lwtFormCheck } from '@shared/forms/unloadformcheck'; import { languageForm } from './language_form'; +import { setBoundChecked, setBoundValue } from './bound_fields'; /** * Build a URL query string from an object (replaces $.param). @@ -34,31 +35,17 @@ function getInputValue(selector: string, context: Document = document): string { } /** - * Set the value of an input element and optionally trigger a change event. + * Set the value of a form field, keeping the Alpine form state in step. */ -function setInputValue( - selector: string, - value: string | number, - triggerChange = false, - context: Document = document -): void { - const el = context.querySelector(selector) as HTMLInputElement | null; - if (el) { - el.value = String(value); - if (triggerChange) { - el.dispatchEvent(new Event('change', { bubbles: true })); - } - } +function setInputValue(selector: string, value: string | number): void { + setBoundValue(document.querySelector(selector), String(value)); } /** - * Set the checked state of a checkbox. + * Set the checked state of a checkbox, keeping the Alpine form state in step. */ -function setChecked(selector: string, checked: boolean, context: Document = document): void { - const el = context.querySelector(selector) as HTMLInputElement | null; - if (el) { - el.checked = checked; - } +function setChecked(selector: string, checked: boolean): void { + setBoundChecked(document.querySelector(selector), checked); } /** @@ -71,6 +58,7 @@ function setChecked(selector: string, checked: boolean, context: Document = docu * [5] = Split each char flag * [6] = Remove spaces flag * [7] = Right-to-left flag + * [8] = Parser type (empty or absent leaves it to be inferred) */ type LanguageDefinition = [ string, // Glosbe code @@ -80,7 +68,8 @@ type LanguageDefinition = [ string, // Sentence split regexp boolean, // Split each char boolean, // Remove spaces - boolean // RTL + boolean, // RTL + string? // Parser type ]; /** @@ -126,14 +115,14 @@ export const languageWizard = { const learningLg = this.langDefs[l2]; if (!learningLg) return; - // Set language name and trigger change event - setInputValue('input[name="LgName"]', l2, true); + // Set language name + setInputValue('input[name="LgName"]', l2); // Set source language code setInputValue('input[name="LgSourceLang"]', learningLg[1]); // Set text size based on language needs - setInputValue('input[name="LgTextSize"]', learningLg[2] ? 200 : 150, true); + setInputValue('input[name="LgTextSize"]', learningLg[2] ? 200 : 150); // Set language parsing rules setInputValue('input[name="LgRegexpSplitSentences"]', learningLg[4]); @@ -141,6 +130,7 @@ export const languageWizard = { setChecked('input[name="LgSplitEachChar"]', learningLg[5]); setChecked('input[name="LgRemoveSpaces"]', learningLg[6]); setChecked('input[name="LgRightToLeft"]', learningLg[7]); + setInputValue('select[name="LgParserType"]', learningLg[8] ?? ''); // Also update dictionary URLs if L1 is already set this.updateDictionaryUrls(); @@ -180,6 +170,9 @@ export const languageWizard = { const knownLg = this.langDefs[l1]; if (!learningLg || !knownLg) return; + // L1 may be preselected from a saved setting, so onL1Change never ran + setInputValue('input[name="LgTargetLang"]', knownLg[1]); + // Reload dictionary URLs with the new language codes languageForm.reloadDictURLs(learningLg[1], knownLg[1]); @@ -203,9 +196,13 @@ export const languageWizard = { setChecked('input[name="LgDict1PopUp"]', true); // Set translator URL - if (window.GGTRANSLATE) { - setInputValue('input[name="LgGoogleTranslateURI"]', window.GGTRANSLATE); - } + window.GGTRANSLATE = 'https://translate.google.com/?' + buildQueryString({ + sl: learningLg[1], + tl: knownLg[1], + text: 'lwt_term', + op: 'translate' + }); + setInputValue('input[name="LgGoogleTranslateURI"]', window.GGTRANSLATE); }, /** diff --git a/tests/backend/Core/ScopedBindingsReachTheQueryTest.php b/tests/backend/Core/ScopedBindingsReachTheQueryTest.php new file mode 100644 index 000000000..c9940c19b --- /dev/null +++ b/tests/backend/Core/ScopedBindingsReachTheQueryTest.php @@ -0,0 +1,53 @@ +getExtension() !== 'php') { + continue; + } + $source = (string) file_get_contents($file->getPathname()); + $found = preg_match_all( + '/forTablePrepared\(\s*\'[^\']+\'\s*,\s*\$\w+\s*\)\s*,\s*\[/', + $source, + $matches, + PREG_OFFSET_CAPTURE + ); + if ($found === 0 || $found === false) { + continue; + } + foreach ($matches[0] as $match) { + $line = substr_count(substr($source, 0, $match[1]), "\n") + 1; + $offenders[] = substr($file->getPathname(), strlen($root) + 1) . ':' . $line; + } + } + + $this->assertSame( + [], + $offenders, + 'Pass the array given to forTablePrepared(), not a new literal' + ); + } +} diff --git a/tests/backend/Modules/Vocabulary/Http/MultiUserTermWritesIntegrationTest.php b/tests/backend/Modules/Vocabulary/Http/MultiUserTermWritesIntegrationTest.php new file mode 100644 index 000000000..c0f2ba5aa --- /dev/null +++ b/tests/backend/Modules/Vocabulary/Http/MultiUserTermWritesIntegrationTest.php @@ -0,0 +1,180 @@ +markTestSkipped('Database connection required'); + } + Globals::setMultiUserEnabled(true); + Globals::setCurrentUserId(self::$userId); + } + + protected function tearDown(): void + { + Globals::setCurrentUserId(null); + Globals::setMultiUserEnabled(false); + } + + private function insertTerm(string $text): int + { + return (int) Connection::preparedInsert( + "INSERT INTO words (WoText, WoTextLC, WoLgID, WoStatus, WoTranslation, WoWordCount, WoUsID) + VALUES (?, ?, ?, 1, 'old', 1, ?)", + [$text, $text, self::$languageId, self::$userId] + ); + } + + private function insertText(string $body): int + { + return (int) Connection::preparedInsert( + "INSERT INTO texts (TxLgID, TxTitle, TxText, TxUsID) VALUES (?, 't', ?, ?)", + [self::$languageId, $body, self::$userId] + ); + } + + /** + * @return array Lowercased occurrence text to its term ID + */ + private function linksIn(int $textId): array + { + $links = []; + $rows = Connection::preparedFetchAll( + 'SELECT LOWER(Ti2Text) AS word, Ti2WoID FROM word_occurrences + WHERE Ti2TxID = ? AND Ti2WordCount = 1', + [$textId] + ); + foreach ($rows as $row) { + $id = $row['Ti2WoID']; + $links[(string) $row['word']] = $id === null ? null : (int) $id; + } + return $links; + } + + /** @param list $words */ + private function importerReturning(array $words): FrequencyImportService + { + $importer = $this->getMockBuilder(FrequencyImportService::class) + ->onlyMethods(['fetchFrequencyList']) + ->getMock(); + $importer->method('fetchFrequencyList')->willReturn($words); + return $importer; + } + + public function testSetWordStatusChangesTheStatus(): void + { + $id = $this->insertTerm('statusword'); + + (new TermStatusApiHandler())->setWordStatus($id, 3); + + $this->assertSame( + 3, + (int) Connection::preparedFetchValue('SELECT WoStatus FROM words WHERE WoID = ?', [$id], 'WoStatus') + ); + } + + public function testUpdateTermFullSavesTheTranslation(): void + { + $id = $this->insertTerm('editword'); + + $result = (new TermCrudApiHandler())->updateTermFull($id, ['translation' => 'new', 'status' => 2]); + + $this->assertTrue($result['success'] ?? false); + $this->assertSame( + 'new', + Connection::preparedFetchValue('SELECT WoTranslation FROM words WHERE WoID = ?', [$id], 'WoTranslation') + ); + } + + public function testStarterWordsReachTextsParsedBeforeAndAfterTheImport(): void + { + $before = $this->insertText('Alpha gamma.'); + TextParsing::parseAndSave('Alpha gamma.', self::$languageId, $before); + + $result = $this->importerReturning(['alpha', 'beta']) + ->importWords(self::$languageId, 'MultiUserWrites', 2); + + $after = $this->insertText('Beta gamma.'); + TextParsing::parseAndSave('Beta gamma.', self::$languageId, $after); + + $this->assertSame(2, $result['imported']); + $this->assertNotNull($this->linksIn($before)['alpha']); + $this->assertNull($this->linksIn($before)['gamma']); + $this->assertNotNull($this->linksIn($after)['beta']); + } +} diff --git a/tests/backend/Security/OwnerlessTableWriteTest.php b/tests/backend/Security/OwnerlessTableWriteTest.php index 5e5ca83d1..8860830a0 100644 --- a/tests/backend/Security/OwnerlessTableWriteTest.php +++ b/tests/backend/Security/OwnerlessTableWriteTest.php @@ -70,8 +70,11 @@ class OwnerlessTableWriteTest extends TestCase 'src/Modules/Vocabulary/Application/Services/WordBulkService.php' => 'deleteMultiple() narrows through filterOwnedWordIds()', 'src/Modules/Dictionary/Application/Services/LocalDictionaryService.php' => - 'entry writes call assertOwnsDictionary(); linkOccurrencesForLanguage() ' - . 'joins on Ti2LgID = WoLgID and a language row has one owner', + 'entry writes call assertOwnsDictionary()', + 'src/Shared/Infrastructure/Database/Maintenance.php' => + 'linkOccurrencesToTerms() joins on Ti2LgID = WoLgID, a language row has one ' + . 'owner, and both callers resolve the language first: assertOwnsDictionary(), ' + . 'or the user-scoped LanguageFacade::getById() in StarterVocabController', 'src/Modules/Text/Application/UseCases/DeleteText.php' => 'cascades from a text resolved through the user-scoped texts table', 'src/Modules/Text/Application/UseCases/ArchiveText.php' => diff --git a/tests/frontend/languages/language_wizard.test.ts b/tests/frontend/languages/language_wizard.test.ts index 8c9c41f14..e7cc99c86 100644 --- a/tests/frontend/languages/language_wizard.test.ts +++ b/tests/frontend/languages/language_wizard.test.ts @@ -218,6 +218,7 @@ describe('language_wizard.ts', () => { + @@ -285,6 +286,7 @@ describe('language_wizard.ts', () => { + @@ -358,9 +360,7 @@ describe('language_wizard.ts', () => { expect(popupInput.checked).toBe(true); }); - it('sets Google Translate URL when available', () => { - (window as any).GGTRANSLATE = 'https://translate.google.com/?source=fr&target=en'; - + it('sets the Google Translate URL and target language', () => { const l1Select = document.getElementById('l1') as HTMLSelectElement; const l2Select = document.getElementById('l2') as HTMLSelectElement; l1Select.value = 'English'; @@ -368,8 +368,97 @@ describe('language_wizard.ts', () => { languageWizard.updateDictionaryUrls(); - const input = document.querySelector('input[name="LgGoogleTranslateURI"]') as HTMLInputElement; - expect(input.value).toBe('https://translate.google.com/?source=fr&target=en'); + const url = new URL((document.querySelector('input[name="LgGoogleTranslateURI"]') as HTMLInputElement).value); + expect(url.hostname).toBe('translate.google.com'); + expect(url.searchParams.get('sl')).toBe('fr'); + expect(url.searchParams.get('tl')).toBe('en'); + expect((document.querySelector('input[name="LgTargetLang"]') as HTMLInputElement).value).toBe('en'); + }); + }); + + // =========================================================================== + // Alpine form state + // =========================================================================== + + describe('keeping the x-model state in step', () => { + /** What Alpine would hold after reading the events x-model listens to. */ + function trackModel(): Record { + const model: Record = {}; + document.querySelectorAll('[name^="Lg"]').forEach((el) => { + const onChange = el.type === 'checkbox' || el.tagName === 'SELECT'; + el.addEventListener(onChange ? 'change' : 'input', () => { + model[el.name] = el.type === 'checkbox' ? el.checked : el.value; + }); + }); + return model; + } + + beforeEach(() => { + languageWizard.langDefs = { + 'English': ['en', 'en', false, 'a-z', '.!?', false, false, false], + 'French': ['fr', 'fr', false, 'a-z', '.!?', false, false, false], + 'Japanese': ['ja', 'ja', true, 'kana', '。', true, true, false, 'mecab'] + }; + document.body.innerHTML = ` + + + + + + + + + + + + + + + + `; + }); + + it('reports every preset field to the form state', () => { + const model = trackModel(); + (document.getElementById('l1') as HTMLSelectElement).value = 'English'; + (document.getElementById('l2') as HTMLSelectElement).value = 'Japanese'; + + languageWizard.onL2Change(); + + expect(model).toMatchObject({ + LgName: 'Japanese', + LgSourceLang: 'ja', + LgTargetLang: 'en', + LgDict1URI: 'https://glosbe.com/ja/en/lwt_term', + LgDict1PopUp: true, + LgRegexpWordCharacters: 'kana', + LgRegexpSplitSentences: '。', + LgSplitEachChar: true, + LgRemoveSpaces: true, + LgRightToLeft: false, + LgParserType: 'mecab' + }); + expect(model.LgGoogleTranslateURI).toContain('translate.google.com'); + }); + + it('clears the parser when the next preset does not name one', () => { + const model = trackModel(); + const l2 = document.getElementById('l2') as HTMLSelectElement; + (document.getElementById('l1') as HTMLSelectElement).value = 'English'; + l2.value = 'Japanese'; + languageWizard.onL2Change(); + l2.value = 'French'; + + languageWizard.onL2Change(); + + expect(model.LgParserType).toBe(''); }); });