JEPA4Japan · チュートリアル

第9章:ツールは信号機であり、Booleanの袋ではない

5,461文字 16分で読めます #Canvas#Frontend Engineering#Infinite Canvas#ELI5

明示的なState、Guard、Cancel、Commit、CommandでSelect、Hand、Rectangle、Freehand、Eraserを構成します。

コース進捗 コース目次 18レッスン中 18件を公開中

第I部:描く前に描画面を選ぶ——プロダクト、ピクセル、座標

  1. 01 第1章:まだ描かない——Canvasはプロダクト設計ではない 公開中
  2. 02 第2章:すぐに記憶を失うピクセルの紙 公開中
  3. 03 第3章:お絵描きを再現可能なレシピにする 公開中
  4. 04 第4章:4枚の地図と1台のカメラ 公開中

第II部:ピクセル世界に頭脳を与える——モデル、スケジューリング、入力、ツール

  1. 05 第5章:ピクセル世界に台帳を作る 公開中
  2. 06 第6章:ランプが点いたときだけ描き直す——Render SchedulerとReactの境界 公開中
  3. 07 第7章:マウス、指、ペンに同じ言葉を話してもらう 公開中
  4. 08 第8章:細い縁を調べる前に大きな箱を探す 公開中
  5. 09 第9章:ツールは信号機であり、Booleanの袋ではない 現在のレッスン

第III部:「ドラッグできる」から「信頼できる」へ——操作、文字、Asset、復旧

  1. 10 第10章:触って気持ちよいエディターにする 公開中
  2. 11 第11章:描かれた文字は編集できる文字ではない 公開中
  3. 12 第12章:借りた画像を勝手に箱へ詰めてはいけない 公開中
  4. 13 第13章:タイムマシンと古い箱 公開中
  5. 14 第14章:正しく見えることと、本当に正しいことは違う 公開中

第IV部:マスターの判断——Performance、Worker、GPU、SDK、共同編集、AI

  1. 15 第15章:1万人を一人ずつ探さない 公開中
  2. 16 第16章:受付を厨房へ入れない——WorkerとGPUへの更新 公開中
  3. 17 第17章:車を自作するか、実績あるシャーシを買うか 公開中
  4. 18 第18章:人とAIが同じ台帳を編集する 公開中

まずは5歳児にもわかるゲームから

この章で覚える真実は一つだけです。インタラクションは状態遷移であって、ばらばらのイベントコールバックではありません。

段ボールで信号機を作り、おもちゃの車を用意します。青信号なら進め、赤信号なら止まり、黄信号ならすでに始めた横断だけを終えられます。信号を切り替えるたび、子どもは前のルールカードを片付けてから次のカードを出します。

先に予想してみてください。「走っている」「走るかもしれない」「止まりかけている」「曲がっている」という4枚の付箋を同時に貼ったら、先生に「いま Escape を押すとどうなるの」と聞かれて一つの答えを返せるでしょうか。青信号が途中で抜かれたら、車の位置を街の地図に記録するべきでしょうか、それとも出発線へ戻すべきでしょうか。

  1. 点灯する状態は一つだけいま適用する規則は一つ
  2. メモを一枚受け取る押す、動かす、キャンセル
  3. 名前のある道を進むGuard が通行可能か決める
  4. ゴールでだけ記録するCommit または Cancel
まず判断してください。車が試走している最中に、地図へ何十個もの途中位置を書いてよいでしょうか。いけません。それはテーブル上の一時的な配置にすぎません。

信号機は飾りではありません。入力メモの正当な受け手を常に一つにします。Idle → Pointing → Dragging → Idle は名前のある道です。移動量が閾値を超えるまで Dragging には入りません。Escape、Pointer Cancel、Tool の切り替えはすべてキャンセル経路を通って戻ります。Undo できる Command を生成するのは Commit だけです。

おもちゃを Canvas に置き換える

おもちゃの交通ゲームCanvas Lab明確な意味
現在点灯している信号Interaction StateIdle、Pointing、Dragging、Resizing、Rotating、Editing
車の種類ToolSelect、Hand、Rectangle、Freehand、Eraser
届いたメモNormalized Input Event第7章の統一イベントで、ブラウザ分岐を含まない
名前のある道Transition(state,event) → next state + effects
交差点の門番Guard距離、権限、オブジェクト存在などの条件
信号変更前にカードをしまうExit actionCapture 所有権を解放し Preview を消す
信号変更後にカードを出すEntry actionPreview を作り Cursor を設定
テーブル上の試し置きTransient PreviewDocument にも History にも属さない
街の地図へ記録Commit/Command一つの原子的で取り消し可能な transaction
出発線へ戻るCancelPreview を破棄し Command は生成しない

