JEPA4Japan · 教程

第 14 章:看起来正确,不代表真的正确

4,770字 14分钟阅读 #Canvas#前端工程#无限画布#通俗讲解

从模型、状态机、浏览器、视觉、无障碍、性能和生产遥测等层面验证 Canvas 编辑器。

课程进度 课程大纲 已发布 18/18 课

从五岁孩子也能理解的游戏开始

本章唯一需要牢记的事实:一张看起来正确的 Canvas 截图,只能证明那一刻的像素相似。模型、交互、视觉效果、无障碍能力和生产环境行为,都必须分别得到验证。

拿出一辆漂亮的玩具消防车。触碰它之前,先预测五件事:车轮能转吗?从桌上掉下来会不会散架?闭着眼能找到刹车吗?不用拖拽、只用一只手能打开车门吗?如果老师离开后它坏了,你能知道它何时坏的吗?鲜红的车身无法回答其中任何一个问题。

现在建立五个检查站。第一站拆开消防车,检查齿轮规则;第二站按固定顺序按下按钮;第三站在不同桌面和不同光照下驾驶;第四站戴上眼罩,只用键盘替代遥控器;第五站安装一个黑匣子,它只记录“发生了哪一类故障,以及持续了多久”。每一站会产生不同的故障证据,也需要不同的修复方式。

  1. 部件规则无论怎样转动,规则都成立
  2. 按钮顺序取消绝不会误提交
  3. 真实桌面浏览器和像素
  4. 不用鼠标任务仍能完成
  5. 使用中测量温度绝不窥探私密内容
先做预测:如果每张视觉快照都通过,但按下 Escape 仍然错误提交移动,产品正确吗?不正确。它只通过了一个检查站。

测试金字塔并不意味着越高的层次就“越高级”。底层规则运行得快,可以探索大量情况。浏览器测试验证真实输入和平台行为,视觉测试验证外观,性能回归测试验证预算。无障碍能力不是最后贴上的标签;它是进入同一份 Document 的另一条路径,必须可以获得焦点、可以描述,也能用键盘操作。

把玩具对应到 Canvas

玩具消防车检查Canvas Lab 证据它回答的问题
齿轮尺寸量规Geometry / Model Unit Test数学和不变量成立吗?
随机转动车轮Property-Based Test在许多有效输入中,属性仍然成立吗?
按钮动作脚本Event Replay / State Assertion执行了哪个 Transition?
可控秒表Deterministic Clock双击、长按和动画不依赖真实时间吗?
在真实桌面试驾Browser Interaction TestPointer、IME 和 Clipboard 真的能工作吗?
从相同角度比较照片Visual RegressionDPR、字体或绘制是否意外改变?
戴眼罩触摸凸起标记Fallback Semantics / Focus MappingScreen Reader 能理解并定位对象吗?
用方向键遥控Keyboard Move / Resize不用 Drag 能完成任务吗?
更大的按钮Target Size / Non-Drag Alternative低精度输入仍能工作吗?
黑匣子Privacy-Safe Telemetry生产环境在哪里失败,影响有多大?

这个类比也有边界:真实辅助技术并不像“戴着眼罩使用”那么简单。用户可能在视觉、运动、认知方面存在差异,也可能同时存在多种差异。单一体验无法代表 Screen Reader、键盘、语音和高对比度需求。黑匣子同样不能记录画布文字、截图、精确轨迹或素材 URL。Correlation ID 只用于连接同一条故障链,不能拿来追踪个人。

