Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,37 @@

なし

## [2.1.0] - 2026-08-02

### 追加

- **iPad の分割幅を `SnapshotDevice` に追加**: `iPadPro11Half`(597×834)と
`iPadPro11Third`(398×834)。iPadOS 26 で Split View / Slide Over が廃止され、
ウィンドウが自由リサイズになったため、HIG が求める 1/2・1/3 幅での検証を撮れるようにした。
幅は横向き(1194pt)を基準に取る —— 縦向きの 1/3(278pt)は iPadOS が許す
ウィンドウ最小幅を下回り、実際には作れない面になるため。
**どちらの幅も horizontal size class は compact に落ちる。**
- **文字サイズの軸 `SnapshotDynamicType`**: `.standard`(`.large`)と
`.accessibility3`。`SnapshotConfiguration.dynamicTypes` で指定する。
SwiftUI の `\.dynamicTypeSize` 環境と `ViewImageConfig` の
`preferredContentSizeCategory` trait の両方に効かせる —— 環境だけだと、
UIKit が寸法を決める部分(ナビゲーションバー・リスト行の最小高)が既定サイズのまま残る。
- `SnapshotEntry.dynamicType`(`String?`)。既定サイズでは `nil`。
- `SnapshotConfiguration.standardDevices`(既定で撮る 3 端末)。

### 変更

- **`SnapshotConfiguration` の既定端末を `SnapshotDevice.allCases` から
`standardDevices` に変更した。** 端末を 1 つ足すたびに既存の全スイートが撮る枚数が
黙って増え、参照画像の無い面が失敗として現れるため。分割幅は使うスイートが明示的に足す。
**2.0 系で撮った参照画像はそのまま使える**(既定の行列は変わっていない)。

### 互換性

- 既定の文字サイズでは参照画像の名前を変えない(`{state}.{theme}_{locale}.png`)。
アクセシビリティサイズのときだけ `_{dynamicType}` が付く。2.0 系で記録した画像の
撮り直しは不要。

## [2.0.0] - 2026-06-06

### 破壊的変更
Expand Down
35 changes: 28 additions & 7 deletions README.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ SwiftUI向けのスナップショットテストライブラリ。宣言的マ
## 特徴