この比喩には限界があります。状態機械がプロダクトルールを代わりに決めるわけではなく、赤・黄・青だけでテキスト編集内のサブ状態を表すこともできません。複雑なツールには Hierarchical State Machine を使えます。select.transforming.resizing は transforming のキャンセルルールを共有しつつ、アクティブな葉状態を一つに保ちます。状態機械はアプリケーション状態を巨大な enum 一つに詰め込むことでもありません。Document、Session、Presence は別々のデータのままです。

まず誤った直感を捨てる

  • 「boolean をいくつか使うほうが簡単だ。」 5個の boolean には32通りの組み合わせがあり、その大半に意味がありません。isDragging && isResizing の Move をどちらが処理するか証明できません。
  • 「Pointer コールバック内で Shape を直接変えるのが一番なめらかだ。」 1回の Drag で何百件もの History が生まれ、Cancel で元に戻せず、共同編集者には未完成物が見えます。
  • 「各 Tool が自分で DOM を監視すればよい。」 入力の所有権が曖昧になり、Tool 切り替え後も古い listener がコミットしかねません。
  • 「pointerup がなければ次のクリックでリセットする。」 次のクリックが古い transaction を誤ってコミットする可能性があります。Cancel、Lost Capture、Blur は第一級イベントです。
  • 「一時 Drag 中でも Undo は普通に History を戻せる。」 基準 Document が変わる一方で Preview が古いオブジェクトを参照します。先に Cancel するか、明示的な入れ子 transaction 方針が必要です。
  • 「状態遷移の中で次イベントを再帰的に dispatch する。」 Reentrancy により Exit が終わる前に再遷移します。Engine は直列キューで処理するべきです。

Production backpack

前提となる契約

第7章が発行するのは PointerStarted/Moved/Ended/Canceled、Wheel、Key、Gesture だけです。第8章は安定した HitResult を返します。Document は Command だけを受け取り、Preview Store は一時的な派生幾何だけを保持します。すべての Tool は React、DOM、Renderer なしの Node テストで動作します。

正式な知識

Finite-State Machine は有限個の状態、イベント、明示的な Transition を持ちます。Hierarchical State Machine を使えば、複数のサブ状態で Entry、Exit、Cancel を共有でき、コードを複製せずに済みます。Tool は長期的に選択される操作モードを表し、Interaction State はその Tool がいま何をしているかを表します。両者を tool='select-dragging-rotating' のような文字列へ混ぜてはいけません。

Select Tool の基本経路は Idle → Pointing → Dragging → Commit です。resize handle に命中すれば Resizing、rotation handle なら Rotating、Text のダブルクリックなら Editing に入ります。Pointing は開始点と HitResult を保存します。画面距離が Drag Threshold を超えたと Guard が判断して初めて Dragging へ移ります。Hand Tool は移動を Camera Session に書き、Document History には入れません。Pan は隣接する Screen Point の delta を使います。Camera を変更するたびに World Point から delta を求め直すとフィードバックドリフトが生じます。Rectangle Tool はアンカーから長方形 Preview を作ります。Freehand Tool は第7章の coalesced points を集め、後続章でリサンプリングします。Eraser Tool は命中した Stable ID を集めますが、Commit まで Batch Delete を作りません。

Transition はイベント、現在 state、読み取り専用 context で決まります。Entry は Preview または Transaction token を作り、Exit は確実に後始末します。Escape、Pointer Cancel、Tool Switch、共同編集中のオブジェクト削除には明示的な Transition が必要です。Guard は現在の pointerId が操作を所有するか、権限があるか、Shape がまだ存在するか、閾値を超えたか、行列を逆変換できるかを確認します。

Transient Preview が描くのは「いま離したらどうなるか」だけです。シリアライズも同期も Undo への追加も行いません。Commit は Preview と基準値の差を一つの Command にし、Cancel は破棄します。Command Boundary はドメイン書き込みの唯一の入口です。連続 Drag は一つの Transaction にまとめます。複数 Shape にまたがる操作は一つの Batch としてコミットし、Undo を1ステップにします。