先消灭错误直觉

  • “我手动点过一次,所以测试完成了。” 人无法稳定重放 Pointer Cancel、竞态、DPR 和 IME 组合输入。手动探索很重要,却不能替代可重复的证据。
  • “100% 覆盖率就代表正确。” 执行过一行代码,不能证明 Matrix Round Trip、Command Inversion 或 Migration 属性成立。
  • “截图匹配就说明交互正确。” 即使 History 损坏、Focus 损坏,或动作不可逆,最终像素仍可能完全相同。
  • “Canvas 无法无障碍,所以一个 aria-label 就够了。” 一个覆盖整个黑匣子的标签,无法让用户选中 shape-7、理解它的连接、移动它或调整大小。
  • “支持键盘就是用许多次 Pointer Move 模拟 Drag。” 键盘应发出相同的领域 Command,而不是伪造浏览器事件。这样 History、权限和审计才能保持一致。
  • “Telemetry 越详细,调试就越容易。” 原始文字、图像、完整坐标序列和用户身份都会泄露业务内容。只收集类别、持续时间、计数、粗粒度环境数据和随机 correlation ID。
  • “测试只需要在 Chromium 中运行。” Clipboard、IME、字体、触摸和图形路径在不同浏览器之间存在差异。应根据真实用户和风险选择 Browser Matrix。

生产级背包

前置条件

第 8 章的 GeometryKernel 不能依赖 Canvas Context。第 9 章的 ToolMachine 必须接受 Fake Input Stream。第 13 章的 Command、History、Migration 和 Persistence 需要纯函数边界。如果删除 Renderer 后这些模块就无法导入,说明架构把事实粘在了像素上。添加测试前,应先分离这条边界。

正式知识

单元测试和属性测试。 Matrix Round Trip 检查 inverse(transform(p)) ≈ p。Geometry Invariants 检查 Bounds 包含其几何图形、距离非负,以及相交关系对称。Transformed Bounds 覆盖旋转、负缩放和 epsilon。Command Inversion 必须满足 apply(invert(apply(doc, c))) = doc,同时忽略合理的版本元数据。Migration 测试覆盖夹具、幂等性和损坏隔离。Serialization 测试稳定字段,而不是偶然的对象属性顺序。Property-Based Testing 会生成大量有限数、有效 Document 和事件序列,再把一次失败收缩成最小反例。

状态与事件测试。 Event Replay 把第 7 章中的归一化事件数组发送给 ToolMachine。State Transition Assertions 同时验证 {from,event,to,effect},而不是只检查最终像素。Pointer Cancel、Lost Capture、Blur、被远程删除的对象和 Undo 请求会形成 Race Sequences。测试手动推进 Deterministic Clock,避免不稳定的 setTimeout(300) 失败。Fake Input Stream 不构造 DOM Events,因此可以在 Node 中运行。

浏览器测试。 Playwright 或等效工具验证真实的 Pointer、Keyboard、Clipboard 权限、IME Composition、Drag/Drop 和 Export 下载。Visual Regression 固定浏览器版本、字体、viewport、DPR、配色方案和动画时钟。每个差异阈值都必须可以解释;不要每逢测试失败就更新基准。Browser Matrix 应依据用户数据,至少覆盖 Chromium、Firefox 和 WebKit;无法可靠模拟的路径还应使用真实触摸与手写笔设备。

无障碍能力。 在 Canvas 外构建 DOM Inspector,作为 Alternate Interface。每个可操作 Shape 都有语义 role、名称、关系描述和稳定的 data-shape-id。Focus Mapping 双向同步 Canvas 选择与 DOM 行;远程更改不能抢走焦点。Keyboard Selection 支持 Tab、方向键和多选。Keyboard Move/Resize 发出与 Pointer 输入相同的 Commands。用数值输入或按钮提供 Non-Drag Alternative。把可点击 Handle 的视觉尺寸与交互 Target Size 分开。Screen-Reader Description 应解释类型、名称、位置、连接和状态,而不是朗读像素。Reduced Motion 会停止非必要插值。High Contrast 不能只用颜色表示 Selection。

可观测性。 捕获 Uncaught Error、未处理的 rejection、Render Time、Slow Interaction 和 Save/Migration/Asset Failure,并同时记录 Object Count、Visible Count、Zoom bucket、Input Device、Browser Version 和 Correlation ID。Long Task 条目与自定义性能标记,把一次交互分为 input、model、geometry、render 和 present 阶段。Privacy-Safe Telemetry 禁止 Shape 文字、截图、完整 URL、用户名和精确轨迹。采样率、保留期限和退出机制需要产品策略。

