From b1d33bd6f55352c38bf036096f3744104cb0d098 Mon Sep 17 00:00:00 2001 From: HugoFara Date: Wed, 4 Nov 2026 13:25:30 +0100 Subject: [PATCH] fix: restore language setup, term writes and starter words New users could not set up a language, and on multi-user installs nobody could change a term's status or save a term edit. - The language wizard wrote to the DOM without telling Alpine, so Save saw an empty form. Retyping the name stored the language with no dictionary, language codes, parsing rules or parser. The wizard now reports every field, applies the preset's parser, and fills in the translator URL and target language, which it never did. - setWordStatus() and updateTermFull() ran a user-scoped query with a new literal list, dropping the user ID that scoping appends: HTTP 500 in multi-user mode only. A source test now rejects that shape. - Starter words were inserted with WoWordCount 0 and never linked, so the reader treated them as unknown. The import counts and links them, and a migration repairs earlier imports. - The e2e test retyped the wizard's name before saving, which hid the first bug. It now saves what the wizard filled in. --- CHANGELOG.md | 16 ++ cypress/e2e/04-languages.cy.ts | 43 +++-- ...4_120000_link_frequency_imported_terms.sql | 28 +++ .../Services/LocalDictionaryService.php | 33 +--- .../Services/FrequencyImportService.php | 30 +++ .../Vocabulary/Http/TermCrudApiHandler.php | 2 +- .../Vocabulary/Http/TermStatusApiHandler.php | 2 +- .../Infrastructure/Database/Maintenance.php | 27 +++ .../js/modules/language/pages/bound_fields.ts | 39 ++++ .../modules/language/pages/language_editor.ts | 2 +- .../modules/language/pages/language_form.ts | 39 +--- .../modules/language/pages/language_wizard.ts | 51 +++-- .../Core/ScopedBindingsReachTheQueryTest.php | 53 ++++++ .../MultiUserTermWritesIntegrationTest.php | 180 ++++++++++++++++++ .../Security/OwnerlessTableWriteTest.php | 7 +- .../languages/language_wizard.test.ts | 99 +++++++++- 16 files changed, 530 insertions(+), 121 deletions(-) create mode 100644 db/migrations/20261104_120000_link_frequency_imported_terms.sql create mode 100644 src/frontend/js/modules/language/pages/bound_fields.ts create mode 100644 tests/backend/Core/ScopedBindingsReachTheQueryTest.php create mode 100644 tests/backend/Modules/Vocabulary/Http/MultiUserTermWritesIntegrationTest.php 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(''); }); });