Reentrancy は Transition の実行中に別イベントを dispatch することです。Engine は FIFO キューと processing ラッチでイベントを直列処理します。effect が新イベントをキューへ入れることはできますが、現在 state を再帰的に変えてはいけません。各遷移について {tool,from,event,to,transactionId} を記録します。本番ログには機密テキストや完全な座標軌跡を記録しません。

根拠と互換性(確認日:2026-08-29)

状態機械そのものはブラウザに依存しません。ブラウザが操作を終了させる原因は MDN pointercancel、lostpointercapture、Page Visibility API に従います。キーボードの repeat と KeyboardEvent.code には明示的な方針が必要です。Escape のリピートは無視でき、矢印キーによる連続移動は受け入れられます。DOM イベントを状態型の一部にすると Node Replay の可搬性を失います。

この章で積み上げる実装

開始地点: Pointer コールバック群と isDragging/isResizing/isDrawing/isMaybeDragging/isAboutToResize。完了地点: 各イベントについて、どの Tool、どの State、どの Transition が処理するか答えられ、書き込みは Commit Command でだけ発生します。

次のファイルを追加します。

  • src/engine/tools/ToolMachine.ts:直列 dispatch、Entry/Exit、trace。
  • src/engine/tools/states.ts:暗黙の組み合わせを禁止する判別 union。
  • src/engine/tools/{Select,Hand,Rectangle,Freehand,Eraser}Tool.ts:各ツールの遷移表。
  • src/engine/preview/PreviewStore.ts:一時状態。
  • src/engine/commands/CommandBus.ts:原子的コミット。
  • src/engine/tools/__tests__/replay.test.ts:イベントシーケンスと遷移 assertion。

まず、同時には真になれない状態と effect のプロトコルを定義します。

type Point = Readonly<{ x: number; y: number }>;
type ToolId = 'select' | 'hand' | 'rectangle' | 'freehand' | 'eraser';

export type ToolState =
  | Readonly<{ kind: 'idle'; tool: ToolId }>
  | Readonly<{
      kind: 'pointing';
      tool: 'select';
      pointerId: number;
      originScreen: Point;
      originWorld: Point;
      hitId: string | null;
      transactionId: string;
    }>
  | Readonly<{
      kind: 'dragging';
      tool: 'select';
      pointerId: number;
      originScreen: Point;
      originWorld: Point;
      current: Point;
      hitId: string;
      transactionId: string;
    }>
  | Readonly<{
      kind: 'resizing';
      tool: 'select';
      pointerId: number;
      shapeIds: readonly string[];
      handle: string;
      origin: Point;
      current: Point;
      transactionId: string;
    }>
  | Readonly<{
      kind: 'rotating';
      tool: 'select';
      pointerId: number;
      shapeIds: readonly string[];
      pivot: Point;
      current: Point;
      transactionId: string;
    }>
  | Readonly<{ kind: 'editing'; tool: 'select'; shapeId: string; transactionId: string }>
  | Readonly<{
      kind: 'panning';
      tool: 'hand';
      pointerId: number;
      originScreen: Point;
      currentScreen: Point;
    }>
  | Readonly<{
      kind: 'drawing-rectangle';
      tool: 'rectangle';
      pointerId: number;
      anchor: Point;
      current: Point;
      transactionId: string;
    }>
  | Readonly<{
      kind: 'drawing-freehand';
      tool: 'freehand';
      pointerId: number;
      points: readonly Point[];
      transactionId: string;
    }>
  | Readonly<{
      kind: 'erasing';
      tool: 'eraser';
      pointerId: number;
      ids: ReadonlySet<string>;
      transactionId: string;
    }>;

export type ToolEvent =
  | Readonly<{ kind: 'down'; pointerId: number; world: Point; screen: Point; hitId: string | null }>
  | Readonly<{ kind: 'move'; pointerId: number; world: Point; screen: Point }>
  | Readonly<{ kind: 'up'; pointerId: number; world: Point }>
  | Readonly<{ kind: 'cancel'; pointerId?: number; reason: string }>
  | Readonly<{ kind: 'switch'; tool: ToolId }>
  | Readonly<{ kind: 'escape' }>
  | Readonly<{ kind: 'object-deleted'; id: string }>
  | Readonly<{ kind: 'undo-requested' }>;

