# Screen Map

> iOSアプリの画面マップ（どの画面で何ができて、操作するとどうなるか）を画面単位で作る。「画面マップを作って」「この機能にアクセシビリティIDを振って」「E2Eの導線をファイルに落として」「動作確認用の画面仕様を作って」と言われたときに使う。ソースを読んで画面・操作・結果・遷移を特定し、accessibilityIdentifier を実装に振り、screen-map/screens/*.yaml に落とす。動作確認の実施そのものは sim-test-report の仕事。Use when building or extending an app's screen map / accessibility identifier spec for E2E verification.

- Skill: `tx-tem/screen-map` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add tx-tem/screen-map`
- Raw SKILL.md: https://api.skillmd.com/api/skills/tx-tem/screen-map/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: tx-TEM (https://skillmd.com/u/tx-tem)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/tx-tem/screen-map

---


# 画面マップ

**どの画面で何ができて、操作するとどうなるか**を1画面1ファイルで書き出す。動作確認のときに、探索なしで対象画面まで辿って操作できるようにするためのもの。**必要な画面から作り、まだ書いていない画面は `stub` で置く。** アプリ全体を一度に作らない。`accessibilityIdentifier` を実装に振り、画面・操作・結果・遷移を `screen-map/screens/*.yaml` に落とす。

**動作確認の実施はこのスキルの仕事ではない**（`sim-test-report`）。ここで作るのはその入力になるマップ。

## 手順

### 0. 対象と出力先を決める

- **どの画面が必要か**を確認する。PR番号、issue、画面名、機能名のどれで来てもよい。範囲が「アプリ全体」の場合は、起点から辿れる骨格（タブ、主要なpush）に絞ることを提案する
- **出力先**は対象アプリのリポジトリの `screen-map/`。無ければ `config.yaml` から作る
- **このスキルはアプリのソースを編集する。** 先に `git status` がクリーンであることを確認し、作業用のブランチを切る。ID付与は差分が散るので、既存の変更と混ぜない

### 1. そのアプリが遷移をどう表しているかを特定する

**ここを外すと以降が全部外れる。** まず起点と、遷移の語彙を1つずつ確定させる。

**起点**（`config.yaml` の `start`）:

```bash
grep -rn "@main\|WindowGroup\|rootViewController\|TabView" --include="*.swift" . | head -20
```

**遷移の語彙** — 順に当てて、ヒットしたものがそのアプリの語彙:

```bash
grep -rnc "NavigationLink\|navigationDestination\|pushViewController\|present(\|Coordinator\|Router\|enum Route" --include="*.swift" . | grep -v ":0$" | sort -t: -k2 -rn | head
```

- **1つの語彙に絞れたら、残りの画面は同じ形で機械的に拾える。** 実例を1つ丁寧に読んで型を掴んでから広げる
- 混在しているアプリは珍しくない（SwiftUI移行中など）。混在なら両方を対象にし、どの画面がどちらかを把握する
- **Coordinator/Router経由は呼び出し側に遷移先の型名が出ない。** `enum` の case と、その case を実際の画面に解決している箇所（`switch` の本体）を突き合わせる。ここが手作業になる部分
- クロージャを親に渡して親が遷移する形も同様。`onSelect:` などの渡し先を追う

特定した語彙を報告に含める。次に同じアプリで画面を足すとき、この手順を省略できる。

### 2. 画面一覧と振るID案を出してレビューを受ける

シミュレーターを触る前、ソースを編集する前に合意を取る。**複数ファイルに散るソース編集を事前合意なしに始めない。**

| 画面id | ファイル | anchor | actions | stub |
|---|---|---|---|---|
| `item_list` | `ItemListViewController.swift` 他2件 | `itemList.title` | addButton → item_new / cell → item_detail / favoriteButton（遷移なし） | |
| `settings` | `SettingsViewController.swift` | `settings.title` | itemListCell → item_list | ✓ |

- **今回書かない画面は `stub` にする。** `to` の先として必要なだけの画面は、最低限 `anchor` とそこへ入る `actions` を書いて中身は作らない。これが無いと `to` を辿るたびに次の画面を書く羽目になり、アプリ全体に引きずられる
- 画面idは `snake_case` で、**そのままファイル名になる**（`item_list` → `screens/item_list.yaml`）。**新しく振るIDの接頭辞も画面idから機械的に導く**（→ `itemList`）。lintが接頭辞を検査できるようにするため、新規分はここを崩さない。既存IDを流用する場合は接頭辞が揃わないので、**手順6でその一覧を報告する**（lintの例外になる）

### 3. 既存のIDを棚卸しする

```bash
grep -rn "accessibilityIdentifier" --include="*.swift" . | head -50
```

- **既にあるIDは変えない。** 既存のUIテストが参照している可能性があり、命名規則に合わないからと振り直すと無関係なテストが落ちる
- 命名が合わないものもそのまま使い、**その文字列を `anchor` / `tap` / `expect` の値に書く**。規則に揃えるのは別PRの話として切り出す
- 既存IDが全く無いアプリでは、この手順は空振りでよい

### 4. 画面ごとに、振ってから書く

**1画面単位で「IDを振る → そのIDでyamlを書く」を閉じる。** 全画面のyamlを先に書いてから一括でID付与すると、必ず食い違う。

振る対象は次だけ。**画面内の全ボタンではない。** 全要素に振ると数画面で100個の差分になってレビューが通らない。

| 対象 | 個数 | 用途 |
|---|---|---|
| `anchor` | 1画面に1つ | 到達判定（フロー内の `assertVisible`） |
| `actions[].tap` | 操作する要素のぶん | フローの `tapOn` |
| `actions[].expect` | 遷移しない操作のぶん | 操作の結果を確かめる観測点 |
| `states[].expect` | 表示が分岐する画面だけ | どちらの状態で着いたかの判定 |

#### anchor

**`anchor` は画面自体に振る。画面内の要素を借りない。**

```swift
public var body: some View {
    VStack { ... }
        .accessibilityIdentifier("browse")     // 画面id と同じ
}
```

借りると意味が二重になる。借りた要素を別コンポーネントに替えたり消したりすると、**画面は存在するのに「着いていない」判定**になる。どの要素を借りるかの判断も要らなくなる。

SwiftUIのコンテナに振ったIDは要素として出る（実測で確認済み）。出ない場合は `.accessibilityElement(children: .contain)` を併せる。それでも出なければ常在要素を借りるしかないが、**借りた理由を手順6で報告する。**

#### IDの書き方

**IDはリテラルで1箇所に書く。接頭辞と役割名を分けて合成しない。** 合成すると、完成したIDがソースのどこにも文字列として存在しなくなり、**ID生存チェックのgrepが生きているIDを `dead` と誤判定する。**

```swift
// 悪い: "browse.error.reloadButton" がどこにも literal で無い
ErrorView(idPrefix: "browse.error") { ... }
```

接頭辞を渡して中で組み立てる形にしない。IDは完成した文字列で、画面のファイルに1箇所だけ書く。

**自分で振れないIDは、そのまま書く。** ナビゲーションの戻る（`BackButton`）やキーボードの検索キー（`Search`）はOSが持つIDで、接頭辞の規則に従えない。そのまま `tap` に書き、手順6で**lintの例外として報告する**。

**コードに書いてもビュー階層に出ないことがある。** コンテナに付けて子要素がまとめられていない、`isAccessibilityElement = false`、SwiftUIでmodifierの付け場所が違う、などが典型。ここは手順5で実測して直すのが前提で、**コードに書いた時点では未確定として扱う。**

#### 共有コンポーネント

**共有コンポーネントには、まず呼び出し側の modifier で振る。** コンポーネントを触らずに済むので、これが既定。

```swift
SearchBar(text: $model.keyword, ...)
    .accessibilityIdentifier("browse.searchField")
```

**振ってから実測で確かめる。届かないことがある。**

| | 呼び出し側の modifier で届くか |
|---|---|
| 単一要素のコンポーネント（ボタン1つ、ラベル1つ） | たぶん届く |
| `UIViewRepresentable` のラッパー | **届くことがある。** ラッパー自体が要素としてツリーに出て、内側のサブビューは同じ位置の別要素として並ぶ（`UISearchBar` にIDが乗り、入力値は隣の行に `プレースホルダ = 打った文字` で出る）。タップも入力もIDで通る |
| 複数要素のコンテナ | **全体に1つまで。** 中のボタンには届かない |

**届かなかった場合だけ、コンポーネントがIDを受け取る形にする。** 順序を逆にしない。**「このコンポーネントは届かないだろう」と予測してパラメータを足さない** — 実測すると届くことがある。

```swift
ErrorView(reloadIdentifier: "browse.error.reloadButton") { ... }
```

**渡すIDは最小限にする。** タップ対象にだけ渡し、状態の観測はそのIDで兼ねる（再読み込みボタンが見えていればエラー状態なので、メッセージ用のIDは要らない）。役割ごとに配るとパラメータが増えるだけになる。

どちらの形でも、**IDのリテラルは画面のファイル側に置く。** コンポーネント側に書くと全画面で同じIDになって区別できない。画面側に置けば `files` に共有コンポーネントを載せる必要もない（載せると、それを触ったPRで全画面がヒットする）。

**IDを受け取る引数に空文字のデフォルトを置かない。** 渡し忘れたときに空のIDが振られ、ツリーにどう出るかが不定になる。`String?` にして、nil のときは modifier を適用しない。

```swift
private let reloadIdentifier: String?
...
if let reloadIdentifier {
    button.accessibilityIdentifier(reloadIdentifier)
} else {
    button
}
```

#### 一覧の行

**`ForEach` で繰り返す要素のIDは、画面側で `switch` して完成形を返す。** ドメインの型に画面固有のIDを持たせると、その型を別の画面で使ったときに他画面のIDが振られる。呼び出し側で組み立てると合成になって grep で引けない。

```swift
// 悪い: 画面固有のIDがドメインの enum に漏れる
extension FilterTarget {
    var identifier: String {
        switch self {
        case .title: "browse.targetPicker.title"
```

```swift
// 悪い: 合成なので "browse.targetPicker.title" が literal で無い
.accessibilityIdentifier("browse.targetPicker.\(target.identifier)")
```

```swift
// 良い: 画面のファイルで switch して完成形を返す
private func identifier(for target: FilterTarget) -> String {
    switch target {
    case .title: "browse.targetPicker.title"
    case .author: "browse.targetPicker.author"
    }
}
```

**一覧のように動的に組まれるIDは、補間に何を入れるかで質が決まる。位置（index）を入れない。**

```swift
// 悪い: データで動く識別子になる
.accessibilityIdentifier("itemList.cell.\(index)")

// 良い: 並び替え・絞り込み・挿入で変わらない
.accessibilityIdentifier("itemList.cell.\(item.id)")
```

`index` を焼き込むと、並び替えや上への1件挿入で全部ずれる。**「IDはデータで動かない」というこのマップの前提が崩れ、座標問題を文字列でやり直すことになる。** 証跡としても弱く、確かめたいのは「3行目が出ている」ではなく「その1件が出ている」。

**表示テキストも入れない**（`itemList.cell.<表示名>`）。API由来でローカライズで変わるので、セレクタに文言を使わないのと同じ理由。

一覧では軸が2つある。混ぜない。

| 軸 | 補間に入れるもの | 例 |
|---|---|---|
| どの行か（identity） | モデルの安定ID | `itemList.cell.<item.id>` |
| 行の中のどの要素か（role） | 静的な役割名 | `itemList.cell.title` / `.subtitle` |

**安定IDを持たないモデルでは `index` に逃げず、行を特定しない形にする。** 識別子は行を指さず、どの行を触るかは実行時の選び方（`select.index` や capture）に任せる。`select.index` は実行時に解決される選択戦略なので問題なく、識別子に焼き込むのとは別物。

`index` が識別子に入って正当なのは並び順そのものが確認対象のときくらいで、その場合もマップに不安定であることを書く。

補間で組まれるIDは、**マップ側ではパターンで書く。**

```yaml
actions:
  - tap: itemList.cell.*
    to: item_detail
```

末尾の `*` がパターンの印。接頭辞（`itemList.cell.`）は静的なので、それでlintの存在確認ができる。**接頭辞を補間の中に散らさない。**

### 5. 実測のビュー階層で検証する

**ここが品質の分水嶺。** コードに書いたIDが実際にツリーに出るかは、動かすまで分からない。

**まずビルドし直してシミュレーターに入れる。** ID付与はソースの変更なので、**古いバイナリのツリーには新しいIDが出ない。** ビルドはそのプロジェクトのやり方に従う。使う端末は起動中のものから選び、**勝手に起動しない**。

```bash
xcrun simctl list devices booted
python3 ~/.claude/skills/sim-test-report/scripts/maestrod.py inspect <UDID> <名前>
```

**各画面へは、書いたばかりの `actions` を辿って行く。** これが**マップの最初の実地テスト**になる。辿れなかったら、IDが出ていないか `to` が間違っているかのどちらかなので、そこで直す。

着いたら `anchor` / `actions[].tap` / `actions[].expect` が行として出ていることを確かめる。

出ていないIDは、付け場所を直すか、`in_tree: false` として記録する（ツリーに出ない要素は座標でしか叩けない）。

**ここを通すのがスキルの完了条件。マップに書いてあることは全部、実測で確認済みにする。**

**いまのデータで踏めない画面は、踏ませて検証する。** 課金プラン限定、特定のアカウント状態、0件やエラーの表示。条件を満たせないことを理由に未検証で残さない。手段は2つあり、**環境を変える方を先に試す。**

**環境を変える**（サーバーを止める、機内モード、データを消す）。コードを触らないので revert 漏れが起きない。ただし**元に戻す**。戻せない場合（プロセスを立て直した、消したデータが復元できない）は、**いまどういう状態かを手順6で引き継ぐ。**

**それで踏めないときだけ一時コードを入れる。**

- 変更箇所に `// FIXME: 画面マップ検証用の一時コード` と、本来の実装をコメントで残す
- 検証後に必ず revert し、`git status` に残っていないことを確認する
- 使った一時コードは手順6で報告する（どのファイルの何を、どの画面のために変えたか）

それでも確かめられなかった画面は**マップに入れない。** 未検証のエントリを残すと使う側に特別扱いが要るうえ、そのエントリは結局信用して辿れない。手順6で報告する。

その画面を通る経路は存在しないことになるが、それが事実。**「検証されていないが、たぶんこう」を書き足すと、マップ全体が無条件に信用できなくなる。**

**最後に `route.py check` を通す。** 実測はIDが出るかを見るもので、こちらは**マップが経路として成立しているか**を見る。到達できない画面、遷移先のファイルが無い `to`、`anchor` の無い画面がここで出る。

```bash
python3 ~/.claude/skills/screen-map/scripts/route.py check
```

**鮮度も出る。** `screens/<id>.yaml` より新しく `files` が触られていたら、その画面のマップは実装とずれている可能性がある（判定は git のコミット日時。mtime は clone や checkout で揃うので使わない）。リファクタやコメントの修正でも出るので不整合ではなく警告だが、**`files` が薄いと検出自体が効かない。**

不整合（`to` の先が無い、`anchor` が無い）が出たら直す。**「経路が切れる／弱い箇所」に出たもの（`in_tree: false`、`by: label`、`stub`）は直す対象ではない**。事実として出ているだけなので、手順6の報告に使う。

検証に使ったデーモンは止める。

```bash
python3 ~/.claude/skills/sim-test-report/scripts/maestrod.py stop
```

### 6. 積み残しを報告する

- ツリーに出なかった要素と、その対処（付け場所を直した / `in_tree: false` にした）
- **使った一時コード**（どのファイルの何を、どの画面のために変えたか）。revert 済みであることも書く
- **戻せなかった環境の状態**（止めたサーバー、消したデータ、立て直したプロセス）。次に何をすれば元に戻るかまで書く
- **`actions` に載せられなかった操作とその理由**（ラベルでも指せない、目標をIDで指せない、判断が必要）。**IDが無いだけなら `by: label` で載せる。外すと「その操作ができる」こと自体が記録から消え、確認項目の候補から落ちる**
- **`by: label` を使った箇所と、IDを振れない理由**
- **`expect` を置けなかった操作**（結果を弁別できる要素が無いもの）
- **ドキュメントと実装の食い違い**。マップを作る過程で必ず見つかる。どちらが正かは判断せず、事実として挙げる
- **肯定形で確かめられない状態。** 空表示のビューが無く「0件で着いた」を指せるIDが存在しないなど。`states` に入れられない理由とあわせて、**その状態を確認可能にするには実装側に何が必要か**まで書く
- **マップに入れなかった画面とその理由**（一時コードでも踏めなかった等）
- **遷移先を追い切れなかった操作。** Router経由やクロージャで解決先が確定できなかったもの。**推測で `to` を書かない**
- 判断が必要でフローに積めない操作（「どれを選ぶか」がデータ次第のもの）
- `stub` にした画面
- **接頭辞の規則に合わないID**（既存IDの流用、`BackButton` や `Search` のようなOS提供のID）。lintの例外になる
- **`route.py check` の出力**。到達できない画面と、経路が切れる箇所がそのまま確認の限界になる
- 手順1で特定した遷移の語彙
- **ID付与はソースの変更である。** 新規UIを実装するときにIDの付与とマップ更新を同じPRでやらないとマップは腐る。この運用をチームに要求するかは人の判断なので、勝手に決めずここで挙げる

**黙って埋めない。** 埋めた分は使った時に壊れ、しかも原因がマップにあると気づけない。

## 経路になる

マップは `to` で辺を持っているので、起点からの経路はグラフの最短路として機械的に組める。`scripts/route.py` がそれをやる。**このスキルの成果物がそのまま sim-test-report の入力になるのは、ここを通してのこと。**

```bash
R=~/.claude/skills/screen-map/scripts/route.py

python3 $R screens                    # 画面の一覧（呼び名・できること）
python3 $R which <パス...>            # 変更したファイルから対象画面を引く
python3 $R path <セグメント...>       # 人が読む経路
python3 $R flow <セグメント...> --app <bundle id> --out <パス>   # Maestro のフロー
python3 $R check                      # 自己テスト（到達可否・切れている箇所・鮮度）
```

`flow --out` は、**走るフローをファイルに書き、標準出力には読める経路を出す**。レビューに見せる文と実際に走るものが1回の実行から出るので食い違わず、フローの中身がモデルの文脈を通らない。

`--out-dir <dir>` を付けると、**`--shot` ごとにフローを分けて**書く。1本＝1枚になるので、走らせる側が証跡と同名のダンプを取れる（ダンプはフローの途中では取れないため）。2本目以降は起動し直さず続きから始まる。

`--from <画面id>` は、**いまその画面に居る前提で続きのフローを出す**（`stopApp` を付けない）。`--out-dir` が内部で使っているのと同じ仕組みで、手で切りたいときに使う。

経路は**セグメントを並べて組む**。`--goto <画面id>`（いま居る画面からそこまで計算して繋ぐ）、`--do <操作id>`（その場で操作する）、`--shot <パス>`（撮る）の3つで、**並び順がそのまま実行順**。

```bash
python3 $R flow --app <bundle id> \
  --goto browse --do text:browse.searchField --input browse.searchField=牛乳 \
  --goto detail --shot <出力先>/iphone_05_detail \
  --goto browse
```

書く側として効いてくるのは次の点。

- **`anchor` が到達判定になる。** 各ホップの後に `assertVisible` として積まれる。`anchor` が無い画面は経路に使えても「着いた」を確かめられない
- **`to` の先はファイルとして実在する必要がある。** 無ければ `check` が不整合として出す。`stub` を置くのはこのため
- **`kind: back` / `dismiss` は `--goto` の復路になる。** 往路の探索からは外れるが、**深く入った先から戻る経路はこれで組む**。戻る操作を持たない画面は、そこから先へ進むしかなくなる（`route.py` が「戻る操作がマップに無いので向かえない」と返す）ので、push で入る画面には戻る操作を書く
- **`kind: back` の `to` は、複数の入口を持つ画面では嘘になる。** どこから来たかで戻り先が変わるため。`route.py` は歩いた履歴の方を採り、食い違いを補足として出す。**宣言と履歴が食い違う画面は、マップを読む人にも同じ罠になる**ので、補足が出たら `to` を見直す
- **`in_tree: false` はそこで経路が切れる。** その先の画面は到達不能として `check` に出る
- **`select.index` はそのまま `tapOn` の `index` になる。** `capture` はフローには積めない（Maestro の変数は1つしか無い）ので、何を選んだかは着いた先のダンプと証跡で拾う。**証跡を読んで判定するのは呼び出し元の仕事**なので、マップ側に「突き合わせるために読む要素」のIDを足す必要はない
- **`text` の値はマップが持たない。** 呼ぶ側が `--input` で渡す。何を打つかを決めるのはテストケース
- **`result` はフローのコメントになる。** 組んだフローはそのままレビューに出るので、`result` が「何が起きるか」を書けていないと、人が導線を確かめられない

**経路が組めないことは、マップの穴がそのまま出たもの。** `route.py` は推測して繋がない。埋めるのはこのスキルの仕事で、次にこのアプリを触るときの対象リストになる。

## スキーマ

```yaml
# config.yaml
start: root                # 起動直後の画面
```

```yaml
# screens/item_list.yaml — ファイル名が画面id（item_list）
anchor: itemList.title     # この画面にいることを証明するID

names: [アイテム一覧, 一覧画面, 記録一覧]   # この画面の呼び名。社内での通称も入れる

summary: アイテムの一覧。お気に入りの登録と解除、削除ができる   # 何の画面で何ができるか1行

files:                     # 主要ファイル（VC / VM / View）＋IDを振ったファイル
  - Features/Item/ItemListViewController.swift
  - Features/Item/ItemListViewModel.swift
  - Features/Item/ItemListView.swift
  - Features/Item/ItemCell.swift

actions:                   # この画面でできる操作と、その結果
  # 遷移する操作。結果が「別画面に移る」なので to を書く
  - tap: itemList.addButton
    to: item_new

  - tap: itemList.cell
    to: item_detail
    select:
      index: 0                      # 実行時の選び方。識別子ではない
      capture: itemTitle            # その行のテキストを後続で使う

  - tap: itemList.backButton
    to: settings
    kind: back

  # 遷移しない操作。何が起きるかと、それを確かめる観測点
  - tap: itemList.favoriteButton
    result: セルにお気に入りの印が付く
    expect: itemList.cell.favoriteBadge

  - tap: itemList.deleteButton
    result: 行が消え、件数の表示が1つ減る
    expect: itemList.countLabel

  - tap: itemList.bannerImage
    to: promo
    in_tree: false                  # ツリーに行が出ない。座標が要る＝フローが切れる

  # タップ以外の操作。キーは操作の種類
  - text: itemList.searchField
    # 入力が入ったことは確かめられる。一覧が絞られたかは件数が無いと確かめられない
    result: 入力が止まると絞り込みを送り、一覧が入れ替わる
    expect: itemList.searchField

  - scroll: down
    result: 末尾に近づくと次のページを取得して一覧に足す
    expect: itemList.countLabel

  - tap: Clear text                 # IDが無くラベルしかない要素
    by: label
    result: 入力が消え、絞り込みが解除される
    expect: itemList.searchField

states:                    # データ条件で表示が分かれる画面だけ書く
  - expect: itemList.emptyView
    when: 0件のとき
```

- **`names` は呼び名、`summary` はできること。** 軸が違うので混ぜない。「記録画面で〜したい」のように**通称で指されたとき**に引くのが `names`（社内の呼び方、日本語表示名）。「お気に入りに関わる画面はどこか」のように**機能で探すとき**に引くのが `summary`。`names` に「お気に入りを登録できる」を書くと別名リストとして濁り、`summary` に通称だけ書くと機能で引けない
- **`summary` は全画面ぶん読んで候補を絞るためのもの。** だから1行に収める。絞ってから候補の `actions` を読む2段にすると、全画面の `actions` を読むより安い
- **画面idはファイル名。** `id` フィールドは持たない。`screens/item_list.yaml` の id は `item_list` で、`to` はこのファイル名を指す。中にも id を書くと不一致が起きたときに「ファイルは存在するのに `to` がどこも指していない」状態になり、どちらが正かが読む側の実装次第になる
- **`files` は画面とファイルの対応を書き留めるもの**（VC / VM / View）。型名が揃っているコードベースなら grep で導出できるが、**揃っている保証がない**（セルが `RowCell.swift`、VMが `ItemsPresenter` など）。導出に頼ると少なく返ったことに気づけないので、書いておく
- **網羅ではない。** `diff × files` は対象画面を引く手がかりだが、**載っていないファイルだけを触ったPRは当たらない**。当たらなかったことを「この画面は無関係」と結論しない
- **`files` は鮮度の基準にもなる。** `check` はこのリストと yaml のコミット日時を比べて、マップが実装に追いついているかを出す。載っていないファイルの変更は検出できない
- **`files` は両方向に使う。** `route.py which` は差分のファイル名から対象画面を引く（変更 → 画面）。逆に、機能名で画面を絞ったあとは**読むべきソースの一覧**になる（画面 → ファイル）。**マップはコードを読まずに済ませるためのものではなく、読む範囲を決めるためのもの。** どちらの向きも `files` が薄いと効かない
- **ただしIDを振ったファイルは必ず含める。** セルやサブビューに振ったなら、そのファイルを足す。ID生存チェックのスコープになるので、狭すぎると生きているIDを `dead` と誤判定する（広すぎる側は誤判定しない。鈍るのは「IDが別画面へ引っ越した」の検出だけ）
- **共有コンポーネントは `files` に入れない。** 複数画面で使うボタンやセルを書くと、それを触ったPRで全画面がヒットして対象が絞れなくなる。その画面専用のファイルだけにする

### actions

1エントリ = 1つの操作。**遷移は結果の一形態**として同じ並びに置く。キーが操作の種類で、`tap` / `text` / `scroll` のどれか1つを持つ。

| | 意味 |
|---|---|
| `tap` | タップする要素のaccessibilityIdentifier。フローの `tapOn` になる。補間で組まれるIDは末尾に `*` を付けてパターンで書く（`itemList.cell.*`） |
| `text` | **文字を打つ操作。** 値は入力欄のID。打つ文字はマップに書かない（テストケース側が決める）。フローでは前の文字を消してから打つ。ピッカーやスライダーのような値の指定はこれではない |
| `scroll` | スクロールで起きる操作。値が要素のIDならそれが見えるまで（`scrollUntilVisible`）、`down` / `up` なら方向だけ。**ページネーションのように目標をIDで指せない操作はこちら。** 回数はデータ次第なので、結果は `expect` で確かめる |
| `by: label` | **IDが無く、ラベルでしか指せない要素のとき。** `tap` の値をIDではなくラベル文字列として扱う（`tapOn: { text: }`）。ローカライズで壊れるので、自分で振れるならIDを振る。OS提供の要素（検索キーのクリアボタン等）だけの逃げ道 |
| `to` | 結果が画面遷移のとき、その遷移先の画面id。**グラフの辺になるのは `to` を持つエントリだけ** |
| `result` | 遷移しないとき、何が起きるかを1行。人が読む。機械判定はしない |
| `expect` | その結果を確かめられる要素のID。フローの `assertVisible`、証跡の判定点。**その操作で変わるものを指す**（後述） |
| `kind` | `push`（既定）/ `modal` / `back` / `dismiss`。フローのコマンドが変わる |
| `select` | 対象がデータ依存のときの**実行時の選び方**。`index`（何番目か）と `capture`（テキストを変数に取る） |
| `in_tree: false` | ビュー階層に行が出ない要素。座標が要る ⇒ **ここでフローが切れる** |

- **`expect` は「その操作で変わるもの」を指す。** 操作しても変わらない要素を指すと、**操作が効かなくても通る**。一覧の絞り込みで `一覧の行` を指すのが典型で、行は元からあるので何も確かめていない。`result` に書いた変化と `expect` が対応しているか読み返す
- **要素の状態も観測点になる。** 選択・非活性・チェックはビュー階層に出るので（`#browse.targetPicker.author [選択]`）、セグメントやタブの切替は**その要素が選択状態になったこと**で確かめられる。値が変わる要素（入力欄のプレースホルダ、件数ラベル）も同様に使える
- **1つの操作の結果を「確かめられる／られない」で割る。** 全か無かにしない。絞り込みなら「入力が入った」は入力欄の値で確かめられるが、「一覧が絞られた」は件数も空表示も無ければ確かめられない。**確かめられる方を `expect` に置き、確かめられない部分を手順6で報告する。** まとめて「置けない」と結論すると、置ける観測点まで落ちる
- **全エントリが同じ `expect` になっていたら間違っている。** 操作ごとに結果が違うのだから、観測点も違うはず。指せる要素が無いなら、それは**確かめられない操作**なので手順6で報告する
- **`by: label` を使う前に、その要素にIDを振れないか確かめる。** 同じファイルの `Text` や `Button` なら `.accessibilityIdentifier()` を足すだけで済む。**逃げ先が表示名だと、ローカライズを差し替えるPRで死ぬ** — セレクタに文言を使わない理由そのもの。表示名と別に不変な識別子が必要なら、`enum` に `identifier` を足すなどして作る。`by: label` が正当なのはOS提供の要素（`Clear text` など）とIDを持たせられないシートの選択肢だけ
- **`by: label` を使った箇所は、IDを振れない理由を手順6で報告する。** 振れるのに使っていないかを人が確かめられるようにする
- **アクションシート・アラートは画面として扱う。** 操作できるものの集合が変わる状態なので、`kind: modal` で `to` を張り、選択肢はその画面の `actions` に書く。閉じる選択肢は `kind: dismiss`。別ファイルにすれば `anchor` / `actions` / `kind` がそのまま使え、経路計算も変えずに通る
- **シートの選択肢はIDで指せないことが多い。** `UIAlertAction` は `accessibilityIdentifier` を素直に持てず、SwiftUIの `.confirmationDialog` 内の Button もIDが提示後のシートに伝わるか分からない。**手順5で実測し、IDで叩けるならIDを、駄目なら `by: label` を使う**（`by: label` の主な用途がこれ）
- **ピッカー（ドラムロール）・スライダー・日付選択は、いまのスキーマでは書けない。** IDで叩ける操作ではなく、ホイールのスワイプや目標値へのドラッグになるため、**そこでフローが切れる**。載せずに手順6で「`actions` に載せられなかった操作」として報告する。キーを増やすのは、実際にそれを含む画面をマップするときに形を決める（Maestroに直接動かすコマンドが無く、いま決めると外す）
- **入力と送信は別のエントリにする。** デバウンス（入力が止まってから送る）と送信キーを叩くのは結果が違う操作で、片方だけ壊れることがある。入力は `text`、送信キーは `tap: Search` のように分けて書く
- **各画面は「そこから行ける先」だけを書く。** 逆方向（この画面への入り口）は書かない。全画面の `actions` を読んで使う側が逆算する。こうしておくと、遷移を変えたPRではその画面のファイルだけ直せば済む
- `kind: back` / `dismiss` は往路の経路探索からは除外するが、**エッジは捨てない。** 「別の画面で操作 → 戻る → この画面で反映を見る」の復路に使う
- **一覧から1件を選ぶ操作には `capture` を入れる。** どれを選んだかが分からないと、遷移先の証跡に写っている対象が正しいかを判定できない
- `capture` が効くのは**画面をまたぐ確認**。「一覧で1件目を開いて名前を拾う → 戻る → 別画面にその名前が出ているか」がモデルの往復ゼロで1フローに収まる。取った値は呼び出し元への報告にも含める（証跡に写っている名前が何なのか分からないと判定できない）

### states

**データ条件で表示が分かれる画面だけ書く。** 到着した時点の状態なので操作の結果ではなく、`actions` に乗らない。分岐が無い画面には書かない。

| | 意味 |
|---|---|
| `expect` | その状態で出る要素のID |
| `when` | どういうデータ条件のときか。人が読む |

### stub

**まだ書いていない画面。** `to` の先として必要なので置く。最低限 `anchor` と、そこへ入る `actions`。他のフィールドは書いても書かなくてよい。`stub: true` の意味は **`actions` が網羅ではない**ということだけで、読む側はこの画面の操作一覧として扱わない。次にこの画面を書くときの対象リストにもなる。

```yaml
# screens/settings.yaml
anchor: settings.title
stub: true
actions:
  - tap: settings.itemListCell
    to: item_list
```