完整 Test Pyramid 按反馈速度排序:Geometry/Model Unit → State Machine → Engine Integration → Browser Interaction → Visual Regression → Performance Regression。它不是只靠不断向上叠层建成的。每个生产故障都应在能够尽早、可靠复现它的最低层获得一项回归测试。

证据与兼容性(验证于 2026-08-29)

关于 Canvas 后备内容和无障碍注意事项,请遵循 WHATWG Canvas element。键盘操作和可见焦点请参见 WCAG 2.2 Keyboard 与 Focus Visible;目标尺寸请参见 WCAG 2.2 Target Size (Minimum)。Reduced Motion 使用 prefers-reduced-motion。运行时指标可以通过 PerformanceObserver 获取,但要先对每种条目类型做特性检测。自动化无障碍扫描不能替代 Screen Reader 和任务级手动测试。

本章工程增量

起点: 第 13 章已经拥有可恢复的 Document,但验证仍只是开发者拖动几个对象并查看截图。终点: 建立六层自动化证据和一个 DOM Inspector;用户不用鼠标也能选择、移动、调整大小和删除;生产故障带有保护隐私的阶段指标。

添加以下文件和接口:

  • src/engine/testing/FakeInputStream.ts 和 ManualClock.ts:确定性事件;
  • src/engine/__tests__/properties.test.ts:matrix、geometry、Command 和 Migration 属性;
  • src/ui/CanvasAccessibility.ts:DOM Inspector 和 Focus Mapping;
  • tests/browser/editor-a11y.spec.ts:Keyboard、Clipboard、IME 和 DPR;
  • tests/visual/canvas.spec.ts:固定环境中的截图;
  • src/observability/telemetry.ts:阶段标记、错误类别和脱敏。

下面的 HTML/TypeScript 为 Canvas 提供了可操作的替代接口。它不会把每个像素都转换成 DOM,而是把 Document 中的业务对象投影为列表。按钮直接发出领域 Commands,因此 Pointer 和 Keyboard 共享 History 与权限。请注意,构建工具会编译 TypeScript,并将其作为外部模块加载;不能把 type Shape = ... 直接放进浏览器原生 <script>:

<main class="canvas-shell">
  <canvas id="canvas" tabindex="0" aria-label="Workflow canvas" aria-describedby="canvas-help"></canvas>
  <p id="canvas-help">Press Tab to enter the object list. Use arrow keys to move, Shift to move faster, +/- to resize, and Delete to remove.</p>
  <section aria-labelledby="objects-title">
    <h2 id="objects-title">Canvas objects</h2>
    <ul id="object-list"></ul>
    <p id="canvas-status" role="status" aria-live="polite"></p>
  </section>
</main>
<script type="module" src="/src/ui/CanvasAccessibility.ts"></script>
type Shape = {
  id: string;
  type: 'rect' | 'text';
  name: string;
  x: number;
  y: number;
  w: number;
  h: number;
};
type Command =
  | { type: 'MoveShape'; id: string; dx: number; dy: number }
  | { type: 'ResizeShape'; id: string; dw: number; dh: number }
  | { type: 'DeleteShape'; id: string };

const shapes: Shape[] = [
  { id: 'shape-start', type: 'rect', name: 'Start', x: 20, y: 40, w: 120, h: 64 },
  { id: 'shape-review', type: 'text', name: 'Manual review', x: 220, y: 40, w: 160, h: 64 },
];
const selected = new Set<string>(),
  undoStack: Shape[][] = [];
const list = document.querySelector<HTMLUListElement>('#object-list')!;
const status = document.querySelector<HTMLElement>('#canvas-status')!;
const canvas = document.querySelector<HTMLCanvasElement>('#canvas')!;

