Files
Brain/brain-h5/tests/mobile-runtime.spec.ts
MingNian b0fce840a3 首次提交:脑晴测 H5 项目
包含前端源码、预构建产物(dist/)、零依赖 Node.js 服务器(server.mjs)、
Cloudflare Worker 版本(worker/)、部署文档(DEPLOY.md)和交接文档。

技术总监可直接 clone 后运行 node server.mjs 启动,无需安装 React 或 build。
DEEPSEEK_API_KEY 通过环境变量注入,未纳入仓库。
2026-07-28 10:24:47 +08:00

243 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { expect, test, type Locator, type Page } from "@playwright/test";
async function drag(page: Page, locator: Locator, deltaX: number, deltaY: number, steps = 8) {
const box = await locator.boundingBox();
if (!box) throw new Error("Drag target has no bounding box");
const startX = box.x + box.width / 2;
const startY = box.y + box.height / 2;
await page.mouse.move(startX, startY);
await page.mouse.down();
for (let step = 1; step <= steps; step += 1) {
await page.mouse.move(
startX + (deltaX * step) / steps,
startY + (deltaY * step) / steps,
);
await page.waitForTimeout(8);
}
await page.mouse.up();
}
test.beforeEach(async ({ page }) => {
await page.goto("/tests/runtime-fixture.html");
});
test("horizontal intent stays in Carousel and cannot create parent momentum", async ({ page }) => {
const carousel = page.locator(".fixture-carousel");
const card = page.locator(".carousel-card").nth(1);
const parent = page.getByTestId("mobile-scroll");
await expect(carousel).not.toHaveAttribute("data-scroll-drag", "ignore");
await drag(page, card, -130, 14, 5);
const afterRelease = await carousel.evaluate((element) => element.scrollLeft);
expect(afterRelease).toBeGreaterThan(40);
expect(await parent.evaluate((element) => element.scrollTop)).toBe(0);
await page.waitForTimeout(250);
expect(await parent.evaluate((element) => element.scrollTop)).toBe(0);
expect(await page.getByTestId("tap-count").textContent()).toBe("0");
});
test("vertical intent over a carousel is handed to MobileScroll in both directions", async ({ page }) => {
const card = page.locator(".carousel-card").nth(1);
const carousel = page.locator(".fixture-carousel");
const parent = page.getByTestId("mobile-scroll");
await drag(page, card, 4, -150);
expect(await parent.evaluate((element) => element.scrollTop)).toBeGreaterThan(60);
expect(await carousel.evaluate((element) => element.scrollLeft)).toBe(0);
await parent.evaluate((element) => {
element.scrollTop = 80;
});
await drag(page, card, -3, 110);
expect(await parent.evaluate((element) => element.scrollTop)).toBeLessThan(80);
});
test("tap activates a card but a completed drag does not", async ({ page }) => {
const firstCard = page.locator(".carousel-card").first();
await firstCard.click();
await expect(page.getByTestId("tap-count")).toHaveText("1");
await drag(page, firstCard, -100, 6);
await expect(page.getByTestId("tap-count")).toHaveText("1");
});
test("Carousel preserves momentum and edge rubber-banding", async ({ page }) => {
const carousel = page.locator(".fixture-carousel");
const card = page.locator(".carousel-card").nth(1);
await drag(page, card, -100, 5, 3);
const releasedOffset = await carousel.evaluate((element) => element.scrollLeft);
await page.waitForTimeout(120);
expect(await carousel.evaluate((element) => element.scrollLeft)).toBeGreaterThan(releasedOffset);
await carousel.evaluate((element) => {
element.scrollLeft = 0;
});
const box = await card.boundingBox();
if (!box) throw new Error("Card has no bounding box");
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x + box.width / 2 + 90, box.y + box.height / 2, { steps: 4 });
expect(Number(await carousel.getAttribute("data-overscroll"))).toBeGreaterThan(0);
await page.mouse.up();
await page.waitForTimeout(900);
expect(Math.abs(Number(await carousel.getAttribute("data-overscroll")))).toBeLessThan(1);
});
test("BottomSheet remains mounted while its default exit animation plays", async ({ page }) => {
await page.locator(".sheet-trigger").click();
await expect(page.getByTestId("bottom-sheet")).toBeVisible();
await page.getByTestId("sheet-overlay").click({ position: { x: 8, y: 8 } });
await expect(page.getByTestId("bottom-sheet")).toHaveCount(1);
await page.waitForTimeout(500);
await expect(page.getByTestId("bottom-sheet")).toHaveCount(0);
});
test("keyboard and its attached footer dismiss on the same transition", async ({ page }) => {
await page.goto("/tests/runtime-fixture.html?fixture=keyboard");
const input = page.getByLabel("Message");
const footer = page.getByTestId("flow-fixed-footer");
const keyboard = page.getByTestId("keyboard-dock");
await input.click();
await expect(keyboard).toHaveAttribute("data-visible", "true");
await drag(page, footer, 0, 120, 5);
await expect(keyboard).toHaveAttribute("data-visible", "false");
await page.waitForTimeout(100);
const progress = await page.evaluate(() => {
const footerElement = document.querySelector<HTMLElement>('[data-testid="flow-fixed-footer"]')!;
const keyboardElement = document.querySelector<HTMLElement>('[data-testid="keyboard-dock"]')!;
const fullHeight = Number.parseFloat(keyboardElement.style.height);
const footerRemaining = Number.parseFloat(getComputedStyle(footerElement).bottom);
const matrix = new DOMMatrixReadOnly(getComputedStyle(keyboardElement).transform);
return {
footer: footerRemaining / fullHeight,
keyboard: 1 - matrix.m42 / fullHeight,
};
});
expect(Math.abs(progress.footer - progress.keyboard)).toBeLessThan(0.18);
await page.waitForTimeout(300);
expect(await footer.evaluate((element) => getComputedStyle(element).bottom)).toBe("34px");
});
test("switching to Pixel keeps the composer above Android navigation", async ({ page }) => {
await page.goto("/tests/runtime-fixture.html?fixture=keyboard");
const input = page.getByLabel("Message");
await input.evaluate((element: HTMLInputElement) => {
element.value = "Draft message";
});
await page.getByTestId("device-picker").click();
await page.getByTestId("device-option-pixel-10").click();
const frame = page.getByTestId("phone-frame");
const screen = page.getByTestId("device-screen");
const statusIndicators = page.getByTestId("status-indicators");
const navigation = page.getByTestId("android-navigation-bar");
const footer = page.getByTestId("flow-fixed-footer");
await expect(frame).toHaveAttribute("data-device", "pixel-10");
await expect(screen).toHaveAttribute("data-device", "pixel-10");
await expect(page.locator(".phone-bezel")).toHaveAttribute(
"src",
"/assets/android/Pixel10.png",
);
await expect(statusIndicators).toHaveAttribute("data-platform", "android");
await expect(statusIndicators).toHaveAttribute(
"src",
"/assets/status/status-icons.svg",
);
await expect(navigation).toBeVisible();
await expect(page.getByTestId("home-indicator")).toHaveCount(0);
await expect(input).toHaveValue("Draft message");
await page.waitForTimeout(300);
const layout = await page.evaluate(() => {
const footerElement = document.querySelector<HTMLElement>(
'[data-testid="flow-fixed-footer"]',
)!;
const navigationElement = document.querySelector<HTMLElement>(
'[data-testid="android-navigation-bar"]',
)!;
const appViewportElement = document.querySelector<HTMLElement>(
'[data-testid="mobile-app-viewport"]',
)!;
return {
footerBottom: footerElement.getBoundingClientRect().bottom,
appViewportBottom: appViewportElement.getBoundingClientRect().bottom,
navigationTop: navigationElement.getBoundingClientRect().top,
navigationHeight: Number.parseFloat(getComputedStyle(navigationElement).height),
safeAreaBottom: Number.parseFloat(
getComputedStyle(document.querySelector<HTMLElement>('[data-testid="device-screen"]')!).getPropertyValue(
"--device-safe-area-bottom",
),
),
};
});
expect(layout.safeAreaBottom).toBe(layout.navigationHeight);
expect(Math.abs(layout.appViewportBottom - layout.navigationTop)).toBeLessThanOrEqual(1);
expect(Math.abs(layout.footerBottom - layout.navigationTop)).toBeLessThanOrEqual(1);
await input.click();
await expect(page.getByTestId("keyboard-dock")).toHaveAttribute("data-visible", "true");
await expect(navigation).toHaveCount(0);
await page.waitForTimeout(300);
const keyboardLayout = await page.evaluate(() => {
const screen = document.querySelector<HTMLElement>('[data-testid="device-screen"]')!;
const viewport = document.querySelector<HTMLElement>('[data-testid="mobile-app-viewport"]')!;
const scroll = document.querySelector<HTMLElement>('[data-testid="mobile-scroll"]')!;
const footerElement = document.querySelector<HTMLElement>('[data-testid="flow-fixed-footer"]')!;
const keyboard = document.querySelector<HTMLElement>('[data-testid="keyboard-dock"]')!;
return {
screenBottom: screen.getBoundingClientRect().bottom,
viewportBottom: viewport.getBoundingClientRect().bottom,
scrollBottom: scroll.getBoundingClientRect().bottom,
footerBottom: footerElement.getBoundingClientRect().bottom,
keyboardTop: keyboard.getBoundingClientRect().top,
keyboardBottom: keyboard.getBoundingClientRect().bottom,
};
});
expect(keyboardLayout.viewportBottom).toBeCloseTo(keyboardLayout.screenBottom, 0);
expect(Math.abs(keyboardLayout.keyboardBottom - keyboardLayout.screenBottom)).toBeLessThanOrEqual(1);
expect(Math.abs(keyboardLayout.scrollBottom - keyboardLayout.keyboardTop)).toBeLessThanOrEqual(1);
expect(Math.abs(keyboardLayout.footerBottom - keyboardLayout.keyboardTop)).toBeLessThanOrEqual(1);
});
test("FlowStack pushes and pops screens while dismissing the keyboard", async ({ page }) => {
await page.goto("/tests/runtime-fixture.html?fixture=flow");
await page.getByLabel("Flow message").click();
await expect(page.getByTestId("keyboard-dock")).toHaveAttribute("data-visible", "true");
await page.getByRole("button", { name: "Push level 2" }).click();
await expect(page.getByRole("heading", { name: "Screen stacking works" })).toBeVisible();
await expect(page.getByTestId("keyboard-dock")).toHaveAttribute("data-visible", "false");
const safeHeaderPlacement = await page.evaluate(() => {
const screen = document.querySelector<HTMLElement>('[data-testid="device-screen"]')!;
const toolbar = document.querySelector<HTMLElement>(".flow-fixture-header")!;
return toolbar.getBoundingClientRect().top - screen.getBoundingClientRect().top;
});
expect(safeHeaderPlacement).toBeGreaterThanOrEqual(54);
await page.getByRole("button", { name: "Push level 3" }).click();
await expect(page.getByRole("heading", { name: "Nested view level 3" })).toBeVisible();
await page.getByRole("button", { name: "Push level 4" }).click();
await expect(page.getByRole("heading", { name: "Nested view level 4" })).toBeVisible();
await page.getByRole("button", { name: "Done" }).click();
await expect(page.getByRole("heading", { name: "Nested view level 3" })).toBeVisible();
await page.getByRole("button", { name: " Back" }).click();
await expect(page.getByRole("heading", { name: "Screen stacking works" })).toBeVisible();
await page.getByRole("button", { name: "Done" }).click();
await expect(page.getByRole("heading", { name: "Flow root" })).toBeVisible();
});