export type Effect =
  | Readonly<{ kind: 'preview-move'; id: string; from: Point; to: Point }>
  | Readonly<{ kind: 'preview-rectangle'; anchor: Point; current: Point }>
  | Readonly<{ kind: 'preview-freehand'; points: readonly Point[] }>
  | Readonly<{ kind: 'preview-erase'; ids: ReadonlySet<string> }>
  | Readonly<{ kind: 'pan-camera'; from: Point; to: Point }>
  | Readonly<{ kind: 'commit'; transactionId: string; command: Command }>
  | Readonly<{ kind: 'cancel-preview'; transactionId?: string }>;

export type Command =
  | Readonly<{ type: 'MoveShape'; id: string; from: Point; to: Point }>
  | Readonly<{ type: 'CreateRectangle'; anchor: Point; opposite: Point }>
  | Readonly<{ type: 'CreateFreehand'; points: readonly Point[] }>
  | Readonly<{ type: 'DeleteShapes'; ids: readonly string[] }>;

以下の reducer は、この章の5ツールについて基本経路、キャンセル、切り替え、Undo 境界を完全に扱います。hitAt は GeometryKernel から受け取ります。

type Result = Readonly<{ state: ToolState; effects: readonly Effect[] }>;
type Context = Readonly<{
  dragThresholdPx: number;
  exists(id: string): boolean;
  hitAt(point: Point): string | null;
  transactionId(): string;
}>;

const idle = (tool: ToolId): ToolState => ({ kind: 'idle', tool });
const distance = (a: Point, b: Point) => Math.hypot(a.x - b.x, a.y - b.y);
const cancel = (state: ToolState, tool = state.tool): Result => ({
  state: idle(tool),
  effects:
    state.kind === 'idle' || state.kind === 'panning'
      ? []
      : [
          {
            kind: 'cancel-preview',
            transactionId: 'transactionId' in state ? state.transactionId : undefined,
          },
        ],
});

