diff --git a/AGENTS.md b/AGENTS.md index 0c0653b..9535e4b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ Conventions for AI coding agents (Claude Code, Cursor, Codex, etc.) and human co * `bin/shutter.dart` — minimal CLI entrypoint; defers to `lib/src/entry_point.dart`. * `lib/shutter.dart` — the public library: `shot`, `diff`, `doctor`, and `prune` as functions, for Dart tools that shoot without starting a process. Everything else under `lib/src/` is private to the package. -* `lib/src/api/` — the library's functions and types (`shot.dart`, `diff.dart`, `doctor.dart`, `prune.dart`); `shoot.dart`, the run that `shutter shot` and `shot` share: it checks the arguments, which `shutter shot` builds from its options, and shoots; and `compare.dart`, the comparison `shutter diff` and `diff` share. +* `lib/src/api/` — the library's functions and types (`shot.dart`, `diff.dart`, `doctor.dart`, `prune.dart`); `shoot.dart`, the run that `shutter shot` and `shot` share: it checks the arguments, which `shutter shot` builds from its options, and shoots; `locale_codes.dart`, the language tag of `--locale`, read as `dart:ui`'s `Locale` reads it; and `compare.dart`, the comparison `shutter diff` and `diff` share. * `lib/src/entry_point.dart` — `runApp` (guarded zone) and `runShutter` (exception → sysexits mapping). Tests drive `runShutter` with a `ShutterContext`. * `lib/src/shutter_exception.dart` — expected failures with their sysexits code; `lib/src/version.dart` — `packageVersion`, generated from `pubspec.yaml` by `build_version` (`dart run build_runner build`); `lib/src/dart_literal.dart` — Dart string literals for generated code. * `lib/src/cli/` — `CommandRunner` plus one file per subcommand. `context.dart` defines `ShutterContext`, which carries the working directory, clock, environment, and engine factory so commands run in tests without Flutter. `shot_options.dart` parses `shot`'s arguments; `shot_command.dart` is `semantics` too. `agent_text.dart` and `manual_text.dart` are const mirrors of `doc/agent.md` and `doc/manual.md`; `test/cli/text_parity_test.dart` enforces byte equality. diff --git a/CHANGELOG.md b/CHANGELOG.md index c7d38e7..00b9e5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ The device a shot is drawn as. * `shutter shot --pixel-ratio ` (and `semantics`, and `shot(pixelRatio:)`) draws every preview at that device pixel ratio instead of 2: the PNG is the shot's logical size times the ratio, and what reads the ratio, such as `MediaQuery.devicePixelRatioOf` and an `AssetImage` picking its variant, follows it as on the device. `shot` prints it, `manifest.json` records it, `diff` prints both runs' when they differ and returns it as `ShotSetup.pixelRatio`. It is not part of shot ids. * `shutter shot --safe-area ,` (and `semantics`, and `shot(safeArea: SafeAreaInsets(...))`) lays every preview out with those safe area insets in logical pixels, which `flutter test` reports as none: the view reports them as its padding and view padding, so an `AppBar` grows by the top inset and a `SafeArea` keeps out of them as on the device; with `--keyboard`, the keyboard absorbs the bottom inset from the padding. The status bar and home indicator are not drawn. `shot` prints the insets, `manifest.json` records them, `diff` prints both runs' when they differ and returns them as `ShotSetup.safeArea`. They are not part of shot ids. * `shutter shot --brightness dark` and `--text-scale ` (and `semantics`, and `shot(brightness:, textScale:)`) report that platform brightness and text scale, as a device's settings, to every preview that sets none of its own; a preview's `brightness` or `textScaleFactor` wins. A `--widget` shot, which has no `Preview` to set them, is shot dark or with larger text this way. `shot` prints them, `manifest.json` records them, `diff` prints both runs' when they differ and returns them as `ShotSetup.brightness` and `ShotSetup.textScale`. They are not part of shot ids. +* `shutter shot --locale ` (and `semantics`, and `shot(locale:)`) reports that locale as the device's instead of en-US: a language, then an optional script and region, such as `ja-JP` or `zh-Hant-TW`. The shell's app widget resolves its locale from it against its `supportedLocales`, and so do a preview's `localizations` that set no `locale`. `shot` prints it, `manifest.json` records it as a language tag, `diff` prints both runs' when they differ and returns it as `ShotSetup.locale`. It is not part of shot ids. ## 1.3.0 diff --git a/README.md b/README.md index 00adbb0..84f3be5 100644 --- a/README.md +++ b/README.md @@ -156,19 +156,20 @@ Each is followed by `--settle` milliseconds; shoot again with another `--settle` `--pixel-ratio 3` draws at the device pixel ratio of the device a screen is laid out for, instead of 2. `--safe-area 59,34` lays a screen out with that device's safe area insets, top and bottom, which `flutter test` reports as none; the status bar and home indicator are not drawn. `--brightness dark` and `--text-scale 1.3` report the device's settings to every preview that sets none of its own, and to a `--widget`. +`--locale ja-JP` reports the device's locale, which the app widget resolves its own from, instead of en-US. ## Subcommands -| Command | Purpose | -| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `agent` | The step-by-step playbook for AI agents. | -| `manual` | The reference: preview files, drawing model, engine, runs, ids, diff, output, exit codes. | -| `doctor` | Check the Flutter SDK version, its font cache, and the project's shell (`--shell`). | -| `init` | Write `/shell.dart`, or the `--shell` file. | -| `shot` | Render the named preview files, or one `--widget`, into a new run (`--widget`/`--import`/`--size`, `--settle`, `--shell`, `--tap`/`--enter`/`--press`/`--hover`/`--focus`, `--capture`/`--viewport`, `--keyboard`/`--keyboard-tint`, `--network-image`, `--platform`, `--pixel-ratio`, `--safe-area`, `--brightness`/`--text-scale`). | -| `semantics` | Draw the semantics tree of the same subjects into a new run, with the options of `shot` except `--keyboard-tint`. | -| `diff ` | Compare two runs (`--images`, `--composite`). | -| `prune` | Delete every run but the newest `--keep `. | +| Command | Purpose | +| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `agent` | The step-by-step playbook for AI agents. | +| `manual` | The reference: preview files, drawing model, engine, runs, ids, diff, output, exit codes. | +| `doctor` | Check the Flutter SDK version, its font cache, and the project's shell (`--shell`). | +| `init` | Write `/shell.dart`, or the `--shell` file. | +| `shot` | Render the named preview files, or one `--widget`, into a new run (`--widget`/`--import`/`--size`, `--settle`, `--shell`, `--tap`/`--enter`/`--press`/`--hover`/`--focus`, `--capture`/`--viewport`, `--keyboard`/`--keyboard-tint`, `--network-image`, `--platform`, `--pixel-ratio`, `--safe-area`, `--brightness`/`--text-scale`, `--locale`). | +| `semantics` | Draw the semantics tree of the same subjects into a new run, with the options of `shot` except `--keyboard-tint`. | +| `diff ` | Compare two runs (`--images`, `--composite`). | +| `prune` | Delete every run but the newest `--keep `. | ## Exit codes diff --git a/doc/agent.md b/doc/agent.md index c445d53..f3687c6 100644 --- a/doc/agent.md +++ b/doc/agent.md @@ -49,6 +49,7 @@ Widget settingsSignedOut() => const SettingsPage(user: null); * Shots are drawn at a device pixel ratio of 2. For the device a screen is laid out for, pass its ratio to the before and after shots alike, `--pixel-ratio 3` for an iPhone: an `AssetImage` then picks that ratio's variant. The PNG is the shot's `size` times the ratio; when you post it, show it at `size`'s width. `diff` prints `pixel_ratio:` when the two runs differ in it. * Shots are laid out without a safe area, as no phone is. For a screen, pass the device's insets, top and bottom in logical pixels, to the before and after shots alike, `--safe-area 59,34` for example (the device's `MediaQuery.viewPaddingOf(context)`): the `AppBar` then grows under the status bar and a `SafeArea` keeps out of the insets as on the device. The status bar and the home indicator are not drawn; say so when you post the image. * For a change to a dark theme or to how text grows, shoot with `--brightness dark` or `--text-scale 1.3` as well, the same in the before and after runs. They reach every preview that sets no `brightness` or `textScaleFactor` of its own, and they are the only way to shoot a `--widget` dark or with larger text. A dark shot needs a shell that sets the app's `darkTheme`. The scale multiplies every font size, as iOS does; Android 14 and later scale their own way, so say so when you post an Android shot at a scale other than 1. `diff` prints `brightness:` and `text_scale:` when the two runs differ in them. +* Shots report en-US as the device's locale. For an app used in another language, pass `--locale ja-JP` (a language, then an optional script and region) to the before and after shots alike: the shell's app widget then resolves its locale from it, as on a device set to that language, if the shell sets the app's `supportedLocales` and `localizationsDelegates`. `diff` prints `locale:` when the two runs differ in it. ## Shell diff --git a/doc/manual.md b/doc/manual.md index f4609bd..ccf8fff 100644 --- a/doc/manual.md +++ b/doc/manual.md @@ -126,6 +126,16 @@ A `--widget` shot has no `Preview` of its own to set them, so these options are The run records `brightness: dark` and `text_scale` in `manifest.json` when not the default; `shot` prints them, and `diff` prints both runs' when they differ, since they change the image without any widget changing. They are not part of shot ids, so a run with `--brightness dark` pairs shot for shot with one without. +## Locale + +`flutter test` reports en-US as the device's locale. +`--locale ` reports another: a language, then an optional script and region, joined by `-` or `_`, such as `ja-JP` or `zh-Hant-TW`. +What resolves its locale from the device's resolves it from this one, as on a device with that language set: the shell's app widget, against its `supportedLocales`, and a preview's `localizations` that set no `locale` of their own. +An app widget that does not support the locale falls back as on a device: the default shell's `MaterialApp`, which supports only en-US, stays in English, so shoot through a shell that sets the app's `supportedLocales` and `localizationsDelegates`. + +The run records `locale` in `manifest.json` as the app receives it: each code in its usual case (`zh-Hant-TW`), and a deprecated language or region replaced as Flutter's `Locale` replaces it (`iw-IL` is `he-IL`); `shot` prints it, and `diff` prints both runs' when they differ, since the locale changes the text without any widget changing. +It is not part of shot ids, so a run with `--locale` pairs shot for shot with one without. + ## The semantics tree `shutter semantics` takes the same preview files, `--widget`, and options as `shot`, except `--keyboard-tint`, and shoots the same way, actions included. @@ -300,7 +310,7 @@ When Flutter ships a capture command in the previewer itself, it replaces v1 wit ## Runs -`.dart_tool/shutter/runs//` holds `.png` per shot and `manifest.json` (`run`, `image` for a `semantics` run, `shell` when a shell file was used, `platform` for a run as iOS, `pixel_ratio` for a ratio other than 2, `safe_area`, `brightness` when dark, `text_scale` other than 1, `actions`, `capture`, `viewport`, `keyboard`, `keyboard_tint`, and `network_images` when given, `shots`). +`.dart_tool/shutter/runs//` holds `.png` per shot and `manifest.json` (`run`, `image` for a `semantics` run, `shell` when a shell file was used, `platform` for a run as iOS, `pixel_ratio` for a ratio other than 2, `safe_area`, `brightness` when dark, `text_scale` other than 1, `locale`, `actions`, `capture`, `viewport`, `keyboard`, `keyboard_tint`, and `network_images` when given, `shots`). `` is the UTC time of the shot (`20260918T101530Z`), suffixed `-2`, `-3`, ... when taken; a hidden `.` file claims the name, so runs started in the same second get distinct ids. `shot` prints the run directory as `run:`; `diff` accepts a run's directory, its id, `latest` for the newest run, or `latest~N` for the run N before it. `latest` counts runs in id order (time, then suffix) and skips a run still being shot, whose `manifest.json` is not written yet. @@ -346,9 +356,9 @@ When the two runs were shot with different shells (path or sha256), `shell` show ## Output `shot`, `semantics`, and `diff` print YAML starting with the comment `# shutter ai-report v1`, with absolute paths to open. -`shot` gives `run`, `shell` (the shell file with its sha256, or `default`), `platform` (as iOS), `pixel_ratio` (other than 2), `safe_area`, `brightness` (dark), `text_scale` (other than 1), `actions`, `capture`, `viewport`, `keyboard`, `keyboard_tint`, and `network_images` (when given), `summary`, and `shots`, errors first. +`shot` gives `run`, `shell` (the shell file with its sha256, or `default`), `platform` (as iOS), `pixel_ratio` (other than 2), `safe_area`, `brightness` (dark), `text_scale` (other than 1), `locale`, `actions`, `capture`, `viewport`, `keyboard`, `keyboard_tint`, and `network_images` (when given), `summary`, and `shots`, errors first. `semantics` gives the same, with `image: semantics` after `shell`. -`diff` gives `diff` (with `--images` or `--composite`), `before`, `after`, `shell`, `image`, `platform`, `pixel_ratio`, `safe_area`, `brightness`, `text_scale`, `actions`, `capture`, `viewport`, `keyboard`, `keyboard_tint`, and `network_images` (each when the two runs differ in it; `image` is `paint` for a `shot` run), `summary`, and `entries` in the order changed → added → removed → unchanged. +`diff` gives `diff` (with `--images` or `--composite`), `before`, `after`, `shell`, `image`, `platform`, `pixel_ratio`, `safe_area`, `brightness`, `text_scale`, `locale`, `actions`, `capture`, `viewport`, `keyboard`, `keyboard_tint`, and `network_images` (each when the two runs differ in it; `image` is `paint` for a `shot` run), `summary`, and `entries` in the order changed → added → removed → unchanged. ## Exit codes @@ -363,13 +373,13 @@ Other failures follow sysexits: 64 usage, 66 missing run or file, 69 no Flutter ## Commands -| command | purpose | -| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `agent` | the step-by-step playbook | -| `manual` | this document | -| `doctor` | SDK version, font cache, shell (`--shell`) | -| `init` | write `shell.dart` (`--shell`) | -| `shot` | render the named preview files, or one `--widget`, into a new run (`--widget`/`--import`/`--size`, `--settle`, `--shell`, `--tap`/`--enter`/`--press`/`--hover`/`--focus`, `--capture`/`--viewport`, `--keyboard`/`--keyboard-tint`, `--network-image`, `--platform`, `--pixel-ratio`, `--safe-area`, `--brightness`/`--text-scale`) | -| `semantics` | draw the semantics tree of the same subjects into a new run, with the options of `shot` except `--keyboard-tint` | -| `diff` | compare two runs (`--images`, `--composite`/`--direction`/`--title`) | -| `prune` | delete every run but the newest (`--keep`) | +| command | purpose | +| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `agent` | the step-by-step playbook | +| `manual` | this document | +| `doctor` | SDK version, font cache, shell (`--shell`) | +| `init` | write `shell.dart` (`--shell`) | +| `shot` | render the named preview files, or one `--widget`, into a new run (`--widget`/`--import`/`--size`, `--settle`, `--shell`, `--tap`/`--enter`/`--press`/`--hover`/`--focus`, `--capture`/`--viewport`, `--keyboard`/`--keyboard-tint`, `--network-image`, `--platform`, `--pixel-ratio`, `--safe-area`, `--brightness`/`--text-scale`, `--locale`) | +| `semantics` | draw the semantics tree of the same subjects into a new run, with the options of `shot` except `--keyboard-tint` | +| `diff` | compare two runs (`--images`, `--composite`/`--direction`/`--title`) | +| `prune` | delete every run but the newest (`--keep`) | diff --git a/lib/src/api/diff.dart b/lib/src/api/diff.dart index 46bf816..3e9a07a 100644 --- a/lib/src/api/diff.dart +++ b/lib/src/api/diff.dart @@ -82,6 +82,10 @@ final class const ShotSetup({ /// that set none of its own. final ShotBrightness brightness = ShotBrightness.light, final double textScale = 1, + + /// The locale reported as the device's, as a language tag (`ja-JP`); + /// none for `flutter test`'s en-US. + final String? locale, }) { @override bool operator ==(Object other) => @@ -99,7 +103,8 @@ final class const ShotSetup({ other.pixelRatio == pixelRatio && other.safeArea == safeArea && other.brightness == brightness && - other.textScale == textScale; + other.textScale == textScale && + other.locale == locale; @override int get hashCode => Object.hash( @@ -114,11 +119,12 @@ final class const ShotSetup({ safeArea, brightness, textScale, + locale, ); @override String toString() => - 'ShotSetup(${[if (shell != null) 'shell: $shell', 'actions: $actions', 'capture: $capture', if (keyboard != null) 'keyboard: $keyboard', 'networkImages: $networkImages', 'semantics: $semantics', 'platform: ${platform.name}', 'pixelRatio: ${formatNumber(pixelRatio)}', if (safeArea != null) 'safeArea: $safeArea', 'brightness: ${brightness.name}', 'textScale: ${formatNumber(textScale)}'].join(', ')})'; + 'ShotSetup(${[if (shell != null) 'shell: $shell', 'actions: $actions', 'capture: $capture', if (keyboard != null) 'keyboard: $keyboard', 'networkImages: $networkImages', 'semantics: $semantics', 'platform: ${platform.name}', 'pixelRatio: ${formatNumber(pixelRatio)}', if (safeArea != null) 'safeArea: $safeArea', 'brightness: ${brightness.name}', 'textScale: ${formatNumber(textScale)}', if (locale != null) 'locale: $locale'].join(', ')})'; } /// The outcome of [diff]: the two runs and each shot id across them. @@ -315,6 +321,7 @@ ShotSetup _setup(ShellFile? shell, RunSetup setup) => ShotSetup( }, brightness: setup.brightness, textScale: setup.textScale, + locale: setup.locale, ); ShotFile? _file(RecordedFile? file) => switch (file) { diff --git a/lib/src/api/locale_codes.dart b/lib/src/api/locale_codes.dart new file mode 100644 index 0000000..639634c --- /dev/null +++ b/lib/src/api/locale_codes.dart @@ -0,0 +1,131 @@ +import '../shutter_exception.dart'; + +/// A language (an ISO 639 code), an optional script, and an optional +/// region, joined by `-` or `_`, as `Locale.fromSubtags` takes them. +final _languageTag = RegExp( + r'^([a-zA-Z]{2,3})(?:[-_]([a-zA-Z]{4}))?(?:[-_]([a-zA-Z]{2}|[0-9]{3}))?$', +); + +/// The language, script, and country codes of [tag] as the app receives +/// them: each in its usual case (`ja`, `Hant`, `JP`), and a deprecated +/// language or region replaced as `dart:ui`'s `Locale` replaces it +/// (`iw` is `he`). Throws a usage error for anything else. +(String, String?, String?) localeCodes(String tag) { + final match = _languageTag.firstMatch(tag); + if (match == null) { + throw ShutterException.usage( + 'locale must be a language, then an optional script and region, ' + 'e.g. ja-JP or zh-Hant-TW (got "$tag").', + ); + } + final language = match.group(1)!.toLowerCase(); + final script = match.group(2); + final country = match.group(3)?.toUpperCase(); + return ( + _deprecatedLanguages[language] ?? language, + script == null + ? null + : '${script[0].toUpperCase()}${script.substring(1).toLowerCase()}', + _deprecatedRegions[country] ?? country, + ); +} + +/// [codes] as a language tag: `ja-JP`, `zh-Hant-TW`. +String languageTag((String, String?, String?) codes) { + final (language, script, country) = codes; + return [language, ?script, ?country].join('-'); +} + +/// `dart:ui`'s `Locale._deprecatedLanguageSubtagMap`: a deprecated +/// language subtag and the one `Locale.languageCode` gives for it. +const _deprecatedLanguages = { + 'in': 'id', + 'iw': 'he', + 'ji': 'yi', + 'jw': 'jv', + 'mo': 'ro', + 'aam': 'aas', + 'adp': 'dz', + 'aue': 'ktz', + 'ayx': 'nun', + 'bgm': 'bcg', + 'bjd': 'drl', + 'ccq': 'rki', + 'cjr': 'mom', + 'cka': 'cmr', + 'cmk': 'xch', + 'coy': 'pij', + 'cqu': 'quh', + 'drh': 'khk', + 'drw': 'prs', + 'gav': 'dev', + 'gfx': 'vaj', + 'ggn': 'gvr', + 'gti': 'nyc', + 'guv': 'duz', + 'hrr': 'jal', + 'ibi': 'opa', + 'ilw': 'gal', + 'jeg': 'oyb', + 'kgc': 'tdf', + 'kgh': 'kml', + 'koj': 'kwv', + 'krm': 'bmf', + 'ktr': 'dtp', + 'kvs': 'gdj', + 'kwq': 'yam', + 'kxe': 'tvd', + 'kzj': 'dtp', + 'kzt': 'dtp', + 'lii': 'raq', + 'lmm': 'rmx', + 'meg': 'cir', + 'mst': 'mry', + 'mwj': 'vaj', + 'myt': 'mry', + 'nad': 'xny', + 'ncp': 'kdz', + 'nnx': 'ngv', + 'nts': 'pij', + 'oun': 'vaj', + 'pcr': 'adx', + 'pmc': 'huw', + 'pmu': 'phr', + 'ppa': 'bfy', + 'ppr': 'lcq', + 'pry': 'prt', + 'puz': 'pub', + 'sca': 'hle', + 'skk': 'oyb', + 'tdu': 'dtp', + 'thc': 'tpo', + 'thx': 'oyb', + 'tie': 'ras', + 'tkk': 'twm', + 'tlw': 'weo', + 'tmp': 'tyj', + 'tne': 'kak', + 'tnf': 'prs', + 'tsf': 'taj', + 'uok': 'ema', + 'xba': 'cax', + 'xia': 'acn', + 'xkh': 'waw', + 'xsj': 'suj', + 'ybd': 'rki', + 'yma': 'lrr', + 'ymt': 'mtm', + 'yos': 'zom', + 'yuu': 'yug', +}; + +/// `dart:ui`'s `Locale._deprecatedRegionSubtagMap`: a deprecated region +/// subtag and the one `Locale.countryCode` gives for it. +const _deprecatedRegions = { + 'BU': 'MM', + 'DD': 'DE', + 'FX': 'FR', + 'TP': 'TL', + 'YD': 'YE', + 'ZR': 'CD', +}; diff --git a/lib/src/api/shoot.dart b/lib/src/api/shoot.dart index 79055d4..fbdfcf0 100644 --- a/lib/src/api/shoot.dart +++ b/lib/src/api/shoot.dart @@ -15,6 +15,7 @@ import '../run/run_store.dart'; import '../run/timestamp_id.dart'; import '../scan/scanner.dart'; import '../shutter_exception.dart'; +import 'locale_codes.dart'; import 'shot.dart'; /// Shoots [subject] as [shot] does, in the project at or above the @@ -37,6 +38,7 @@ Future shoot( SafeAreaInsets? safeArea, ShotBrightness brightness = ShotBrightness.light, double textScale = 1, + String? locale, bool semantics = false, void Function(ShotProgress event)? onProgress, }) async { @@ -62,6 +64,7 @@ Future shoot( 'text scale must be > 0 and finite (got ${formatNumber(textScale)}).', ); } + final codes = locale == null ? null : localeCodes(locale); _checkActions(actions); final viewport = switch (capture) { ScreenCapture(:final viewport?) => _size(viewport, 'viewport'), @@ -158,6 +161,7 @@ Future shoot( safeArea: insets, brightness: brightness, textScale: textScale, + locale: codes, onProgress: onProgress, ), ); @@ -178,6 +182,7 @@ Future shoot( safeArea: insets, brightness: brightness, textScale: textScale, + locale: codes == null ? null : languageTag(codes), ), )..write(runDir); return StoredRun(runDir, manifest); diff --git a/lib/src/api/shot.dart b/lib/src/api/shot.dart index b0ecada..c740241 100644 --- a/lib/src/api/shot.dart +++ b/lib/src/api/shot.dart @@ -302,6 +302,9 @@ final class const ShotResult({ /// without one. /// - [brightness], [textScale]: the platform brightness and text scale /// (> 0) reported to every preview that sets none of its own. +/// - [locale]: the locale reported as the device's, a language tag of a +/// language, an optional script, and an optional region, e.g. `ja-JP` +/// or `zh-Hant-TW`; none keeps `flutter test`'s en-US. /// - [onProgress]: told of what the run is doing as it does it. Future shot( String projectDir, @@ -317,6 +320,7 @@ Future shot( SafeAreaInsets? safeArea, ShotBrightness brightness = ShotBrightness.light, double textScale = 1, + String? locale, bool semantics = false, void Function(ShotProgress event)? onProgress, }) => shoot( @@ -333,6 +337,7 @@ Future shot( safeArea: safeArea, brightness: brightness, textScale: textScale, + locale: locale, semantics: semantics, onProgress: onProgress, ).then(shotRun); diff --git a/lib/src/cli/agent_text.dart b/lib/src/cli/agent_text.dart index 4ba91be..dc7e9c0 100644 --- a/lib/src/cli/agent_text.dart +++ b/lib/src/cli/agent_text.dart @@ -52,6 +52,7 @@ Widget settingsSignedOut() => const SettingsPage(user: null); * Shots are drawn at a device pixel ratio of 2. For the device a screen is laid out for, pass its ratio to the before and after shots alike, `--pixel-ratio 3` for an iPhone: an `AssetImage` then picks that ratio's variant. The PNG is the shot's `size` times the ratio; when you post it, show it at `size`'s width. `diff` prints `pixel_ratio:` when the two runs differ in it. * Shots are laid out without a safe area, as no phone is. For a screen, pass the device's insets, top and bottom in logical pixels, to the before and after shots alike, `--safe-area 59,34` for example (the device's `MediaQuery.viewPaddingOf(context)`): the `AppBar` then grows under the status bar and a `SafeArea` keeps out of the insets as on the device. The status bar and the home indicator are not drawn; say so when you post the image. * For a change to a dark theme or to how text grows, shoot with `--brightness dark` or `--text-scale 1.3` as well, the same in the before and after runs. They reach every preview that sets no `brightness` or `textScaleFactor` of its own, and they are the only way to shoot a `--widget` dark or with larger text. A dark shot needs a shell that sets the app's `darkTheme`. The scale multiplies every font size, as iOS does; Android 14 and later scale their own way, so say so when you post an Android shot at a scale other than 1. `diff` prints `brightness:` and `text_scale:` when the two runs differ in them. +* Shots report en-US as the device's locale. For an app used in another language, pass `--locale ja-JP` (a language, then an optional script and region) to the before and after shots alike: the shell's app widget then resolves its locale from it, as on a device set to that language, if the shell sets the app's `supportedLocales` and `localizationsDelegates`. `diff` prints `locale:` when the two runs differ in it. ## Shell diff --git a/lib/src/cli/manual_text.dart b/lib/src/cli/manual_text.dart index ce95bbb..51ac25f 100644 --- a/lib/src/cli/manual_text.dart +++ b/lib/src/cli/manual_text.dart @@ -129,6 +129,16 @@ A `--widget` shot has no `Preview` of its own to set them, so these options are The run records `brightness: dark` and `text_scale` in `manifest.json` when not the default; `shot` prints them, and `diff` prints both runs' when they differ, since they change the image without any widget changing. They are not part of shot ids, so a run with `--brightness dark` pairs shot for shot with one without. +## Locale + +`flutter test` reports en-US as the device's locale. +`--locale ` reports another: a language, then an optional script and region, joined by `-` or `_`, such as `ja-JP` or `zh-Hant-TW`. +What resolves its locale from the device's resolves it from this one, as on a device with that language set: the shell's app widget, against its `supportedLocales`, and a preview's `localizations` that set no `locale` of their own. +An app widget that does not support the locale falls back as on a device: the default shell's `MaterialApp`, which supports only en-US, stays in English, so shoot through a shell that sets the app's `supportedLocales` and `localizationsDelegates`. + +The run records `locale` in `manifest.json` as the app receives it: each code in its usual case (`zh-Hant-TW`), and a deprecated language or region replaced as Flutter's `Locale` replaces it (`iw-IL` is `he-IL`); `shot` prints it, and `diff` prints both runs' when they differ, since the locale changes the text without any widget changing. +It is not part of shot ids, so a run with `--locale` pairs shot for shot with one without. + ## The semantics tree `shutter semantics` takes the same preview files, `--widget`, and options as `shot`, except `--keyboard-tint`, and shoots the same way, actions included. @@ -303,7 +313,7 @@ When Flutter ships a capture command in the previewer itself, it replaces v1 wit ## Runs -`.dart_tool/shutter/runs//` holds `.png` per shot and `manifest.json` (`run`, `image` for a `semantics` run, `shell` when a shell file was used, `platform` for a run as iOS, `pixel_ratio` for a ratio other than 2, `safe_area`, `brightness` when dark, `text_scale` other than 1, `actions`, `capture`, `viewport`, `keyboard`, `keyboard_tint`, and `network_images` when given, `shots`). +`.dart_tool/shutter/runs//` holds `.png` per shot and `manifest.json` (`run`, `image` for a `semantics` run, `shell` when a shell file was used, `platform` for a run as iOS, `pixel_ratio` for a ratio other than 2, `safe_area`, `brightness` when dark, `text_scale` other than 1, `locale`, `actions`, `capture`, `viewport`, `keyboard`, `keyboard_tint`, and `network_images` when given, `shots`). `` is the UTC time of the shot (`20260918T101530Z`), suffixed `-2`, `-3`, ... when taken; a hidden `.` file claims the name, so runs started in the same second get distinct ids. `shot` prints the run directory as `run:`; `diff` accepts a run's directory, its id, `latest` for the newest run, or `latest~N` for the run N before it. `latest` counts runs in id order (time, then suffix) and skips a run still being shot, whose `manifest.json` is not written yet. @@ -349,9 +359,9 @@ When the two runs were shot with different shells (path or sha256), `shell` show ## Output `shot`, `semantics`, and `diff` print YAML starting with the comment `# shutter ai-report v1`, with absolute paths to open. -`shot` gives `run`, `shell` (the shell file with its sha256, or `default`), `platform` (as iOS), `pixel_ratio` (other than 2), `safe_area`, `brightness` (dark), `text_scale` (other than 1), `actions`, `capture`, `viewport`, `keyboard`, `keyboard_tint`, and `network_images` (when given), `summary`, and `shots`, errors first. +`shot` gives `run`, `shell` (the shell file with its sha256, or `default`), `platform` (as iOS), `pixel_ratio` (other than 2), `safe_area`, `brightness` (dark), `text_scale` (other than 1), `locale`, `actions`, `capture`, `viewport`, `keyboard`, `keyboard_tint`, and `network_images` (when given), `summary`, and `shots`, errors first. `semantics` gives the same, with `image: semantics` after `shell`. -`diff` gives `diff` (with `--images` or `--composite`), `before`, `after`, `shell`, `image`, `platform`, `pixel_ratio`, `safe_area`, `brightness`, `text_scale`, `actions`, `capture`, `viewport`, `keyboard`, `keyboard_tint`, and `network_images` (each when the two runs differ in it; `image` is `paint` for a `shot` run), `summary`, and `entries` in the order changed → added → removed → unchanged. +`diff` gives `diff` (with `--images` or `--composite`), `before`, `after`, `shell`, `image`, `platform`, `pixel_ratio`, `safe_area`, `brightness`, `text_scale`, `locale`, `actions`, `capture`, `viewport`, `keyboard`, `keyboard_tint`, and `network_images` (each when the two runs differ in it; `image` is `paint` for a `shot` run), `summary`, and `entries` in the order changed → added → removed → unchanged. ## Exit codes @@ -366,14 +376,14 @@ Other failures follow sysexits: 64 usage, 66 missing run or file, 69 no Flutter ## Commands -| command | purpose | -| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `agent` | the step-by-step playbook | -| `manual` | this document | -| `doctor` | SDK version, font cache, shell (`--shell`) | -| `init` | write `shell.dart` (`--shell`) | -| `shot` | render the named preview files, or one `--widget`, into a new run (`--widget`/`--import`/`--size`, `--settle`, `--shell`, `--tap`/`--enter`/`--press`/`--hover`/`--focus`, `--capture`/`--viewport`, `--keyboard`/`--keyboard-tint`, `--network-image`, `--platform`, `--pixel-ratio`, `--safe-area`, `--brightness`/`--text-scale`) | -| `semantics` | draw the semantics tree of the same subjects into a new run, with the options of `shot` except `--keyboard-tint` | -| `diff` | compare two runs (`--images`, `--composite`/`--direction`/`--title`) | -| `prune` | delete every run but the newest (`--keep`) | +| command | purpose | +| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `agent` | the step-by-step playbook | +| `manual` | this document | +| `doctor` | SDK version, font cache, shell (`--shell`) | +| `init` | write `shell.dart` (`--shell`) | +| `shot` | render the named preview files, or one `--widget`, into a new run (`--widget`/`--import`/`--size`, `--settle`, `--shell`, `--tap`/`--enter`/`--press`/`--hover`/`--focus`, `--capture`/`--viewport`, `--keyboard`/`--keyboard-tint`, `--network-image`, `--platform`, `--pixel-ratio`, `--safe-area`, `--brightness`/`--text-scale`, `--locale`) | +| `semantics` | draw the semantics tree of the same subjects into a new run, with the options of `shot` except `--keyboard-tint` | +| `diff` | compare two runs (`--images`, `--composite`/`--direction`/`--title`) | +| `prune` | delete every run but the newest (`--keep`) | '''; diff --git a/lib/src/cli/shot_command.dart b/lib/src/cli/shot_command.dart index 91c63e7..c846674 100644 --- a/lib/src/cli/shot_command.dart +++ b/lib/src/cli/shot_command.dart @@ -155,6 +155,12 @@ class ShotCommand(final ShutterContext context, {final bool semantics = false}) 'Platform text scale reported to every preview that sets none ' 'of its own, e.g. 1.3.', defaultsTo: '1', + ) + ..addOption( + 'locale', + help: + 'Locale reported as the device\'s, as a language, an optional ' + 'script, and an optional region, e.g. ja-JP or zh-Hant-TW.', ); } @@ -246,6 +252,7 @@ class ShotCommand(final ShutterContext context, {final bool semantics = false}) safeArea: safeArea, brightness: brightness, textScale: textScale, + locale: args.option('locale'), semantics: semantics, ); reportShots(run.manifest, run.dir, ShutterIO.stdoutSink); diff --git a/lib/src/engine/engine.dart b/lib/src/engine/engine.dart index 4d3a71f..86a0117 100644 --- a/lib/src/engine/engine.dart +++ b/lib/src/engine/engine.dart @@ -63,6 +63,10 @@ class const CaptureRequest({ final ShotBrightness brightness = ShotBrightness.light, final double textScale = 1, + /// The locale reported as the device's: language, script, and country + /// codes. + final (String, String?, String?)? locale, + /// Told of each render as it starts, each shot as it finishes, and each /// font download. final void Function(ShotProgress event)? onProgress, diff --git a/lib/src/engine/generator.dart b/lib/src/engine/generator.dart index 63f5c33..aea2b7b 100644 --- a/lib/src/engine/generator.dart +++ b/lib/src/engine/generator.dart @@ -205,6 +205,13 @@ String mainSource(List helpers, GeneratorConfig config) { if (request.textScale != 1) { buffer.writeln(' textScale: ${request.textScale},'); } + if (request.locale case (final language, final script, final country)?) { + final codes = [ + for (final code in [language, script, country]) + code == null ? 'null' : dartString(code), + ]; + buffer.writeln(' locale: (${codes.join(', ')}),'); + } if (config.fallbackFonts.isNotEmpty) { buffer.writeln(' fallbackFonts: {'); for (final MapEntry(:key, :value) in config.fallbackFonts.entries) { diff --git a/lib/src/engine/harness_text.dart b/lib/src/engine/harness_text.dart index f4672dd..92b941b 100644 --- a/lib/src/engine/harness_text.dart +++ b/lib/src/engine/harness_text.dart @@ -79,6 +79,10 @@ class const ShutterConfig({ final Brightness? brightness, final double? textScale, + /// The locale reported as the device's, by language, script, and + /// country code; flutter_test's en-US when null. + final (String, String?, String?)? locale, + /// Host font files by family, in fallback order, chosen by the CLI. final Map> fallbackFonts = const {}, }); @@ -359,6 +363,17 @@ Future _capture( if (preview.textScaleFactor ?? config.textScale case final scale?) { tester.platformDispatcher.textScaleFactorTestValue = scale; } + // The device's locale, which an app widget, or a preview's own + // localizations without a locale, resolves its locale from. + if (config.locale case (final language, final script, final country)?) { + final locale = Locale.fromSubtags( + languageCode: language, + scriptCode: script, + countryCode: country, + ); + tester.platformDispatcher.localesTestValue = [locale]; + tester.platformDispatcher.localeTestValue = locale; + } Widget child = _subject(entry); final wrapper = preview.wrapper; diff --git a/lib/src/reporters/diff_reporter.dart b/lib/src/reporters/diff_reporter.dart index c9c46e5..c9d821a 100644 --- a/lib/src/reporters/diff_reporter.dart +++ b/lib/src/reporters/diff_reporter.dart @@ -50,6 +50,7 @@ void reportDiff(RunDiff diff, String? dir, IOSink sink) { formatNumber(before.textScale), formatNumber(after.textScale), ), + ('locale', formatLocale(before.locale), formatLocale(after.locale)), ('actions', formatActions(before.actions), formatActions(after.actions)), ('capture', formatCapture(before.screen), formatCapture(after.screen)), ( diff --git a/lib/src/reporters/format.dart b/lib/src/reporters/format.dart index 0fb1b40..7a6863d 100644 --- a/lib/src/reporters/format.dart +++ b/lib/src/reporters/format.dart @@ -63,11 +63,18 @@ String formatSafeArea((double, double)? safeArea) => switch (safeArea) { null => 'none', }; +/// `ja-JP`, or `default` for a run shot without `--locale`. +String formatLocale(String? locale) => switch (locale) { + // Quoted when YAML would read it otherwise: `no` (Norwegian) is false. + final tag? => yamlScalar(tag), + null => 'default', +}; + /// `semantics` for a run of `shutter semantics`, else `paint`. String formatImage(bool semantics) => semantics ? 'semantics' : 'paint'; /// The `image`, `platform`, `pixel_ratio`, `safe_area`, `brightness`, -/// `text_scale`, `actions`, `capture`, `viewport`, `keyboard`, +/// `text_scale`, `locale`, `actions`, `capture`, `viewport`, `keyboard`, /// `keyboard_tint`, and `network_images` lines of a run's [setup], each /// only when set (`platform` when not Android, `pixel_ratio`, /// `brightness`, and `text_scale` when not the default). @@ -88,6 +95,9 @@ void writeSetup(StringBuffer body, RunSetup setup) { if (setup.textScale != 1) { body.writeln('text_scale: ${formatNumber(setup.textScale)}'); } + if (setup.locale case final locale?) { + body.writeln('locale: ${formatLocale(locale)}'); + } if (setup.actions.isNotEmpty) { body.writeln('actions: ${formatActions(setup.actions)}'); } diff --git a/lib/src/run/manifest.dart b/lib/src/run/manifest.dart index 14dbc99..8f8922e 100644 --- a/lib/src/run/manifest.dart +++ b/lib/src/run/manifest.dart @@ -138,8 +138,9 @@ enum ShotBrightness { light, dark } /// URL, whether the images are of the semantics tree /// (`shutter semantics`) rather than of what the app paints, the /// `--platform` the app was drawn as, the `--pixel-ratio` it was drawn -/// at, the `--safe-area` insets, top and bottom, and the `--brightness` -/// and `--text-scale` reported to every preview that sets none. +/// at, the `--safe-area` insets, top and bottom, the `--brightness` and +/// `--text-scale` reported to every preview that sets none, and the +/// `--locale` reported as the device's, as a language tag (`ja-JP`). typedef RunSetup = ({ List actions, bool screen, @@ -153,6 +154,7 @@ typedef RunSetup = ({ (double, double)? safeArea, ShotBrightness brightness, double textScale, + String? locale, }); /// [argb] as `#AARRGGBB`. @@ -176,6 +178,7 @@ const RunSetup plainSetup = ( safeArea: null, brightness: ShotBrightness.light, textScale: 1, + locale: null, ); RecordedFile? _recordedFile(Object? json) => switch (json) { @@ -247,6 +250,7 @@ class const RunManifest({ _ => ShotBrightness.light, }, textScale: (json['text_scale'] as num?)?.toDouble() ?? 1, + locale: json['locale'] as String?, ), ); @@ -273,6 +277,7 @@ class const RunManifest({ :safeArea, :brightness, :textScale, + :locale, ) = setup; return { 'run': run, @@ -285,6 +290,7 @@ class const RunManifest({ 'safe_area': [jsonNumber(top), jsonNumber(bottom)], if (brightness != ShotBrightness.light) 'brightness': brightness.name, if (textScale != 1) 'text_scale': jsonNumber(textScale), + 'locale': ?locale, if (actions.isNotEmpty) 'actions': actions, if (screen) 'capture': 'screen', if (viewport case (final w, final h)?) diff --git a/test/api/diff_test.dart b/test/api/diff_test.dart index d58df33..07c0c7c 100644 --- a/test/api/diff_test.dart +++ b/test/api/diff_test.dart @@ -94,6 +94,7 @@ void main() { safeArea: (59, 34), brightness: ShotBrightness.dark, textScale: 1.3, + locale: 'ja-JP', ), ); @@ -121,6 +122,7 @@ void main() { safeArea: SafeAreaInsets(top: 59, bottom: 34), brightness: ShotBrightness.dark, textScale: 1.3, + locale: 'ja-JP', ), entries: [ ShotDiffEntry( diff --git a/test/api/shot_test.dart b/test/api/shot_test.dart index 5e1886a..eeea108 100644 --- a/test/api/shot_test.dart +++ b/test/api/shot_test.dart @@ -65,6 +65,7 @@ void main() { safeArea: const SafeAreaInsets(top: 59, bottom: 34), brightness: ShotBrightness.dark, textScale: 1.3, + locale: 'zh_hant_tw', ); final run = shotRun(stored); final dir = p.join(root, '.dart_tool', 'shutter', 'runs', run.id); @@ -133,9 +134,25 @@ void main() { (ShotBrightness.dark, 1.3), ); expect((setup.brightness, setup.textScale), (ShotBrightness.dark, 1.3)); + // Each code in its usual case, joined as a language tag. + expect(request.locale, ('zh', 'Hant', 'TW')); + expect(setup.locale, 'zh-Hant-TW'); }, ); + test('a deprecated language or region is recorded as the app receives ' + 'it', () async { + final root = createProject(); + final engine = FakeEngine(const []); + final stored = await shoot( + fakeContext(root, engine: engine), + const WidgetExpression('B()'), + locale: 'iw-BU', + ); + expect(engine.requests.single.locale, ('he', null, 'MM')); + expect(stored.manifest.setup.locale, 'he-MM'); + }); + test('paths are relative to the project directory given', () async { final root = createProject(files: {'lib/ui/b.dart': ''}); final engine = FakeEngine(const []); @@ -185,6 +202,7 @@ void main() { expect(request.pixelRatio, defaultPixelRatio); expect(request.safeArea, isNull); expect((request.brightness, request.textScale), (ShotBrightness.light, 1)); + expect(request.locale, isNull); final unbounded = FakeEngine(const []); await shoot( @@ -208,6 +226,7 @@ void main() { double pixelRatio = defaultPixelRatio, SafeAreaInsets? safeArea, double textScale = 1, + String? locale, bool semantics = false, }) => shoot( fakeContext(root), @@ -219,6 +238,7 @@ void main() { pixelRatio: pixelRatio, safeArea: safeArea, textScale: textScale, + locale: locale, semantics: semantics, ); await expectLater( @@ -248,6 +268,12 @@ void main() { throwsA(usageError('text scale must be > 0 and finite')), ); } + for (final locale in ['', 'j', 'japanese', 'ja-JPN', 'ja-JP-x', 'ja JP']) { + await expectLater( + run(locale: locale), + throwsA(usageError('locale must be a language, then an optional')), + ); + } await expectLater( run(subject: const PreviewFiles([])), throwsA(usageError('PreviewFiles names no file.')), diff --git a/test/api/value_test.dart b/test/api/value_test.dart index d3e384f..1e2671a 100644 --- a/test/api/value_test.dart +++ b/test/api/value_test.dart @@ -178,6 +178,7 @@ void main() { safeArea: SafeAreaInsets(top: 59, bottom: 34), brightness: ShotBrightness.dark, textScale: 1.3, + locale: 'ja-JP', ); valueType( () => setup(action: fresh()), @@ -194,7 +195,7 @@ void main() { 'ShotFile(path: a.png, sha256: b)}, ' 'semantics: true, platform: ios, pixelRatio: 2.625, ' 'safeArea: SafeAreaInsets(top: 59, bottom: 34), brightness: dark, ' - 'textScale: 1.3)', + 'textScale: 1.3, locale: ja-JP)', ); expect( '${const ShotSetup()}', @@ -208,6 +209,7 @@ void main() { isNot(const ShotSetup()), ); expect(const ShotSetup(textScale: 2), isNot(const ShotSetup())); + expect(const ShotSetup(locale: 'ja-JP'), isNot(const ShotSetup())); title = '/app/runs/r2/a.0.png'; const shot = ShotResult(id: 'a.0', status: ShotStatus.ok, name: 'a'); ShotDiffEntry entry(String png) => ShotDiffEntry( diff --git a/test/cli/shot_command_test.dart b/test/cli/shot_command_test.dart index 2fcb1cd..35a738f 100644 --- a/test/cli/shot_command_test.dart +++ b/test/cli/shot_command_test.dart @@ -292,10 +292,13 @@ void main() { 'dark', '--text-scale', '1.3', + '--locale', + 'ja_jp', ], fakeContext(root, engine: engine)); expect(result.exitCode, 0, reason: result.stderr); final request = engine.requests.single; expect((request.brightness, request.textScale), (ShotBrightness.dark, 1.3)); + expect(request.locale, ('ja', null, 'JP')); expect(request.platform, ShotPlatform.ios); expect(request.pixelRatio, 2.625); expect(request.safeArea, (59.0, 34.5)); @@ -332,6 +335,7 @@ void main() { expect(report['pixel_ratio'], 2.625); expect(report['safe_area'], [59, 34.5]); expect((report['brightness'], report['text_scale']), ('dark', 1.3)); + expect(report['locale'], 'ja-JP'); final manifest = RunManifest.read(report['run'] as String); final setup = manifest.setup; expect(setup.actions, labels); @@ -342,6 +346,7 @@ void main() { expect(setup.pixelRatio, 2.625); expect(setup.safeArea, (59.0, 34.5)); expect((setup.brightness, setup.textScale), (ShotBrightness.dark, 1.3)); + expect(setup.locale, 'ja-JP'); for (final (flag, kind) in [ ('--hover', ActionKind.hover), @@ -367,6 +372,7 @@ void main() { (request.brightness, request.textScale), (ShotBrightness.light, 1), ); + expect(request.locale, isNull); } }); @@ -529,6 +535,12 @@ void main() { expect(bad.exitCode, 64, reason: scale); expect(bad.stderr, contains('text scale must be > 0 and finite')); } + final japanese = await run(['--widget', 'x', '--locale', 'Japanese']); + expect(japanese.exitCode, 64); + expect( + japanese.stderr, + contains('locale must be a language, then an optional script and region'), + ); final dim = await run(['--widget', 'x', '--brightness', 'dim']); expect(dim.exitCode, 64); expect(dim.stderr, contains('"dim" is not an allowed value')); diff --git a/test/diff/diff_engine_test.dart b/test/diff/diff_engine_test.dart index 5fc0ccf..8462dc3 100644 --- a/test/diff/diff_engine_test.dart +++ b/test/diff/diff_engine_test.dart @@ -55,6 +55,7 @@ void main() { safeArea: null, brightness: ShotBrightness.light, textScale: 1, + locale: null, ), )..write(dir), ); diff --git a/test/e2e/device_test.dart b/test/e2e/device_test.dart index abc56eb..18fe421 100644 --- a/test/e2e/device_test.dart +++ b/test/e2e/device_test.dart @@ -293,4 +293,58 @@ Widget own() => _appearance(); }; expect(entries, {'Device': 'changed', 'Own': 'unchanged'}); }); + + test("--locale is the device's: the platform reports it, and a preview's " + 'localizations without a locale resolve to it', () async { + final root = await shared(); + File(p.join(root, 'lib/preview/locale_preview.dart')).writeAsStringSync(''' +import 'package:flutter/widget_previews.dart'; +import 'package:flutter/widgets.dart'; + +Widget _box(bool on) => ColoredBox( + color: on ? const Color(0xFF0000FF) : const Color(0xFF00FF00), + child: const SizedBox(width: 10, height: 10), +); + +PreviewLocalizationsData englishOrJapanese() => const PreviewLocalizationsData( + supportedLocales: [Locale('en'), Locale('ja')], + localizationsDelegates: [DefaultWidgetsLocalizations.delegate], +); + +@Preview(name: 'Platform', size: Size(10, 10)) +Widget platform() => Builder( + builder: (context) => _box( + View.of(context).platformDispatcher.locale == const Locale('ja', 'JP'), + ), +); + +@Preview(name: 'Resolved', size: Size(10, 10), localizations: englishOrJapanese) +Widget resolved() => Builder( + builder: (context) => + _box(Localizations.localeOf(context).languageCode == 'ja'), +); +'''); + const file = 'lib/preview/locale_preview.dart'; + final (plain, plainShots) = await shootIn(root, [file]); + final (japanese, japaneseShots) = await shootIn(root, [ + file, + '--locale', + 'ja-JP', + ]); + for (final shots in [plainShots, japaneseShots]) { + for (final shot in shots.values) { + expect(shot.status, ShotStatus.ok, reason: shot.error); + } + } + for (final name in ['Platform', 'Resolved']) { + expect(_color(_png(plain, plainShots[name]!), 10, 10), (0, 255, 0)); + expect(_color(_png(japanese, japaneseShots[name]!), 10, 10), (0, 0, 255)); + } + expect(RunManifest.read(japanese).setup.locale, 'ja-JP'); + + final diff = await shutter(root, ['diff', plain, japanese]); + expect(diff.exitCode, 1, reason: diff.stdout + diff.stderr); + final report = loadYaml(diff.stdout) as YamlMap; + expect(report['locale'], {'before': 'default', 'after': 'ja-JP'}); + }); } diff --git a/test/engine/generator_test.dart b/test/engine/generator_test.dart index b9392ce..69ec4a3 100644 --- a/test/engine/generator_test.dart +++ b/test/engine/generator_test.dart @@ -146,6 +146,7 @@ void main() { safeArea: (59, 34.5), brightness: ShotBrightness.dark, textScale: 1.3, + locale: ('zh', 'Hant', 'TW'), ), materialFontsDir: '/sdk/fonts', design: const DesignSupport(available: [], shell: null), @@ -174,7 +175,8 @@ void main() { ' pixelRatio: 3.0,\n' ' safeArea: (59.0, 34.5),\n' ' brightness: .dark,\n' - ' textScale: 1.3,\n', + ' textScale: 1.3,\n' + " locale: ('zh', 'Hant', 'TW'),\n", ), ); final plain = mainSource(const [], config(project)); @@ -189,6 +191,24 @@ void main() { expect(plain, isNot(contains('safeArea:'))); expect(plain, isNot(contains('brightness:'))); expect(plain, isNot(contains('textScale:'))); + expect(plain, isNot(contains('locale:'))); + final japanese = mainSource( + const [], + GeneratorConfig( + request: CaptureRequest( + project: project, + libraries: const [], + runDir: '/runs/r1', + settleMs: 300, + locale: ('ja', null, 'JP'), + ), + materialFontsDir: '/sdk/fonts', + design: const DesignSupport(available: [], shell: null), + googleFonts: false, + fonts: const [], + ), + ); + expect(japanese, contains(" locale: ('ja', null, 'JP'),\n")); }); test('a --widget helper imports unprefixed, escapes the expression in the ' diff --git a/test/reporters/diff_reporter_test.dart b/test/reporters/diff_reporter_test.dart index 949bb40..6c62aca 100644 --- a/test/reporters/diff_reporter_test.dart +++ b/test/reporters/diff_reporter_test.dart @@ -182,6 +182,7 @@ entries: safeArea: null, brightness: ShotBrightness.light, textScale: 1.0, + locale: null, ); const screen = ( actions: ['press text:OK'], @@ -196,6 +197,7 @@ entries: safeArea: null, brightness: ShotBrightness.light, textScale: 1.0, + locale: null, ); final same = await report(pressed, pressed); for (final key in [ @@ -205,6 +207,7 @@ entries: 'safe_area', 'brightness', 'text_scale', + 'locale', 'actions', 'capture', 'viewport', @@ -241,6 +244,7 @@ entries: safeArea: null, brightness: ShotBrightness.light, textScale: 1.0, + locale: null, ); final keyboard = await report(plainSetup, typing); expect(keyboard['keyboard'], {'before': 'none', 'after': 336}); @@ -259,6 +263,7 @@ entries: safeArea: null, brightness: ShotBrightness.light, textScale: 1.0, + locale: null, ); final tint = await report(typing, tinted); expect(tint.containsKey('keyboard'), isFalse); @@ -278,6 +283,7 @@ entries: safeArea: null, brightness: ShotBrightness.light, textScale: 1.0, + locale: null, ); final images = await report(plainSetup, served); expect(images['network_images'], { @@ -299,6 +305,7 @@ entries: safeArea: null, brightness: ShotBrightness.light, textScale: 1.0, + locale: null, ); final image = await report(plainSetup, tree); expect(image['image'], {'before': 'paint', 'after': 'semantics'}); @@ -316,6 +323,7 @@ entries: safeArea: null, brightness: ShotBrightness.light, textScale: 1.0, + locale: null, ); final platform = await report(plainSetup, ios); expect(platform['platform'], {'before': 'android', 'after': 'ios'}); @@ -334,6 +342,7 @@ entries: safeArea: null, brightness: ShotBrightness.light, textScale: 1.0, + locale: null, ); final ratio = await report(plainSetup, dense); expect(ratio['pixel_ratio'], {'before': 2, 'after': 2.625}); @@ -352,6 +361,7 @@ entries: safeArea: (59.0, 34.0), brightness: ShotBrightness.light, textScale: 1.0, + locale: null, ); final safeArea = await report(plainSetup, notched); expect(safeArea['safe_area'], { @@ -374,11 +384,62 @@ entries: safeArea: null, brightness: ShotBrightness.dark, textScale: 1.3, + locale: null, ); final appearance = await report(plainSetup, darkLarge); expect(appearance['brightness'], {'before': 'light', 'after': 'dark'}); expect(appearance['text_scale'], {'before': 1, 'after': 1.3}); expect(appearance.containsKey('safe_area'), isFalse); + expect(appearance.containsKey('locale'), isFalse); + const japanese = ( + actions: [], + screen: false, + viewport: null, + keyboard: null, + keyboardTint: null, + networkImages: {}, + semantics: false, + platform: ShotPlatform.android, + pixelRatio: defaultPixelRatio, + safeArea: null, + brightness: ShotBrightness.light, + textScale: 1.0, + locale: 'ja-JP', + ); + final locale = await report(plainSetup, japanese); + expect(locale['locale'], {'before': 'default', 'after': 'ja-JP'}); + expect(locale.containsKey('brightness'), isFalse); + // Norwegian's `no` is quoted: a YAML 1.1 reader takes `no` for false. + final norwegian = await collect( + (sink) => reportDiff( + const RunDiff( + before: 'a', + after: 'b', + entries: [], + afterSetup: ( + actions: [], + screen: false, + viewport: null, + keyboard: null, + keyboardTint: null, + networkImages: {}, + semantics: false, + platform: ShotPlatform.android, + pixelRatio: defaultPixelRatio, + safeArea: null, + brightness: ShotBrightness.light, + textScale: 1, + locale: 'no', + ), + ), + null, + sink, + ), + ); + expect( + norwegian, + contains('locale:\n before: default\n after: "no"\n'), + ); }, ); diff --git a/test/reporters/shot_reporter_test.dart b/test/reporters/shot_reporter_test.dart index 7792948..cbd23fb 100644 --- a/test/reporters/shot_reporter_test.dart +++ b/test/reporters/shot_reporter_test.dart @@ -99,6 +99,7 @@ shots: safeArea: (59, 34.5), brightness: ShotBrightness.dark, textScale: 1.3, + locale: 'zh-Hant-TW', ), ), ); @@ -112,6 +113,7 @@ shots: 'safe_area: [59, 34.5]\n' 'brightness: dark\n' 'text_scale: 1.3\n' + 'locale: zh-Hant-TW\n' 'actions: ["tap text:Open", "press key:save"]\n' 'capture: screen\n' 'viewport: [390, 844]\n' @@ -140,6 +142,7 @@ shots: safeArea: null, brightness: ShotBrightness.light, textScale: 1, + locale: null, ), ), ); diff --git a/test/run/manifest_test.dart b/test/run/manifest_test.dart index 225ced4..529610a 100644 --- a/test/run/manifest_test.dart +++ b/test/run/manifest_test.dart @@ -91,6 +91,7 @@ void main() { safeArea: (59, 34.5), brightness: ShotBrightness.dark, textScale: 1.3, + locale: 'ja-JP', ), ).write(dir); final back = RunManifest.read(dir); @@ -107,6 +108,7 @@ void main() { expect(setup.pixelRatio, 2.625); expect(setup.safeArea, (59.0, 34.5)); expect((setup.brightness, setup.textScale), (ShotBrightness.dark, 1.3)); + expect(setup.locale, 'ja-JP'); expect(back.toJson().keys, [ 'run', 'image', @@ -115,6 +117,7 @@ void main() { 'safe_area', 'brightness', 'text_scale', + 'locale', 'actions', 'capture', 'viewport', @@ -147,5 +150,6 @@ void main() { (plain.setup.brightness, plain.setup.textScale), (ShotBrightness.light, 1), ); + expect(plain.setup.locale, isNull); }); }