function execute(command: Command) {
  const i = shapes.findIndex((s) => s.id === command.id);
  if (i < 0) return;
  const before = structuredClone(shapes);
  if (command.type === 'DeleteShape') {
    shapes.splice(i, 1);
    selected.delete(command.id);
  } else if (command.type === 'MoveShape')
    shapes[i] = { ...shapes[i], x: shapes[i].x + command.dx, y: shapes[i].y + command.dy };
  else
    shapes[i] = {
      ...shapes[i],
      w: Math.max(24, shapes[i].w + command.dw),
      h: Math.max(24, shapes[i].h + command.dh),
    };
  undoStack.push(before);
  status.textContent = `${command.type} completed; you can undo it`;
  renderInspector(command.type === 'DeleteShape' ? undefined : command.id);
  drawCanvas();
}
function undo() {
  const before = undoStack.pop();
  if (!before) return;
  const focusedId = (document.activeElement as HTMLElement | null)?.dataset.shapeId;
  shapes.splice(0, shapes.length, ...before);
  status.textContent = 'Undid the previous action';
  renderInspector(focusedId);
  drawCanvas();
}
function description(s: Shape) {
  return `${s.type === 'text' ? 'Text' : 'Rectangle'} “${s.name}”, position ${s.x}, ${s.y}, size ${s.w} × ${s.h}`;
}
function renderInspector(preferredFocusId?: string) {
  const focusedId =
    preferredFocusId ?? (document.activeElement as HTMLElement | null)?.dataset.shapeId;
  list.replaceChildren(
    ...shapes.map((s) => {
      const li = document.createElement('li'),
        button = document.createElement('button');
      button.type = 'button';
      button.dataset.shapeId = s.id;
      button.textContent = s.name;
      button.setAttribute('aria-pressed', String(selected.has(s.id)));
      button.setAttribute('aria-label', description(s));
      button.onclick = () => {
        selected.clear();
        selected.add(s.id);
        renderInspector(s.id);
        drawCanvas();
      };
      button.onkeydown = (event) => {
        const step = event.shiftKey ? 10 : 1;
        const delta: Record<string, [number, number] | undefined> = {
          ArrowLeft: [-step, 0],
          ArrowRight: [step, 0],
          ArrowUp: [0, -step],
          ArrowDown: [0, step],
        };
        if (delta[event.key]) {
          event.preventDefault();
          const [dx, dy] = delta[event.key]!;
          execute({ type: 'MoveShape', id: s.id, dx, dy });
        } else if (event.key === 'Delete') {
          event.preventDefault();
          execute({ type: 'DeleteShape', id: s.id });
        } else if (event.key === '+' || event.key === '=') {
          event.preventDefault();
          execute({ type: 'ResizeShape', id: s.id, dw: step, dh: step });
        } else if (event.key === '-') {
          event.preventDefault();
          execute({ type: 'ResizeShape', id: s.id, dw: -step, dh: -step });
        }
      };
      li.append(button);
      return li;
    }),
  );
  if (focusedId)
    list.querySelector<HTMLElement>(`[data-shape-id="${CSS.escape(focusedId)}"]`)?.focus();
}
function drawCanvas() {
  const ctx = canvas.getContext('2d')!;
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  for (const s of shapes) {
    ctx.strokeStyle = selected.has(s.id) ? '#2563eb' : '#334155';
    ctx.strokeRect(s.x, s.y, s.w, s.h);
    ctx.fillText(s.name, s.x + 8, s.y + 24);
  }
}
addEventListener('keydown', (event) => {
  if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'z') {
    event.preventDefault();
    undo();
  }
});
matchMedia('(prefers-reduced-motion: reduce)').addEventListener('change', drawCanvas);
renderInspector();
drawCanvas();

回到玩具比喻:DOM 列表就是消防车上那组可触摸的凸点和有名称的按钮,而不是另一辆消防车。它操作相同的 shapes,因此视力用户和 Screen Reader 用户不会进入两套逐渐分叉的业务逻辑。

属性测试与浏览器测试分别验证数学和任务:

import fc from 'fast-check';
import { expect, test } from 'vitest';