export function transition(state: ToolState, event: ToolEvent, ctx: Context): Result {
  if (event.kind === 'switch') return cancel(state, event.tool);
  if (event.kind === 'escape') return cancel(state);
  if (event.kind === 'cancel') {
    if (
      event.pointerId !== undefined &&
      'pointerId' in state &&
      event.pointerId !== state.pointerId
    )
      return { state, effects: [] };
    return cancel(state);
  }
  if (event.kind === 'undo-requested' && state.kind !== 'idle') return cancel(state);
  if (event.kind === 'object-deleted' && 'shapeId' in state && state.shapeId === event.id)
    return cancel(state);
  if (event.kind === 'object-deleted' && 'hitId' in state && state.hitId === event.id)
    return cancel(state);
  if (event.kind === 'object-deleted' && 'shapeIds' in state && state.shapeIds.includes(event.id))
    return cancel(state);

  if (state.kind === 'idle' && event.kind === 'down') {
    if (state.tool === 'select')
      return {
        state: {
          kind: 'pointing',
          tool: 'select',
          pointerId: event.pointerId,
          originScreen: event.screen,
          originWorld: event.world,
          hitId: event.hitId,
          transactionId: ctx.transactionId(),
        },
        effects: [],
      };
    if (state.tool === 'hand')
      return {
        state: {
          kind: 'panning',
          tool: 'hand',
          pointerId: event.pointerId,
          originScreen: event.screen,
          currentScreen: event.screen,
        },
        effects: [],
      };
    if (state.tool === 'rectangle')
      return {
        state: {
          kind: 'drawing-rectangle',
          tool: 'rectangle',
          pointerId: event.pointerId,
          anchor: event.world,
          current: event.world,
          transactionId: ctx.transactionId(),
        },
        effects: [{ kind: 'preview-rectangle', anchor: event.world, current: event.world }],
      };
    if (state.tool === 'freehand')
      return {
        state: {
          kind: 'drawing-freehand',
          tool: 'freehand',
          pointerId: event.pointerId,
          points: [event.world],
          transactionId: ctx.transactionId(),
        },
        effects: [{ kind: 'preview-freehand', points: [event.world] }],
      };
    return {
      state: {
        kind: 'erasing',
        tool: 'eraser',
        pointerId: event.pointerId,
        ids: new Set(event.hitId ? [event.hitId] : []),
        transactionId: ctx.transactionId(),
      },
      effects: [],
    };
  }

  if (state.kind === 'pointing' && event.kind === 'move' && event.pointerId === state.pointerId) {
    if (
      !state.hitId ||
      !ctx.exists(state.hitId) ||
      distance(state.originScreen, event.screen) < ctx.dragThresholdPx
    )
      return { state, effects: [] };
    const next: ToolState = {
      kind: 'dragging',
      tool: 'select',
      pointerId: state.pointerId,
      originScreen: state.originScreen,
      originWorld: state.originWorld,
      current: event.world,
      hitId: state.hitId,
      transactionId: state.transactionId,
    };
    return {
      state: next,
      effects: [
        { kind: 'preview-move', id: state.hitId, from: state.originWorld, to: event.world },
      ],
    };
  }
  if (state.kind === 'dragging' && event.kind === 'move' && event.pointerId === state.pointerId) {
    return {
      state: { ...state, current: event.world },
      effects: [
        { kind: 'preview-move', id: state.hitId, from: state.originWorld, to: event.world },
      ],
    };
  }
  if (state.kind === 'dragging' && event.kind === 'up' && event.pointerId === state.pointerId) {
    return {
      state: idle('select'),
      effects: [
        {
          kind: 'commit',
          transactionId: state.transactionId,
          command: { type: 'MoveShape', id: state.hitId, from: state.originWorld, to: event.world },
        },
      ],
    };
  }
  if (state.kind === 'pointing' && event.kind === 'up' && event.pointerId === state.pointerId)
    return { state: idle('select'), effects: [] };

  if (state.kind === 'panning' && event.kind === 'move' && event.pointerId === state.pointerId)
    return {
      state: { ...state, currentScreen: event.screen },
      effects: [{ kind: 'pan-camera', from: state.currentScreen, to: event.screen }],
    };
  if (state.kind === 'panning' && event.kind === 'up' && event.pointerId === state.pointerId)
    return { state: idle('hand'), effects: [] };

  if (
    state.kind === 'drawing-rectangle' &&
    event.kind === 'move' &&
    event.pointerId === state.pointerId
  )
    return {
      state: { ...state, current: event.world },
      effects: [{ kind: 'preview-rectangle', anchor: state.anchor, current: event.world }],
    };
  if (
    state.kind === 'drawing-rectangle' &&
    event.kind === 'up' &&
    event.pointerId === state.pointerId
  )
    return {
      state: idle('rectangle'),
      effects: [
        {
          kind: 'commit',
          transactionId: state.transactionId,
          command: { type: 'CreateRectangle', anchor: state.anchor, opposite: event.world },
        },
      ],
    };

  if (
    state.kind === 'drawing-freehand' &&
    event.kind === 'move' &&
    event.pointerId === state.pointerId
  ) {
    const points = [...state.points, event.world];
    return { state: { ...state, points }, effects: [{ kind: 'preview-freehand', points }] };
  }
  if (
    state.kind === 'drawing-freehand' &&
    event.kind === 'up' &&
    event.pointerId === state.pointerId
  ) {
    const last = state.points[state.points.length - 1];
    const points =
      last && last.x === event.world.x && last.y === event.world.y
        ? state.points
        : [...state.points, event.world];
    return {
      state: idle('freehand'),
      effects: [
        {
          kind: 'commit',
          transactionId: state.transactionId,
          command: { type: 'CreateFreehand', points },
        },
      ],
    };
  }

  if (state.kind === 'erasing' && event.kind === 'move' && event.pointerId === state.pointerId) {
    const id = ctx.hitAt(event.world),
      ids = new Set(state.ids);
    if (id) ids.add(id);
    return { state: { ...state, ids }, effects: [{ kind: 'preview-erase', ids }] };
  }
  if (state.kind === 'erasing' && event.kind === 'up' && event.pointerId === state.pointerId) {
    const ids = new Set(state.ids),
      id = ctx.hitAt(event.world);
    if (id) ids.add(id);
    return {
      state: idle('eraser'),
      effects: [
        {
          kind: 'commit',
          transactionId: state.transactionId,
          command: { type: 'DeleteShapes', ids: [...ids] },
        },
      ],
    };
  }

  return { state, effects: [] };
}