- **宣言的マクロ**: `@SnapshotSuite` / `@Snapshot` / `@ComponentSnapshot` で View を返すだけ
- **マトリクステスト**: デバイス × テーマ × ロケールの全組み合わせを自動生成
- **マトリクステスト**: デバイス × テーマ × ロケール × 文字サイズの全組み合わせを自動生成
- **デバイスサブディレクトリ**: `__Snapshots__/{ViewName}/{device}/{stateName}.{theme}_{locale}.png` で自動整理
- **iPad 対応**: iPhone16 + iPhoneSE + iPadPro11 の3デバイスをデフォルトサポート
- **iPad 対応**: iPhone16 + iPhoneSE + iPadPro11 の3デバイスをデフォルトサポート。iPadOS 26 のウィンドウ幅(1/2・1/3)は明示指定で足せる
- **メタデータカタログ**: per-view `manifest.json` とルート `snapshot-catalog.json` を自動生成
- **テーマシステム統合**: `ThemeApplicable` プロトコルで任意のテーマシステムと接続
- **View / Component 分離**: View は全軸マトリクス、コンポーネントはテーマ軸のみでテスト
Expand Down Expand Up @@ -329,16 +329,25 @@ public struct SnapshotCase: Sendable, CustomTestStringConvertible {
| `devices` | `[SnapshotDevice]` | `[.iPhone16, .iPhoneSE, .iPadPro11]` | テスト対象デバイス |
| `themes` | `[SnapshotTheme]` | `[.light, .dark]` | テスト対象テーマ |
| `locales` | `[String]` | `["en", "ja"]` | テスト対象ロケール |
| `dynamicTypes` | `[SnapshotDynamicType]` | `[.standard]` | テスト対象の文字サイズ |
| `precision` | `Float` | `0.99` | ピクセル精度 |
| `perceptualPrecision` | `Float` | `0.98` | 知覚的精度 |

### SnapshotDevice

| ケース | 画面サイズ | スケール |
|--------|----------|---------|
| `.iPhone16` | 393 × 852 | @3x |
| `.iPhoneSE` | 375 × 667 | @2x |
| `.iPadPro11` | 834 × 1194 | @2x |
| ケース | 画面サイズ | スケール | horizontal size class |
|--------|----------|---------|------|
| `.iPhone16` | 393 × 852 | @3x | compact |
| `.iPhoneSE` | 375 × 667 | @2x | compact |
| `.iPadPro11` | 834 × 1194 | @2x | regular |
| `.iPadPro11Half` | 597 × 834 | @2x | compact |
| `.iPadPro11Third` | 398 × 834 | @2x | compact |

後ろの 2 つは iPad のウィンドウ幅。iPadOS 26 で Split View / Slide Over が廃止されて
自由リサイズのウィンドウになり、HIG が 1/2・1/3 幅での検証を求めるようになったため足した。
幅は横向き(1194pt)を基準に取る —— 縦向きの 1/3(278pt)は iPadOS が許すウィンドウの
最小幅を下回り、実際には作れない面を撮ることになる。
**既定の端末一覧には入れていない。** 要るスイートが明示的に足す。

### SnapshotTheme

Expand All @@ -347,6 +356,18 @@ public struct SnapshotCase: Sendable, CustomTestStringConvertible {
| `.light` | ライトモード |
| `.dark` | ダークモード |

### SnapshotDynamicType

| ケース | `DynamicTypeSize` | ファイル名の接尾辞 |
|--------|------|------|
| `.standard` | `.large` | なし |
| `.accessibility3` | `.accessibility3` | `_accessibility3` |

`.standard` は 2.0 系と同じファイル名のままなので、**この軸を足しても既存の参照画像は
無効にならない。** SwiftUI の `\.dynamicTypeSize` 環境と `preferredContentSizeCategory`
trait の両方に効かせる —— 環境だけだと、UIKit が寸法を決める部分(ナビゲーションバー・
リスト行の最小高)が既定サイズのまま残る。

### ThemeApplicable

```swift
Expand Down
37 changes: 29 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ SwiftUI snapshot testing library. Eliminates boilerplate with declarative macros
## Features

- **Declarative macros**: `@SnapshotSuite` / `@Snapshot` / `@ComponentSnapshot` — just return a View
- **Matrix testing**: Automatically generates every device × theme × locale combination
- **Matrix testing**: Automatically generates every device × theme × locale × text size combination
- **Device subdirectories**: Auto-organized at `__Snapshots__/{ViewName}/{device}/{stateName}.{theme}_{locale}.png`
- **iPad support**: iPhone 16, iPhone SE, and iPad Pro 11 supported by default (3 devices)
- **iPad support**: iPhone 16, iPhone SE, and iPad Pro 11 by default; iPadOS 26 window widths (1/2, 1/3) opt in
- **Metadata catalog**: Auto-generates per-view `manifest.json` and root `snapshot-catalog.json`
- **Theme system integration**: Connect any theme system via the `ThemeApplicable` protocol
- **View / Component separation**: Views use the full matrix; components use theme axis only
Expand Down Expand Up @@ -129,7 +129,7 @@ struct MyViewSnapshots {

**Output**: `__Snapshots__/MyView/{device}/loaded.{theme}_{locale}.png`

The default configuration produces 3 devices × 2 themes × 2 locales = **12 snapshots**.
The default configuration produces 3 devices × 2 themes × 2 locales × 1 text size = **12 snapshots**.

### Component Snapshots

Expand Down Expand Up @@ -329,16 +329,25 @@ public struct SnapshotCase: Sendable, CustomTestStringConvertible {
| `devices` | `[SnapshotDevice]` | `[.iPhone16, .iPhoneSE, .iPadPro11]` | Target devices |
| `themes` | `[SnapshotTheme]` | `[.light, .dark]` | Target themes |
| `locales` | `[String]` | `["en", "ja"]` | Target locales |
| `dynamicTypes` | `[SnapshotDynamicType]` | `[.standard]` | Target text sizes |
| `precision` | `Float` | `0.99` | Pixel precision |
| `perceptualPrecision` | `Float` | `0.98` | Perceptual precision |

### SnapshotDevice

| Case | Screen Size | Scale |
|--------|----------|---------|
| `.iPhone16` | 393 × 852 | @3x |
| `.iPhoneSE` | 375 × 667 | @2x |
| `.iPadPro11` | 834 × 1194 | @2x |
| Case | Screen Size | Scale | Horizontal size class |
|--------|----------|---------|------|
| `.iPhone16` | 393 × 852 | @3x | compact |
| `.iPhoneSE` | 375 × 667 | @2x | compact |
| `.iPadPro11` | 834 × 1194 | @2x | regular |
| `.iPadPro11Half` | 597 × 834 | @2x | compact |
| `.iPadPro11Third` | 398 × 834 | @2x | compact |

The two window widths cover the 1/2 and 1/3 sizes the HIG asks you to verify now that
iPadOS 26 replaced Split View with freely resizable windows. Widths are taken from the
landscape screen (1194pt) — a third of the portrait width (278pt) is below the minimum
window width iPadOS allows, so it would render a layout no user can produce. They are
**not** in the default device list; add them explicitly to the suites that need them.

### SnapshotTheme

Expand All @@ -347,6 +356,18 @@ public struct SnapshotCase: Sendable, CustomTestStringConvertible {
| `.light` | Light mode |
| `.dark` | Dark mode |

### SnapshotDynamicType

| Case | `DynamicTypeSize` | File name suffix |
|--------|------|------|
| `.standard` | `.large` | none |
| `.accessibility3` | `.accessibility3` | `_accessibility3` |

`.standard` keeps the 2.0 file name, so adding this axis never invalidates existing
reference images. Both the SwiftUI `\.dynamicTypeSize` environment and the
`preferredContentSizeCategory` trait are set — the environment alone leaves UIKit-sized
chrome (navigation bar, minimum row height) at the default size.

### ThemeApplicable

```swift
Expand Down
72 changes: 46 additions & 26 deletions Sources/VisualTesting/Assertions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,31 +44,34 @@ public enum VisualTesting {

for theme in configuration.themes {
for locale in configuration.locales {
let vc = makeHostingController(
view: wrapped,
device: device,
theme: theme,
locale: locale
)
let snapshotName = "\(theme.rawValue)_\(locale)"

let failure = verifySnapshot(
of: vc,
as: .image(
on: device.config,
precision: configuration.precision,
perceptualPrecision: configuration.perceptualPrecision
),
named: snapshotName,
snapshotDirectory: dir,
file: file,
testName: stateName,
line: line
)
if let message = failure {
Issue.record(
Comment(rawValue: "\(viewName)/\(device.rawValue)/\(stateName).\(snapshotName): \(message)")
for dynamicType in configuration.dynamicTypes {
let vc = makeHostingController(
view: wrapped,
device: device,
theme: theme,
locale: locale,
dynamicType: dynamicType
)
let snapshotName = snapshotName(theme: theme, locale: locale, dynamicType: dynamicType)

let failure = verifySnapshot(
of: vc,
as: .image(
on: device.config(dynamicType: dynamicType),
precision: configuration.precision,
perceptualPrecision: configuration.perceptualPrecision
),
named: snapshotName,
snapshotDirectory: dir,
file: file,
testName: stateName,
line: line
)
if let message = failure {
Issue.record(
Comment(rawValue: "\(viewName)/\(device.rawValue)/\(stateName).\(snapshotName): \(message)")
)
}
}
}
}
Expand All @@ -80,6 +83,7 @@ public enum VisualTesting {
device: device,
themes: configuration.themes,
locales: configuration.locales,
dynamicTypes: configuration.dynamicTypes,
inNavigation: inNavigation,
disableAnimations: disableAnimations,
file: file
Expand Down Expand Up @@ -157,15 +161,31 @@ public enum VisualTesting {

// MARK: - Private Helpers

/// 参照画像の名前。
///
/// **既定の文字サイズでは名前に何も足さない。** 足すと 2.0 系で撮った参照画像が
/// 全部行方不明になり、この軸を使っていないスイートまで撮り直しになる。
private static func snapshotName(
theme: SnapshotTheme,
locale: String,
dynamicType: SnapshotDynamicType
) -> String {
let base = "\(theme.rawValue)_\(locale)"
return dynamicType == .standard ? base : "\(base)_\(dynamicType.rawValue)"
}

@MainActor
private static func makeHostingController<V: View>(
view: V,
device: SnapshotDevice,
theme: SnapshotTheme,
locale: String
locale: String,
dynamicType: SnapshotDynamicType
) -> UIViewController {
let themed = themeApplicable.applyTheme(view, theme: theme)
let localized = themed.environment(\.locale, Locale(identifier: locale))
let localized = themed
.environment(\.locale, Locale(identifier: locale))
.environment(\.dynamicTypeSize, dynamicType.dynamicTypeSize)

let hostingController = UIHostingController(rootView: localized)
hostingController.view.frame = CGRect(
Expand Down
21 changes: 14 additions & 7 deletions Sources/VisualTesting/CatalogGenerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ extension VisualTesting {
device: SnapshotDevice?,
themes: [SnapshotTheme],
locales: [String],
dynamicTypes: [SnapshotDynamicType] = [.standard],
inNavigation: Bool,
disableAnimations: Bool,
file: StaticString
Expand Down Expand Up @@ -46,13 +47,18 @@ extension VisualTesting {
// View snapshot: device subdirectory
for theme in themes {
for locale in locales {
let fileName = "\(device.rawValue)/\(stateName).\(theme.rawValue)_\(locale).png"
entries.append(SnapshotEntry(
device: device.rawValue,
theme: theme.rawValue,
locale: locale,
file: fileName
))
for dynamicType in dynamicTypes {
// 既定の文字サイズは名前に出さない(`Assertions.snapshotName` と同じ規則)。
let suffix = dynamicType == .standard ? "" : "_\(dynamicType.rawValue)"
let fileName = "\(device.rawValue)/\(stateName).\(theme.rawValue)_\(locale)\(suffix).png"
entries.append(SnapshotEntry(
device: device.rawValue,
theme: theme.rawValue,
locale: locale,
dynamicType: dynamicType == .standard ? nil : dynamicType.rawValue,
file: fileName
))
}
}
}
} else {
Expand All @@ -63,6 +69,7 @@ extension VisualTesting {
device: nil,
theme: theme.rawValue,
locale: nil,
dynamicType: nil,
file: fileName
))
}
Expand Down
Loading
Loading