test('every invertible translation/rotation/scale in the pure math kernel round-trips', () => {
  fc.assert(
    fc.property(
      fc.record({
        x: fc.double({ noNaN: true, min: -1e6, max: 1e6 }),
        y: fc.double({ noNaN: true, min: -1e6, max: 1e6 }),
      }),
      fc.double({ noNaN: true, min: 0.01, max: 100 }),
      fc.double({ noNaN: true, min: -Math.PI, max: Math.PI }),
      (point, scale, angle) => {
        const c = Math.cos(angle),
          s = Math.sin(angle);
        const m = { a: c * scale, b: s * scale, c: -s * scale, d: c * scale, e: 37, f: -19 };
        const apply = (p: { x: number; y: number }, n: typeof m) => ({
          x: n.a * p.x + n.c * p.y + n.e,
          y: n.b * p.x + n.d * p.y + n.f,
        });
        const det = m.a * m.d - m.b * m.c;
        const inverse = {
          a: m.d / det,
          b: -m.b / det,
          c: -m.c / det,
          d: m.a / det,
          e: (m.c * m.f - m.d * m.e) / det,
          f: (m.b * m.e - m.a * m.f) / det,
        };
        const screen = apply(point, m),
          roundTrip = apply(screen, inverse);
        expect(Math.abs(roundTrip.x - point.x)).toBeLessThan(1e-6);
        expect(Math.abs(roundTrip.y - point.y)).toBeLessThan(1e-6);
      },
    ),
    { numRuns: 2_000 },
  );
});
import { expect, test } from '@playwright/test';

test('selects and moves an object with keyboard only while preserving focus and History', async ({ page }) => {
  await page.goto('/lab');
  const start = page.getByRole('button', { name: /Rectangle “Start”/ });
  await start.focus();
  await page.keyboard.press('ArrowRight');
  await page.keyboard.press('Shift+ArrowDown');
  await expect(start).toHaveAttribute('aria-label', /position 21, 50/);
  await page.keyboard.press('ControlOrMeta+z');
  await expect(start).toHaveAttribute('aria-label', /position 21, 40/);
  await page.keyboard.press('ControlOrMeta+z');
  await expect(start).toHaveAttribute('aria-label', /position 20, 40/);
  await expect(start).toBeFocused();
});

Telemetry 也必须通过运行时白名单。TypeScript 类型无法阻止 as any、旧客户端或第三方 SDK。下面的序列化器只接受类别、持续时间、计数和粗粒度环境数据,并拒绝未知字段。这样可以防止 shapeText、Screenshot、URL、坐标和用户 ID 为了“方便”而混入:

const allowed = new Set([
  'name',
  'correlationId',
  'durationMs',
  'objectCount',
  'visibleCount',
  'zoomBucket',
  'inputDevice',
  'browserFamily',
  'browserMajor',
]);
const eventNames = new Set([
  'uncaught_error',
  'slow_interaction',
  'save_failure',
  'migration_failure',
  'asset_failure',
]);

export function serializeTelemetry(raw: unknown) {
  if (!raw || typeof raw !== 'object') throw new Error('INVALID_TELEMETRY');
  const event = raw as Record<string, unknown>;
  for (const key of Object.keys(event))
    if (!allowed.has(key)) throw new Error(`SENSITIVE_OR_UNKNOWN_FIELD:${key}`);
  if (!eventNames.has(String(event.name)) || !/^[a-f0-9-]{36}$/.test(String(event.correlationId)))
    throw new Error('INVALID_TELEMETRY');
  for (const key of ['durationMs', 'objectCount', 'visibleCount', 'browserMajor']) {
    const value = event[key];
    if (value !== undefined && (typeof value !== 'number' || !Number.isFinite(value) || value < 0))
      throw new Error('INVALID_TELEMETRY_NUMBER');
  }
  return JSON.stringify(event);
}

correlationId 是为每条故障链生成的随机短期 ID。它绝不能从账户、文档或 Shape ID 派生。模式测试必须验证 { ...safe, shapeText: 'confidential' }、screenshot、url、points 和 userId 全部遭到拒绝。服务器必须再次强制实施同一白名单,并应用采样、保留、访问控制和删除策略。