export type TransitionTrace = Readonly<{
  tool: ToolId;
  from: ToolState['kind'];
  event: ToolEvent['kind'];
  to: ToolState['kind'];
  transactionId?: string;
}>;

export class ToolMachine {
  private state: ToolState = idle('select');
  private queue: ToolEvent[] = [];
  private processing = false;
  constructor(
    private readonly context: Context,
    private readonly run: (effect: Effect) => void,
    private readonly trace: (entry: TransitionTrace) => void = () => undefined,
  ) {}
  dispatch(event: ToolEvent) {
    this.queue.push(event);
    if (this.processing) return;
    this.processing = true;
    try {
      while (this.queue.length) {
        const event = this.queue.shift()!;
        const from = this.state;
        const result = transition(from, event, this.context);
        this.state = result.state;
        const transactionId =
          'transactionId' in from
            ? from.transactionId
            : 'transactionId' in result.state
              ? result.state.transactionId
              : undefined;
        this.trace({
          tool: from.tool,
          from: from.kind,
          event: event.kind,
          to: result.state.kind,
          transactionId,
        });
        for (const effect of result.effects) this.run(effect);
      }
    } finally {
      this.processing = false;
    }
  }
  snapshot() {
    return this.state;
  }
}

Resize、Rotate、Editing も同じプロトコルを使います。Entry で元の Selection Snapshot を固定し、Move は Preview だけを変え、Commit は一つの Batch を生成します。オブジェクトが削除された、または行列を逆変換できない場合は Cancel します。これらを Dragging の boolean フィールドへ戻してはいけません。

テストは実装詳細をモックするのではなく、イベントリプレイで経路を証明します。

import { describe, expect, it } from 'vitest';
import { ToolMachine } from '../ToolMachine';

describe('ToolMachine replay', () => {
  it('閾値を超えるとドラッグし、Escape は Preview だけを消して Command をコミットしない', () => {
    const effects: Array<{ kind: string }> = [];
    let sequence = 0;
    const machine = new ToolMachine(
      {
        dragThresholdPx: 4,
        exists: () => true,
        hitAt: () => 'shape-1',
        transactionId: () => `tx-${++sequence}`,
      },
      (e) => effects.push(e),
    );
    machine.dispatch({
      kind: 'down',
      pointerId: 3,
      screen: { x: 0, y: 0 },
      world: { x: 0, y: 0 },
      hitId: 'shape-1',
    });
    machine.dispatch({
      kind: 'move',
      pointerId: 3,
      screen: { x: 10, y: 0 },
      world: { x: 10, y: 0 },
    });
    machine.dispatch({ kind: 'escape' });

    expect(machine.snapshot()).toEqual({ kind: 'idle', tool: 'select' });
    expect(effects.map((e) => e.kind)).toEqual(['preview-move', 'cancel-preview']);
    expect(effects.some((e) => e.kind === 'commit')).toBe(false);
  });

  it('2本目の pointer の up/cancel は1本目の transaction を commit も cancel もしない', () => {
    const effects: Array<{ kind: string }> = [];
    const machine = new ToolMachine(
      {
        dragThresholdPx: 4,
        exists: () => true,
        hitAt: () => 'shape-1',
        transactionId: () => 'tx-owner',
      },
      (e) => effects.push(e),
    );
    machine.dispatch({
      kind: 'down',
      pointerId: 1,
      screen: { x: 0, y: 0 },
      world: { x: 0, y: 0 },
      hitId: 'shape-1',
    });
    machine.dispatch({
      kind: 'move',
      pointerId: 1,
      screen: { x: 10, y: 0 },
      world: { x: 10, y: 0 },
    });
    machine.dispatch({ kind: 'up', pointerId: 2, world: { x: 99, y: 99 } });
    machine.dispatch({ kind: 'cancel', pointerId: 2, reason: 'other pointer canceled' });

    expect(machine.snapshot().kind).toBe('dragging');
    expect(effects.some((e) => e.kind === 'commit' || e.kind === 'cancel-preview')).toBe(false);
  });
});

