fix: 按审核结论补强短语实现与测试 (#11)

- adjustRange 改变范围时同时清空短语链接与已加载条目:调整端点后不再声称「已保存」,
  已输入文本保留,保存按当前范围确定的身份写入(可能更新另一条,但绝不重复)
- 新增 TestWordKeysNeverContainSpaces:显式锁定「词片段不含空白」这一不变量,
  termKind/termWordCount 完全依赖它,此前只有间接覆盖
- e2e 断言超时提高到 15s:11 个用例共用开发服务器并行运行时,首屏模块加载可能超过
  默认 5s 造成偶发失败(产品行为未变,连续两次并行全量运行均通过)
This commit was merged in pull request #30.
This commit is contained in:
ila
2026-09-14 22:47:14 +08:00
parent b1b76cfb7c
commit a658fa6fd1
4 changed files with 55 additions and 1 deletions
+3
View File
@@ -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 },
+15
View File
@@ -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[] = []
+8 -1
View File
@@ -289,7 +289,14 @@ export function useReaderLookup(chapter: Ref<ChapterDetail | null>) {
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. */
+29
View File
@@ -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)))