运行 npm exec vitest run src/engine。预期全部 2,000 个 Matrix/Geometry 属性样本、Command Inversion、Migration 和 Serialization 测试通过。运行 npm exec playwright test --project=chromium --project=firefox --project=webkit。预期 Pointer、Keyboard、拒绝 Clipboard 访问、IME、Drag/Drop、Export 和无障碍任务全部通过。运行 npm run test:visual 时,预期基准差异为零;任何更新基准的 PR 都必须说明视觉原因。npm run benchmark 应报告 p50/p95/p99,并与预算比较。

故意破坏它

注入的故障症状证据修复回归测试恢复
拔掉鼠标无法选择或移动键盘任务停滞DOM Inspector + 同源 Command纯键盘 Playwright保留 Focus 和 Selection
浏览器缩放到 200%Handle/Overlay 错位截图 + 边界框统一 Screen/CSS 坐标100/200/400% 项目重新计算 overlay
Reduced MotionCamera 仍会飞行动画媒体查询和时钟轨迹把非必要动画时长设为 0模拟测试直接跳到最终状态
字体缺失换行与 Selection 偏移字体状态 + 视觉差异显式回退;字体就绪后重排阻断字体路由确定性回退
改变测试 DPR像素模糊或快照漂移backing size 报告感知 DPR 的 resizeDPR 1/2/3 截图重建位图
Clipboard 权限被拒未处理的 Promiseclipboard_denied telemetry提供文字/按钮回退权限拒绝测试保持 Document 不变
Migration Payload 损坏空白页面migration_failure + 版本隔离并打开恢复 UI损坏夹具读取旧的已提交状态
Screen Reader 只看到 Canvas只播报“canvas”无障碍树可聚焦对象列表和描述VoiceOver/NVDA 任务Focus Mapping

用证据通过

自动化证据手动证据通过条件
Unit、Property、Replay、Browser、Visual 和 Performance CIVoiceOver/NVDA 键盘任务、200% Zoom、High Contrast 和触摸目标检查每个自动化层都通过;不用鼠标即可完成 Select/Move/Resize/Delete 手动任务
Telemetry Schema 测试和敏感字段拒绝测试在测试环境检查一条真实 Correlation 链可以定位阶段和故障类别,且不含文字、截图、精确路径或身份
层次通过证据无法替代它的证据
Model/Geometry属性不变量和最小反例一张截图
State MachineReplay 中的每个 Transition最终 Shape 位置
Browser真实 Pointer/IME/Clipboard/Drag使用手写 DOM Events 的单元测试
Visual固定环境中的像素差异“我看着没问题”
Accessibility完整的键盘和 Screen Reader 任务自动化扫描零错误
Production分阶段 p95、分类错误和相关故障链开发机器控制台
  • 删除 Renderer 后,Geometry、Document、History、Migration、State Machine 和 Commands 测试仍能导入并运行。
  • Pointer Cancel、Race Sequence、Fake Input Stream 和 Deterministic Clock 都有测试。
  • Keyboard 可以 Select、Move、Resize 和 Delete,并且提供 Non-Drag Alternative。
  • 重绘或远程更新时,Focus 不会无故丢失;Selection 与 DOM Inspector 保持同步。
  • Reduced Motion、High Contrast、200% Zoom 和合理的 Target Size 能通过手动任务。
  • Telemetry 不包含 Shape 文字、截图、完整 URL、精确轨迹或身份信息。

向五岁孩子解释

不要使用“单元测试”“无障碍”或“Telemetry”这些词。解释为什么消防车的一张漂亮照片,不能证明车轮和刹车能正常工作,也不能证明看不见的人也能使用它。为什么任何一个检查站都不能被移除?如果消防车在真实学校里坏了,它怎样才能留下有用线索,却不读取孩子的秘密?

一个不使用术语的好答案

照片只能告诉我们那一刻外壳的样子。我们必须分别测量各个部件,按下每种按钮,并在真实桌面上试驾。还要保证不会用鼠标或看不见屏幕的人,仍然能找到有名字的东西并执行相同动作。如果消防车在学校变慢或损坏,只记录“哪个部件花了多长时间、发生了哪类故障,以及哪些记录属于同一事故”——绝不能记录孩子画了什么或写了什么。这样,每一种“真正正确”都有属于自己的证据。