pnpm vitest run src/engine/tools を実行し、すべての transition replay が通り、coverage レポートで各 state の Cancel path が実行されることを確認します。pnpm playwright test tests/browser/tool-cancel.spec.ts を実行し、Escape、フォーカス喪失、Pointer Cancel、Tool 切り替えのすべてで Idle に戻り、Document revision が増えないことを確認します。

信号機へ戻りましょう。状態は付箋の山ではなく、いま点灯している一つの信号です。Preview は練習場にある車の位置で、Command は街の地図に追加する新しい記録です。どのイベントを見ても、どの信号、どの道、記録を行うかを指し示せます。

わざと壊してみる

注入する障害症状証拠修正回帰テスト復旧
Drag 中に EscapeShape が途中で止まる、またはコミットされるTransition trace と revisionPreview を Cancel し Command を発行しないdown/move/escape をリプレイ基準 snapshot から再描画
Drag 中に Tool を切り替える二つの Tool が同時反応古い state が Idle ではないSwitch は先に Exit/Cancel を通る全 Tool 間の切り替え入力所有権を消す
Pointer Cancel を送る永久に Draggingtrace にキャンセル辺がないすべての active state に CancelCancel table testtransaction token を破棄
Resize 中にオブジェクト削除null 参照またはオブジェクト復活exists guard 失敗object-deleted → cancel共同削除シーケンスSelection を更新
Preview 中に Undo を要求基準と Preview がずれるHistory revision が変化先に Cancel、次に Undo入れ子イベントシーケンス派生状態を再計算
Double Click と Drag 閾値が衝突編集と移動の両方が起きる二つの transition が発火Pointing Guard が一方を選ぶ境界時刻・距離のパラメータテキスト Overlay をキャンセル
Pointer Up を一度も送らないstate が固まるEvent Replay の終状態が Idle でないCancel/Lost Capture/Blur watchdogUp 欠落テストEngine が明示的にキャンセル

証拠をもって合格する

問い指し示すべき証拠
現在のイベントを誰が処理するかtool + state + event + transition trace
Document をいつ書くか唯一の commit effect と CommandBus ログ
Cancel はきれいかrevision 不変、Preview 空、終状態 Idle
Continuous Drag は何ステップの Undo か一つの transactionId、一つの Command
Reentrancy は起き得るかFIFO テスト内で effect が dispatch しても厳密に順序維持
状態は網羅されているかTypeScript never チェックと遷移表 coverage
検査面自動化された証拠手動の証拠
Transition が一つEvent Replay と state snapshotDevTools trace が Tool/State/Transition を示す
Cancel がコミットしないrevision、Command 数、Preview の assertionEscape、フォーカス喪失、システムキャンセルを操作
入力所有者が一つTool 切り替えと Reentrancy シーケンステスト高速で Tool を交互に選び二重反応がない
  • Select、Hand、Rectangle、Freehand、Eraser に明示的な Idle/Active/Commit/Cancel 経路がある。
  • Resizing、Rotating、Editing は boolean ではなく明示的な状態である。
  • Guard、Entry、Exit、Transient Preview、Transaction Boundary に単体テストがある。
  • Escape、Tool Switch、Pointer Cancel、オブジェクト削除、Undo、Up 喪失の結果が決定的である。
  • Tool は DOM を監視せず、Document を直接変更しない。
  • どのイベントにも「どの Tool、どの State、どの Transition」と答えられる。

5歳児に説明する

「状態機械」「トランザクション」「コマンド」「再入」という言葉を使わずに答えてください。

  1. なぜ信号機は一度に一つの規則だけをはっきり点灯させるのでしょうか。
  2. 練習場で先生に止められた車の途中位置を、なぜ街の地図へ書けないのでしょうか。
  3. 車を替える前に、なぜ前の車のルールカードを片付けるのでしょうか。
  4. 新しい問題です。車が止まりきる前に別のメモが届いたら、管理人はどうやって二枚のメモの割り込みを防ぎますか。
参考解答を見る 一度に一つの規則だけなら、どの動作にも答えは一つです。付箋を同時に何枚も貼ると矛盾する組み合わせが生まれます。練習場は試すだけなので、ゴールに着いたら地図へ一度記録し、止められたら出発線へ戻ります。車を替える前に古いカードを片付ければ、古い車が新しい指示を聞き続けません。管理人は新しいメモを列に並べ、一枚を処理してテーブルを片付けてから次を取るため、信号を切り替えている途中に割り込まれません。