Files
lexgo/learner/e2e/mobile-display.spec.ts
ila ab23ff60e5 feat: 书籍封面与音频附件、播放器与播放位置 (#21)
- schema v8:lexgo_book_attachments(按 book_id+kind 唯一,bytes MEDIUMBLOB)与
  lexgo_playback_positions(owner_id+book_id 主键),只用可重放 DDL,外键级联删书
- 后端:按文件头 magic bytes 判定类型(MP3 / JPG / PNG / WebP,含 WebP 维度解析),
  音频 ≤20 MiB、封面 ≤2 MiB 且 ≤4096²;上传/替换/移除;binaryResponse 交给
  http.ServeContent 提供 Range 206、416、ETag 与 304;播放位置 upsert,替换音频时重置
- 学习端:带凭据的 fetch + 对象 URL(令牌不进 URL),书库封面、书籍页「音频与封面」
  区块、阅读页常驻播放器(播放/暂停、进度、0.75–1.5 倍速、错误重试、位置上报)
- 测试:Go 86 项(新增附件单测/集成与 v7→v8 迁移)、学习端 157 单测与 26 项 E2E
- 顺带修掉 e2e/phrase.spec.ts 的偶发失败:重试条件改为断言期望词数、重读坐标、关面板后重试
- 文档:Architecture / Business-Rules / Local-Development / Requirements / Home /
  Deployment-and-Operations(备份范围含附件与体积提示)
2026-09-15 18:39:56 +08:00

78 lines
4.5 KiB
TypeScript

import { expect, test } from '@playwright/test'
import { login, mockLearnerApi, touchSwipe } from './mobile-fixtures'
// Touch must scroll the chapter instead of being swallowed by the selection handling, and a tap
// must still open the word panel. This is emulated touch input through Chromium, not a real phone.
test('a touch swipe scrolls the chapter and does not open the word panel', async ({ page }) => {
await mockLearnerApi(page)
await login(page)
await page.getByRole('link', { name: 'Fictional mobile book' }).click()
await page.locator('.chapter-list a.chapter-name').first().click()
await expect(page.locator('.reader-text')).toBeVisible()
// The words arrive with the token response, so wait for them instead of sampling once.
await expect.poll(async () => page.locator('.reader-word').count()).toBeGreaterThan(3)
// A swipe upwards scrolls the page down and leaves the panel closed.
await touchSwipe(page, 640, 240)
await expect.poll(async () => page.evaluate(() => window.scrollY)).toBeGreaterThan(0)
expect(await page.locator('.lookup-panel').count()).toBe(0)
await page.screenshot({ path: '../.local/evidence/issue14-mobile-scrolled.png' })
// A tap on a word still opens the panel, which sits above the text without covering it.
await page.locator('.reader-word').nth(1).tap()
const panel = page.locator('.lookup-panel')
await expect(panel).toBeVisible()
const panelBox = (await panel.boundingBox())!
expect(panelBox.x).toBeGreaterThanOrEqual(0)
expect(panelBox.x + panelBox.width).toBeLessThanOrEqual(391)
// The page reserves room for the fixed sheet, so the last paragraph can still be read.
const padding = await page.evaluate(() => getComputedStyle(document.querySelector('.reader-page') as Element).paddingBottom)
expect(parseFloat(padding)).toBeGreaterThan(100)
// Closing the panel keeps the reader where they were: the tapped word stays on screen. The page
// releases the space it reserved for the sheet, so the browser may clamp the scroll by a little
// when the chapter end is already in view; what must not happen is losing sight of the word.
const word = page.locator('.reader-word').nth(1)
await page.getByRole('button', { name: '关闭释义' }).tap()
await expect(panel).toHaveCount(0)
await expect(word).toBeInViewport()
})
test('the display choice survives a reload and belongs to the account', async ({ page }) => {
await mockLearnerApi(page)
await login(page)
await page.getByTestId('display-trigger').tap()
await page.getByRole('menu').getByText('深色', { exact: true }).tap()
await page.getByTestId('display-trigger').tap()
await page.getByRole('menu').getByText('特大', { exact: true }).tap()
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark')
const readerScale = await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('--reader-font-scale').trim())
expect(readerScale).toBe('1.3')
// Reloading keeps both choices: they are stored, not just applied.
await page.reload()
await expect(page.getByRole('heading', { name: '我的书库' })).toBeVisible()
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark')
expect(await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('--reader-font-scale').trim())).toBe('1.3')
// The reading text really is larger, which is what the choice promises.
await page.getByRole('link', { name: 'Fictional mobile book' }).click()
await page.locator('.chapter-list a.chapter-name').first().click()
await expect(page.locator('.reader-text')).toBeVisible()
const size = await page.locator('.reader-text').evaluate(element => parseFloat(getComputedStyle(element).fontSize))
expect(size).toBeGreaterThan(16)
await page.screenshot({ path: '../.local/evidence/issue14-mobile-dark-large.png' })
// Signing out and back in as another account must not inherit the first account's choice.
await page.getByRole('button', { name: '退出登录' }).tap()
await expect(page.getByRole('heading', { name: '欢迎回来' })).toBeVisible()
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light')
await page.getByLabel('账号').fill('fictional-mobile')
await page.getByLabel('密码', { exact: true }).fill('fictional-password')
await page.getByRole('button', { name: '登录', exact: true }).click()
await expect(page.getByRole('heading', { name: '我的书库' })).toBeVisible()
// The same account keeps its own choice.
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark')
})