diff --git a/learner/playwright.config.ts b/learner/playwright.config.ts index c83524b..9c0468d 100644 --- a/learner/playwright.config.ts +++ b/learner/playwright.config.ts @@ -8,6 +8,9 @@ export default defineConfig({ forbidOnly: !!process.env.CI, retries: 0, reporter: 'list', + // Every spec loads the whole SPA from the shared dev server, so a slow first module graph + // under parallel workers must not fail an assertion that the app itself would pass. + expect: { timeout: 15000 }, use: { baseURL: 'http://127.0.0.1:5173', headless: true, trace: 'off' }, projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'], channel: 'chrome' } }], webServer: { command: 'npm run dev -- --host 127.0.0.1', url: 'http://127.0.0.1:5173', reuseExistingServer: !process.env.CI }, diff --git a/learner/src/__tests__/phrase.spec.ts b/learner/src/__tests__/phrase.spec.ts index 7849a9b..c063922 100644 --- a/learner/src/__tests__/phrase.spec.ts +++ b/learner/src/__tests__/phrase.spec.ts @@ -131,6 +131,21 @@ describe('phrase selection and panel', () => { expect(view.get('[data-testid="phrase-range"]').text()).toContain('已保存') }) + it('stops claiming a stored phrase once its range is adjusted', async () => { + const span: PhraseSpan = { id: 7, status: 'new', wordCount: 3, startToken: 2, endToken: 7 } + const { view } = await open({ phrases: [span] }) + await view.findAll('.reader-word').find(item => item.text() === 'small')!.trigger('click') + await flushPromises() + expect(view.get('[data-testid="phrase-range"]').text()).toContain('已保存') + await view.get('[data-testid="range-end-right"]').trigger('click') + await flushPromises() + // The adjusted range is another identity, so the panel no longer says it is stored, while + // the text the learner already had stays in the form. + expect(view.get('.lookup-panel').text()).toContain('新词条') + expect(view.get('.lookup-panel').text()).not.toContain('已保存') + expect((view.get('#term-definition').element as HTMLTextAreaElement).value).toBe('一小步') + }) + it('refuses a range longer than the phrase limit and keeps the panel closed', async () => { // One word more than a phrase may hold. const longTokens: ReaderToken[] = [] diff --git a/learner/src/composables/useReaderLookup.ts b/learner/src/composables/useReaderLookup.ts index 659abcd..84c8530 100644 --- a/learner/src/composables/useReaderLookup.ts +++ b/learner/src/composables/useReaderLookup.ts @@ -289,7 +289,14 @@ export function useReaderLookup(chapter: Ref) { const currentChapter = chapter.value if (!current || !currentChapter) return const next = adjustTokenRange(tokens.value, currentChapter.originalText ?? '', current, edge, direction) - if (next) range.value = next + if (!next) return + // The adjusted range is a different identity, so the panel must stop claiming that the + // stored entry is what is on screen: both the phrase link and the entry the form was loaded + // from are dropped. The typed text stays, and saving it writes the phrase that matches the + // current range (which may update another entry, never two). + rangeTermId.value = null + savedTermId.value = null + range.value = next } /** True when the panel is editing an entry that is already stored. */ diff --git a/server/app/lexgo/phrase_test.go b/server/app/lexgo/phrase_test.go index bae84bd..11a78c5 100644 --- a/server/app/lexgo/phrase_test.go +++ b/server/app/lexgo/phrase_test.go @@ -118,6 +118,35 @@ func TestPhraseIdentityRules(t *testing.T) { } } +// TestWordKeysNeverContainSpaces locks the premise the derived kind relies on: a word token +// never contains a space, so the identity key alone tells a word from a phrase and no column is +// needed for it. +func TestWordKeysNeverContainSpaces(t *testing.T) { + samples := []string{phraseFixtureText, "a b\tc\r\nd", "don’t stop-BELIEVING.", "🙂 café e\u0301 42"} + checked := 0 + for _, sample := range samples { + for _, token := range Tokenize(sample) { + if token.Kind != "word" { + continue + } + checked++ + if strings.ContainsAny(normalizeWord(token.Text), " \t\r\n") { + t.Fatalf("word key %q contains whitespace", normalizeWord(token.Text)) + } + } + } + if checked < 8 { + t.Fatalf("expected several word tokens, checked %d", checked) + } + // The derived kind therefore agrees with the shape of the key in both directions. + if termKind("a small step") != termKindPhrase || termWordCount("a small step") != 3 { + t.Fatal("a key with spaces must be a three-word phrase") + } + if termKind("curiosity") != termKindWord || termWordCount("curiosity") != 1 { + t.Fatal("a key without spaces must be a word") + } +} + func mustWords(t *testing.T, text string) []TextToken { t.Helper() words, err := phraseWords(phraseTokens(text), 0, len([]rune(text)))