Persist versioned models and validated network metadata in IndexedDB. Show scene download progress and recoverable errors with the existing frosted material. Connect existing skeletons to pending navigation and route boundaries while preserving the map. Previously only dynamic imports showed fallbacks, and rejected model promises prevented retries. Verified: 32 focused unit tests; 4 navigation and scene browser checks; 7 cache browser checks including invalid-metadata recovery; isolated production build and type checks.
282 lines
10 KiB
TypeScript
282 lines
10 KiB
TypeScript
import { expect, test } from "@playwright/test";
|
|
|
|
import { mockBackend } from "./support/mockBackend";
|
|
|
|
test.setTimeout(180_000);
|
|
|
|
test("首次加载和失败提示位于工具面板上方,窄屏可直接重试", async ({ page }) => {
|
|
await mockBackend(page);
|
|
await page.addInitScript(() => {
|
|
localStorage.setItem("active_project", "playwright-project");
|
|
localStorage.setItem("MAP_WORKSPACE", "zjb");
|
|
localStorage.setItem("NETWORK_NAME", "zjb");
|
|
});
|
|
let release: () => void = () => {};
|
|
const pending = new Promise<void>((resolve) => { release = resolve; });
|
|
const failedModel = "**/models/detail_roof.glb*";
|
|
await page.route(failedModel, async (route) => { await pending; await route.abort("failed"); });
|
|
await page.goto("/three-dimensional-scene");
|
|
const scene = page.frameLocator('iframe[title="高铁湛江北站供水系统三维场景"]');
|
|
await expect(scene.locator("#boot-title")).toHaveText("正在载入场景资源 9 / 10", { timeout: 60_000 });
|
|
const loadingMaterial = await scene.locator(".boot-panel").evaluate((element) => ({
|
|
background: getComputedStyle(element).backgroundImage,
|
|
blur: getComputedStyle(element).backdropFilter,
|
|
}));
|
|
for (const width of [1280, 375, 320]) {
|
|
await page.setViewportSize({ width, height: 800 });
|
|
expect(await page.evaluate(() => document.elementFromPoint(innerWidth / 2, innerHeight / 2)?.tagName)).toBe("IFRAME");
|
|
await page.screenshot({ path: `test-results/scene-host-loading-${width}.png` });
|
|
}
|
|
release();
|
|
await expect(scene.locator("#boot-title")).toHaveText("场景资源加载失败");
|
|
await page.unroute(failedModel);
|
|
await scene.getByRole("button", { name: "重新载入场景" }).click();
|
|
await expect(scene.locator("#boot")).toBeHidden({ timeout: 60_000 });
|
|
await expect(page.getByRole("slider", { name: /建筑背景透明度/ })).toBeEnabled();
|
|
expect(await page.getByLabel("三维场景控制面板").evaluate((element) => ({
|
|
background: getComputedStyle(element).backgroundImage,
|
|
blur: getComputedStyle(element).backdropFilter,
|
|
}))).toEqual(loadingMaterial);
|
|
});
|
|
|
|
type CameraProbe = {
|
|
active: string | null;
|
|
position: number[];
|
|
target: number[];
|
|
up: number[];
|
|
};
|
|
|
|
type SceneProbe = {
|
|
errors: string[];
|
|
contextVisible?: boolean;
|
|
camera?: CameraProbe;
|
|
appearance?: { contextOpacity: number };
|
|
networkStyle?: { style: { scale: number } };
|
|
};
|
|
|
|
test("三维页面默认展开场景工具并可调建筑透明度", async ({ page }) => {
|
|
await mockBackend(page);
|
|
await page.addInitScript(() => {
|
|
localStorage.setItem("active_project", "playwright-project");
|
|
localStorage.setItem("MAP_WORKSPACE", "zjb");
|
|
localStorage.setItem("NETWORK_NAME", "zjb");
|
|
});
|
|
await page.goto("/three-dimensional-scene");
|
|
|
|
await expect(page.getByLabel("三维场景控制面板")).toBeVisible();
|
|
await expect(page.getByRole("tab", { name: "场景" })).toHaveAttribute(
|
|
"aria-selected",
|
|
"true",
|
|
);
|
|
const opacitySlider = page.getByRole("slider", {
|
|
name: /建筑背景透明度/,
|
|
});
|
|
await expect(opacitySlider).toBeEnabled({ timeout: 60_000 });
|
|
await expect(opacitySlider).toHaveAccessibleName("建筑背景透明度 60%");
|
|
|
|
const backgroundToggle = page.getByRole("switch", { name: "建筑背景" });
|
|
await backgroundToggle.evaluate((element) =>
|
|
element.scrollIntoView({ block: "center" }),
|
|
);
|
|
const panel = page.getByLabel("三维场景控制面板");
|
|
const panelBeforeToggle = await panel.boundingBox();
|
|
const panelScrollBeforeToggle = await panel.locator(".overflow-y-auto").evaluate(
|
|
(element) => element.scrollTop,
|
|
);
|
|
await backgroundToggle.click();
|
|
await expect(backgroundToggle).toHaveAttribute("aria-checked", "false");
|
|
const panelAfterToggle = await panel.boundingBox();
|
|
const panelScrollAfterToggle = await panel.locator(".overflow-y-auto").evaluate(
|
|
(element) => element.scrollTop,
|
|
);
|
|
expect(panelAfterToggle).toEqual(panelBeforeToggle);
|
|
expect(panelScrollAfterToggle).toBe(panelScrollBeforeToggle);
|
|
|
|
await page.setViewportSize({ width: 375, height: 812 });
|
|
const mobilePanelBeforeToggle = await panel.boundingBox();
|
|
await backgroundToggle.click();
|
|
await expect(backgroundToggle).toHaveAttribute("aria-checked", "true");
|
|
const mobilePanelAfterToggle = await panel.boundingBox();
|
|
expect(mobilePanelAfterToggle).toEqual(mobilePanelBeforeToggle);
|
|
await expect(opacitySlider).toBeEnabled();
|
|
expect(
|
|
await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth),
|
|
).toBe(true);
|
|
});
|
|
|
|
test("全部预设使用稳定且紧凑的场景视角", async ({ page }) => {
|
|
await page.goto(
|
|
"/three-dimensional/zjb/v29/preview.html?host=platform2&v=32-context-opacity-test",
|
|
);
|
|
await expect(page.locator("#boot")).toBeHidden({ timeout: 60_000 });
|
|
|
|
const sceneProbe = () =>
|
|
page.evaluate(
|
|
() =>
|
|
(window as typeof window & { webQA: SceneProbe }).webQA,
|
|
);
|
|
|
|
await expect.poll(async () => (await sceneProbe()).camera?.active).toBe("overview");
|
|
const initialProbe = await sceneProbe();
|
|
const overview = initialProbe.camera;
|
|
expect(overview?.up[1]).toBeGreaterThan(0.8);
|
|
expect(initialProbe.appearance?.contextOpacity).toBe(0.4);
|
|
expect(initialProbe.networkStyle?.style.scale).toBe(6);
|
|
|
|
await page.evaluate(() =>
|
|
window.postMessage(
|
|
{
|
|
channel: "tjwater:zjb-scene",
|
|
version: 2,
|
|
type: "command",
|
|
projectCode: "zjb",
|
|
modelId: "zjb-water-network-v23",
|
|
command: { name: "set-appearance", patch: { contextOpacity: 0.35 } },
|
|
},
|
|
window.location.origin,
|
|
),
|
|
);
|
|
await expect
|
|
.poll(async () => (await sceneProbe()).appearance?.contextOpacity)
|
|
.toBe(0.35);
|
|
|
|
const cameraBeforeContextToggle = (await sceneProbe()).camera;
|
|
await page.evaluate(() =>
|
|
window.postMessage(
|
|
{
|
|
channel: "tjwater:zjb-scene",
|
|
version: 2,
|
|
type: "command",
|
|
projectCode: "zjb",
|
|
modelId: "zjb-water-network-v23",
|
|
command: { name: "toggle-context" },
|
|
},
|
|
window.location.origin,
|
|
),
|
|
);
|
|
await expect.poll(async () => (await sceneProbe()).contextVisible).toBe(false);
|
|
const cameraAfterContextToggle = (await sceneProbe()).camera;
|
|
expect(cameraAfterContextToggle?.position).toEqual(cameraBeforeContextToggle?.position);
|
|
expect(cameraAfterContextToggle?.target).toEqual(cameraBeforeContextToggle?.target);
|
|
await page.evaluate(() =>
|
|
window.postMessage(
|
|
{
|
|
channel: "tjwater:zjb-scene",
|
|
version: 2,
|
|
type: "command",
|
|
projectCode: "zjb",
|
|
modelId: "zjb-water-network-v23",
|
|
command: { name: "toggle-context" },
|
|
},
|
|
window.location.origin,
|
|
),
|
|
);
|
|
|
|
await page.evaluate(() =>
|
|
window.postMessage(
|
|
{
|
|
channel: "tjwater:zjb-scene",
|
|
version: 2,
|
|
type: "command",
|
|
projectCode: "zjb",
|
|
modelId: "zjb-water-network-v23",
|
|
command: { name: "visit-camera", viewId: "plan" },
|
|
},
|
|
window.location.origin,
|
|
),
|
|
);
|
|
|
|
await expect.poll(async () => (await sceneProbe()).camera?.active).toBe("plan");
|
|
await expect
|
|
.poll(async () => {
|
|
const camera = (await sceneProbe()).camera;
|
|
return Math.max(
|
|
Math.abs((camera?.position[0] ?? 0) - (camera?.target[0] ?? 1)),
|
|
Math.abs((camera?.position[2] ?? 0) - (camera?.target[2] ?? 1)),
|
|
);
|
|
})
|
|
.toBeLessThan(0.01);
|
|
const planProbe = await sceneProbe();
|
|
const plan = planProbe.camera;
|
|
expect(planProbe.errors).toEqual([]);
|
|
expect(Math.abs((plan?.position[0] ?? 0) - (plan?.target[0] ?? 1))).toBeLessThan(0.01);
|
|
expect(Math.abs((plan?.position[2] ?? 0) - (plan?.target[2] ?? 1))).toBeLessThan(0.01);
|
|
expect(plan?.up[0]).toBeCloseTo(0, 6);
|
|
expect(plan?.up[1]).toBeCloseTo(0, 6);
|
|
expect(plan?.up[2]).toBeCloseTo(-1, 6);
|
|
|
|
for (const viewId of [
|
|
"pump",
|
|
"hydraulic",
|
|
"singlePump",
|
|
"main",
|
|
"meter",
|
|
"crossing",
|
|
"station",
|
|
"riser",
|
|
]) {
|
|
await page.evaluate((nextViewId) =>
|
|
window.postMessage(
|
|
{
|
|
channel: "tjwater:zjb-scene",
|
|
version: 2,
|
|
type: "command",
|
|
projectCode: "zjb",
|
|
modelId: "zjb-water-network-v23",
|
|
command: { name: "visit-camera", viewId: nextViewId },
|
|
},
|
|
window.location.origin,
|
|
),
|
|
viewId,
|
|
);
|
|
await expect.poll(async () => (await sceneProbe()).camera?.active).toBe(viewId);
|
|
const presetProbe = await sceneProbe();
|
|
expect(presetProbe.errors).toEqual([]);
|
|
expect(presetProbe.camera?.position.every(Number.isFinite)).toBe(true);
|
|
expect(presetProbe.camera?.target.every(Number.isFinite)).toBe(true);
|
|
expect(presetProbe.camera?.up[1]).toBeGreaterThan(0.99);
|
|
}
|
|
|
|
const occupancy = await page.evaluate(async () => {
|
|
const threeUrl =
|
|
"/three-dimensional/zjb/v29/vendor/three/three.module.js";
|
|
const navigationUrl =
|
|
"/three-dimensional/zjb/v29/camera-navigation.mjs?v=31-camera-presets-test";
|
|
const THREE = await import(threeUrl);
|
|
const { framePose } = await import(navigationUrl);
|
|
const aspect = 16 / 9;
|
|
const box = new THREE.Box3(
|
|
new THREE.Vector3(-330, 0, -205),
|
|
new THREE.Vector3(330, 15, 205),
|
|
);
|
|
const pose = framePose(box, aspect, {
|
|
direction: [0, 1, 0],
|
|
up: [0, 0, -1],
|
|
padding: 1.04,
|
|
});
|
|
const camera = new THREE.PerspectiveCamera(42, aspect, 0.1, 10_000);
|
|
camera.position.fromArray(pose.position);
|
|
camera.up.fromArray(pose.up);
|
|
camera.lookAt(new THREE.Vector3(...pose.target));
|
|
camera.updateProjectionMatrix();
|
|
camera.updateMatrixWorld();
|
|
const points = [
|
|
[-330, 0, -205],
|
|
[-330, 0, 205],
|
|
[-330, 15, -205],
|
|
[-330, 15, 205],
|
|
[330, 0, -205],
|
|
[330, 0, 205],
|
|
[330, 15, -205],
|
|
[330, 15, 205],
|
|
].map((point) => new THREE.Vector3(...point).project(camera));
|
|
return {
|
|
x: Math.max(...points.map((point) => Math.abs(point.x))),
|
|
y: Math.max(...points.map((point) => Math.abs(point.y))),
|
|
};
|
|
});
|
|
expect(occupancy.x).toBeLessThanOrEqual(1);
|
|
expect(occupancy.y).toBeLessThanOrEqual(1);
|
|
expect(Math.max(occupancy.x, occupancy.y)).toBeGreaterThan(0.9);
|
|
});
|