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.
166 lines
8.1 KiB
TypeScript
166 lines
8.1 KiB
TypeScript
import { expect, test } from "@playwright/test";
|
|
|
|
const root = "/three-dimensional/zjb/v29/";
|
|
const preview = root + "preview.html?host=platform2&v=35-resource-cache";
|
|
const isAsset = (url: string) => /\.glb(?:\?|$)|water-network-v28\.json/.test(url);
|
|
|
|
test.setTimeout(120_000);
|
|
|
|
test("HTTP 200 的错误元数据不会进入缓存,服务器恢复后重试成功", async ({ page }) => {
|
|
let requests = 0;
|
|
await page.route("**/water-network-v28.json*", async (route) => {
|
|
requests++;
|
|
if (requests === 1) return route.fulfill({ json: { error: "temporary proxy failure" } });
|
|
return route.continue();
|
|
});
|
|
await page.goto(preview);
|
|
await expect(page.locator("#boot")).toHaveAttribute("data-state", "error", { timeout: 60_000 });
|
|
const cached = await page.evaluate((base) => new Promise((resolve, reject) => {
|
|
const request = indexedDB.open("tjwater-scene-assets-v1", 1);
|
|
request.onerror = () => reject(request.error);
|
|
request.onsuccess = () => {
|
|
const database = request.result;
|
|
const read = database.transaction("resources").objectStore("resources").get(new URL(base + "water-network-v28.json", location.origin).href);
|
|
read.onsuccess = () => { resolve(Boolean(read.result)); database.close(); };
|
|
read.onerror = () => { reject(read.error); database.close(); };
|
|
};
|
|
}), root);
|
|
expect(cached).toBe(false);
|
|
await page.getByRole("button", { name: "重新载入场景" }).click();
|
|
await expect(page.locator("#boot")).toBeHidden({ timeout: 60_000 });
|
|
expect(requests).toBe(2);
|
|
});
|
|
|
|
test("模型和元数据跨页面复用持久缓存,即使服务器资源不可用也能重开场景", async ({ page, context }) => {
|
|
const requests: string[] = [];
|
|
let block = false;
|
|
// Routing disables HTTP cache, so this checks IndexedDB rather than browser HTTP caching.
|
|
await context.route("**/three-dimensional/**", (route) => {
|
|
if (isAsset(route.request().url())) {
|
|
requests.push(route.request().url());
|
|
if (block) return route.abort("failed");
|
|
}
|
|
return route.continue();
|
|
});
|
|
const coldStart = Date.now();
|
|
await page.goto(preview);
|
|
await expect(page.locator("#boot")).toBeHidden({ timeout: 60_000 });
|
|
const coldMs = Date.now() - coldStart;
|
|
expect(requests).toHaveLength(10);
|
|
requests.length = 0;
|
|
block = true;
|
|
await page.close();
|
|
const reopened = await context.newPage();
|
|
const warmStart = Date.now();
|
|
await reopened.goto(preview);
|
|
await expect(reopened.locator("#boot")).toBeHidden({ timeout: 60_000 });
|
|
expect(await reopened.evaluate(() =>
|
|
(window as typeof window & { webQA: { camera: { active: string } } }).webQA.camera.active,
|
|
)).toBe("overview");
|
|
expect(requests).toEqual([]);
|
|
console.log(JSON.stringify({ coldMs, warmMs: Date.now() - warmStart, warmAssetRequests: requests.length }));
|
|
});
|
|
|
|
test("首次载入显示真实进度,失败保留提示,重试可以恢复", async ({ page }) => {
|
|
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(preview);
|
|
await expect(page.locator("#boot-title")).toHaveText("正在载入场景资源 9 / 10", { timeout: 60_000 });
|
|
await expect(page.getByRole("progressbar")).toHaveAttribute("value", "9");
|
|
await expect(page.locator("#boot-detail")).toContainText(/已读取 \d+\.\d MB/);
|
|
await page.screenshot({ path: "test-results/scene-loading-desktop.png" });
|
|
await page.setViewportSize({ width: 320, height: 640 });
|
|
await page.screenshot({ path: "test-results/scene-loading-mobile.png" });
|
|
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
|
|
release();
|
|
await expect(page.locator("#boot-title")).toHaveText("场景资源加载失败");
|
|
await expect(page.locator("#boot-detail")).toContainText("detail_roof.glb");
|
|
await expect(page.locator("#boot")).toHaveAttribute("aria-busy", "false");
|
|
await expect(page.getByRole("button", { name: "重新载入场景" })).toBeVisible();
|
|
await page.screenshot({ path: "test-results/scene-loading-error.png" });
|
|
await page.unroute(failedModel);
|
|
await page.getByRole("button", { name: "重新载入场景" }).click();
|
|
await expect(page.locator("#boot")).toBeHidden({ timeout: 60_000 });
|
|
});
|
|
|
|
test("同一资源合并并发请求,摘要改变后重新下载并覆盖旧缓存", async ({ page }) => {
|
|
let requests = 0;
|
|
await page.route("**/cache-probe.json*", (route) => {
|
|
requests++;
|
|
return route.fulfill({ json: { revision: new URL(route.request().url()).searchParams.get("v") } });
|
|
});
|
|
await page.goto(root + "manifest.json");
|
|
const result = await page.evaluate(async (base) => {
|
|
const moduleUrl = base + "resourceCache.mjs";
|
|
const { createSceneResourceCache } = await import(moduleUrl);
|
|
const cache = createSceneResourceCache(location.origin + base);
|
|
const decode = (data: ArrayBuffer) => JSON.parse(new TextDecoder().decode(data));
|
|
const first = await Promise.all([
|
|
cache.load("cache-probe.json", "first", decode),
|
|
cache.load("cache-probe.json", "first", decode),
|
|
]);
|
|
const updated = await cache.load("cache-probe.json", "second", decode);
|
|
const freshClient = createSceneResourceCache(location.origin + base);
|
|
const cached = await freshClient.load("cache-probe.json", "second", decode);
|
|
return { first, updated, cached };
|
|
}, root);
|
|
expect(requests).toBe(2);
|
|
expect(result.first).toEqual([{ revision: "first" }, { revision: "first" }]);
|
|
expect(result.updated).toEqual({ revision: "second" });
|
|
expect(result.cached).toEqual({ revision: "second" });
|
|
});
|
|
|
|
for (const failure of ["denied", "quota"] as const) {
|
|
test(`浏览器缓存${failure === "denied" ? "被禁用" : "空间不足"}时仍可下载并解析资源`, async ({ page }) => {
|
|
await page.goto(root + "manifest.json");
|
|
await page.route("**/cache-probe.json*", (route) => route.fulfill({ json: { ok: true } }));
|
|
const result = await page.evaluate(async ({ base, failure }) => {
|
|
if (failure === "denied") {
|
|
Object.defineProperty(window, "indexedDB", { get() { throw new Error("denied"); } });
|
|
} else {
|
|
const transaction = IDBDatabase.prototype.transaction;
|
|
IDBDatabase.prototype.transaction = function (...args: Parameters<typeof transaction>) {
|
|
if (args[1] === "readwrite") throw new DOMException("Full", "QuotaExceededError");
|
|
return transaction.apply(this, args);
|
|
};
|
|
}
|
|
const moduleUrl = base + "resourceCache.mjs";
|
|
const { createSceneResourceCache } = await import(moduleUrl);
|
|
const cache = createSceneResourceCache(location.origin + base);
|
|
return cache.load("cache-probe.json", "first", (data: ArrayBuffer) => JSON.parse(new TextDecoder().decode(data)));
|
|
}, { base: root, failure });
|
|
expect(result).toEqual({ ok: true });
|
|
});
|
|
}
|
|
|
|
test("HTTP 错误和解析失败不会写入缓存,下一次请求可恢复", async ({ page }) => {
|
|
let requests = 0;
|
|
await page.route("**/cache-probe.json*", (route) => {
|
|
requests++;
|
|
return requests === 1
|
|
? route.fulfill({ status: 503, body: "unavailable" })
|
|
: requests === 2
|
|
? route.fulfill({ body: "invalid json" })
|
|
: route.fulfill({ json: { ok: true } });
|
|
});
|
|
await page.goto(root + "manifest.json");
|
|
const result = await page.evaluate(async (base) => {
|
|
const moduleUrl = base + "resourceCache.mjs";
|
|
const { createSceneResourceCache } = await import(moduleUrl);
|
|
const cache = createSceneResourceCache(location.origin + base);
|
|
const decode = (data: ArrayBuffer) => JSON.parse(new TextDecoder().decode(data));
|
|
const errors: string[] = [];
|
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
try { await cache.load("cache-probe.json", "first", decode); }
|
|
catch (error) { errors.push(String(error)); }
|
|
}
|
|
return { errors, recovered: await cache.load("cache-probe.json", "first", decode) };
|
|
}, root);
|
|
expect(requests).toBe(3);
|
|
expect(result.errors).toHaveLength(2);
|
|
expect(result.errors[0]).toContain("HTTP 503");
|
|
expect(result.recovered).toEqual({ ok: true });
|
|
});
|