Ctrl K
ComunidadGuías…

e2e/assembly-review.spec.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { expect, test } from "@playwright/test";
import { createHash } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import type { AssemblyInspection } from "@benchledger/api-contract";
import { openProject, openProjectSection } from "./workspace-controls";
import { contactSheet, type ReviewFrame, type ReviewPixels } from "./visual-review";

const views = ["Isometric", "Front", "Rear"] as const;
const separations = [0, 25, 50, 75, 100] as const;
const viewports = [{ name: "desktop", width: 1440, height: 1000 }, { name: "phone", width: 390, height: 844 }] as const;

test("synthetic assembly review pack covers camera, separation, theme and viewport states", async ({ page, context }, testInfo) => {
  test.setTimeout(120_000);
  const pageErrors: string[] = [];
  page.on("pageerror", error => pageErrors.push(error.message));
  const fixture = await readFile("packages/artifacts/testfiles/synthetic-assembly.step");
  const fixtureSha256 = createHash("sha256").update(fixture).digest("hex");
  await page.setViewportSize(viewports[0]);
  await page.goto("/");
  await page.getByLabel("Workspace password").fill("demo-password-please-change");
  await page.getByRole("button", { name: "Sign in", exact: true }).click();
  await openProject(page, "Synthetic H2D desk lamp");
  // The signed-out demo session probe intentionally returns 401 before login.
  page.on("console", message => { if (message.type() === "error") pageErrors.push(`console.error: ${message.text()}`); });
  await page.getByRole("tab", { name: /^Files/u }).click();
  await page.getByLabel("Choose files to upload").setInputFiles({ name: "synthetic-review.step", mimeType: "model/step", buffer: fixture });
  await page.getByRole("button", { name: "Add 1 file", exact: true }).click();
  await expect(page.getByRole("button", { name: "Download synthetic-review.step", exact: true })).toBeVisible();
  await openProjectSection(page, "Assembly");
  await page.getByLabel("synthetic-review.step", { exact: true }).check();
  const inspecting = page.waitForResponse(response => response.url().endsWith("/assembly/inspect") && response.request().method() === "POST");
  await page.getByRole("button", { name: "Open assembly", exact: true }).click();
  const response = await inspecting;
  expect(response.ok()).toBe(true);
  const inspection = await response.json() as AssemblyInspection;
  expect(inspection.sources).toHaveLength(1);
  expect(inspection.sources[0]!.sha256).toBe(fixtureSha256);
  expect(inspection.parts.map(part => part.name)).toEqual(["Base", "Lid"]);
  const identity = new URL(response.url()).pathname.match(/\/projects\/([^/]+)\/revisions\/([^/]+)\/assembly\/inspect$/u);
  expect(identity).not.toBeNull();
  const model = page.locator(".assembly-canvas canvas");
  const dimensions = page.locator(".assembly-dimensions");
  const toolbar = page.getByRole("group", { name: "Assembly views", exact: true });
  const slider = page.getByRole("slider", { name: "Assembly separation" });
  await expect(model).toBeVisible();
  await expect(dimensions).toContainText("40.0 × 30.0 × 17.0 mm");
  const assembledBounds = (await dimensions.textContent())!;
  const states: { theme: string; viewport: string; view: string; separation: number; sheet: string; frame: number; dimensions: string; pixels: ReviewPixels }[] = [];

  for (const viewport of viewports) {
    await page.setViewportSize({ width: viewport.width, height: viewport.height });
    for (const theme of ["light", "dark"] as const) {
      await page.evaluate(theme => document.documentElement.dataset.theme = theme, theme);
      await expect(page.locator("html")).toHaveAttribute("data-theme", theme);
      const frames: ReviewFrame[] = [];
      const frameStates: Omit<typeof states[number], "pixels">[] = [];
      const filename = `assembly-${viewport.name}-${theme}.png`;
      for (const separation of separations) {
        // Exercise the public keyboard control, including the fit on key-up.
        await slider.focus(); await page.keyboard.press("Home");
        for (let value = 0; value < Math.floor(separation / 10); value++) await page.keyboard.press("PageUp");
        for (let value = 0; value < separation % 10; value++) await page.keyboard.press("ArrowRight");
        await expect(slider).toHaveAttribute("aria-valuenow", String(separation));
        for (const view of views) {
          await toolbar.getByRole("button", { name: view, exact: true }).click();
          for (const candidate of views) await expect(toolbar.getByRole("button", { name: candidate, exact: true })).toHaveAttribute("aria-pressed", String(candidate === view));
          await expect(slider).toHaveAttribute("aria-valuenow", String(separation));
          await expect(dimensions).toHaveText(assembledBounds);
          await expect(model).toBeVisible();
          await expect(page.locator(".assembly-render-error")).toHaveCount(0);
          // A screenshot waits for layout/paint; the canvas bounds must follow
          // the current viewport before capturing the WebGL result.
          await expect.poll(() => model.evaluate(canvas => Math.abs((canvas as HTMLCanvasElement).width / Math.min(devicePixelRatio, 2) - canvas.getBoundingClientRect().width))).toBeLessThan(2);
          await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(viewport.width);
          expect(pageErrors, `${viewport.name}/${theme}/${view}/${separation}: unexpected page errors`).toEqual([]);
          // Centre the complete model canvas above the phone's fixed navigation
          // before capture, so UI overlays cannot masquerade as model pixels.
          await model.evaluate(canvas => canvas.scrollIntoView({ block: "center", inline: "nearest", behavior: "instant" }));
          await expect.poll(() => model.evaluate(canvas => {
            const bounds = canvas.getBoundingClientRect();
            const navigation = document.querySelector('nav[aria-label="Workspace on phone"]');
            const navBounds = navigation?.getBoundingClientRect();
            const bottom = navBounds && navBounds.height > 0 ? navBounds.top : innerHeight;
            return bounds.top >= 0 && bounds.bottom <= bottom;
          })).toBe(true);
          frames.push({ label: `${view} · separation ${separation}% · ${viewport.width}px · ${theme}`, png: await model.screenshot() });
          frameStates.push({ theme, viewport: viewport.name, view, separation, sheet: filename, frame: frames.length - 1, dimensions: assembledBounds });
        }
      }
      const sheet = await contactSheet(context, frames, `Synthetic assembly · ${viewport.name} ${viewport.width}px · ${theme}`);
      const sheetPath = testInfo.outputPath(filename);
      await writeFile(sheetPath, sheet.png);
      await testInfo.attach(filename, { path: sheetPath, contentType: "image/png" });
      for (const [index, pixels] of sheet.pixels.entries()) {
        expect(pixels.foregroundRatio, `${frames[index]!.label}: model canvas must contain visible geometry`).toBeGreaterThan(0.005);
        states.push({ ...frameStates[index]!, pixels });
      }
    }
  }

  // Selection remains usable after the full camera/theme/resize matrix.
  await page.getByRole("button", { name: "Lid", exact: true }).click();
  await expect(page.getByRole("button", { name: "Lid", exact: true })).toHaveAttribute("aria-pressed", "true");
  await page.getByRole("button", { name: "Isolate part", exact: true }).click();
  await expect(page.getByLabel("Show Base", { exact: true })).not.toBeChecked();
  await expect(page.getByLabel("Show Lid", { exact: true })).toBeChecked();
  await expect(dimensions).toHaveText(assembledBounds);
  await page.getByRole("button", { name: "Show all parts", exact: true }).click();
  await expect(page.getByLabel("Show Base", { exact: true })).toBeChecked();
  await expect(slider).toHaveAttribute("aria-valuenow", "100");
  expect(pageErrors).toEqual([]);
  expect(states).toHaveLength(60);
  const manifest = {
    schemaVersion: 1, synthetic: true, fixture: { name: "synthetic-assembly.step", sha256: fixtureSha256 },
    provenance: {
      projectId: decodeURIComponent(identity![1]!), projectRevisionId: decodeURIComponent(identity![2]!),
      assembly: { state: "unsaved inspected draft", persistedVersion: null, inspectionSha256: createHash("sha256").update(JSON.stringify(inspection)).digest("hex") },
      sources: inspection.sources, parts: inspection.parts.map(({ id, artifactId, nodeId, name }) => ({ id, artifactId, nodeId, name }))
    },
    assertions: ["separation preserved by camera choice", "exactly one camera pressed", "assembled dimensions stable", "model canvas nonblank", "no horizontal overflow", "selection and isolation restored", "no unexpected page errors or console errors"],
    states
  };
  const manifestPath = testInfo.outputPath("assembly-review-manifest.json");
  await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
  await testInfo.attach("assembly-review-manifest", { path: manifestPath, contentType: "application/json" });
});