diff --git a/lib/alchemist.dart b/lib/alchemist.dart index c9b6b6d..15a2773 100644 --- a/lib/alchemist.dart +++ b/lib/alchemist.dart @@ -1,5 +1,6 @@ export 'src/alchemist_config.dart'; export 'src/blocked_text_image.dart'; +export 'src/golden_metadata.dart'; export 'src/golden_test.dart'; export 'src/golden_test_group.dart'; export 'src/golden_test_scenario.dart'; diff --git a/lib/src/alchemist_config.dart b/lib/src/alchemist_config.dart index b2c6a03..9cdefa1 100644 --- a/lib/src/alchemist_config.dart +++ b/lib/src/alchemist_config.dart @@ -69,11 +69,15 @@ class AlchemistConfig extends Equatable { ThemeData? theme, PlatformGoldensConfig? platformGoldensConfig, CiGoldensConfig? ciGoldensConfig, + bool? metadataEnabled, + bool? semanticsEnabled, }) : _forceUpdateGoldenFiles = forceUpdateGoldenFiles, _theme = theme, _goldenTestTheme = goldenTestTheme, _platformGoldensConfig = platformGoldensConfig, - _ciGoldensConfig = ciGoldensConfig; + _ciGoldensConfig = ciGoldensConfig, + _metadataEnabled = metadataEnabled, + _semanticsEnabled = semanticsEnabled; /// The instance of the [AlchemistConfig] in the current zone used by the /// `alchemist` package. @@ -200,6 +204,20 @@ class AlchemistConfig extends Equatable { _ciGoldensConfig ?? const CiGoldensConfig(); final CiGoldensConfig? _ciGoldensConfig; + /// Whether to generate JSON metadata files alongside golden images. + /// + /// When enabled, a `.json` file is written next to each golden `.png` + /// containing exact pixel bounds for each scenario. + bool get metadataEnabled => _metadataEnabled ?? false; + final bool? _metadataEnabled; + + /// Whether to include semantics tree data in the metadata output. + /// + /// Requires [metadataEnabled] to be `true`. When enabled, each scenario's + /// metadata includes accessibility tree data (labels, roles, actions). + bool get semanticsEnabled => _semanticsEnabled ?? false; + final bool? _semanticsEnabled; + /// Creates a copy of this [AlchemistConfig] and replaces the given fields. AlchemistConfig copyWith({ bool? forceUpdateGoldenFiles, @@ -207,6 +225,8 @@ class AlchemistConfig extends Equatable { GoldenTestTheme? goldenTestTheme, PlatformGoldensConfig? platformGoldensConfig, CiGoldensConfig? ciGoldensConfig, + bool? metadataEnabled, + bool? semanticsEnabled, }) { return AlchemistConfig( forceUpdateGoldenFiles: forceUpdateGoldenFiles ?? _forceUpdateGoldenFiles, @@ -214,6 +234,8 @@ class AlchemistConfig extends Equatable { goldenTestTheme: goldenTestTheme ?? _goldenTestTheme, platformGoldensConfig: platformGoldensConfig ?? _platformGoldensConfig, ciGoldensConfig: ciGoldensConfig ?? _ciGoldensConfig, + metadataEnabled: metadataEnabled ?? _metadataEnabled, + semanticsEnabled: semanticsEnabled ?? _semanticsEnabled, ); } @@ -233,6 +255,8 @@ class AlchemistConfig extends Equatable { other?._platformGoldensConfig, ), ciGoldensConfig: ciGoldensConfig.merge(other?._ciGoldensConfig), + metadataEnabled: other?._metadataEnabled, + semanticsEnabled: other?._semanticsEnabled, ); } @@ -243,6 +267,8 @@ class AlchemistConfig extends Equatable { goldenTestTheme, platformGoldensConfig, ciGoldensConfig, + metadataEnabled, + semanticsEnabled, ]; } diff --git a/lib/src/golden_metadata.dart b/lib/src/golden_metadata.dart new file mode 100644 index 0000000..9c07d3d --- /dev/null +++ b/lib/src/golden_metadata.dart @@ -0,0 +1,193 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:ui' show CheckedState, Rect, Size, Tristate; + +import 'package:flutter/semantics.dart'; + +/// Metadata for a single scenario within a golden test. +/// +/// Contains the scenario name, pixel bounds, and optionally +/// semantics tree data. +class ScenarioMetadata { + /// Creates a [ScenarioMetadata] with the given [name] and [bounds]. + ScenarioMetadata({ + required this.name, + required this.bounds, + this.semantics, + }); + + /// The name of the scenario (matches `GoldenTestScenario.name`). + final String name; + + /// The pixel bounds of the scenario in the golden image. + final Rect bounds; + + /// Optional semantics tree data for this scenario. + final List? semantics; + + /// Serializes this metadata to a JSON-compatible map. + /// + /// Bounds are truncated to integers since they represent pixels. + /// The `semantics` key is only included when non-null and non-empty. + Map toJson() { + final json = { + 'name': name, + 'x': bounds.left.truncate(), + 'y': bounds.top.truncate(), + 'w': bounds.width.truncate(), + 'h': bounds.height.truncate(), + }; + + if (semantics != null && semantics!.isNotEmpty) { + json['semantics'] = semantics!.map((s) => s.toJson()).toList(); + } + + return json; + } +} + +/// Metadata for a complete golden test image. +/// +/// Contains the golden file path, image dimensions, and a list of +/// [ScenarioMetadata] entries for each scenario in the image. +class GoldenMetadata { + /// Creates a [GoldenMetadata] for the given [goldenPath]. + GoldenMetadata({required this.goldenPath}); + + /// The golden file path (String or Uri). + final Object goldenPath; + + /// Image dimensions, set after capture. + Size imageSize = Size.zero; + + /// The scenarios in this golden test. + final List scenarios = []; + + /// Adds a scenario with the given [name], [bounds], and optional + /// [semantics]. + void addScenario({ + required String name, + required Rect bounds, + List? semantics, + }) { + scenarios.add( + ScenarioMetadata(name: name, bounds: bounds, semantics: semantics), + ); + } + + /// Serializes this metadata to a JSON-compatible map. + Map toJson() { + return { + 'version': 1, + 'image': { + 'w': imageSize.width.truncate(), + 'h': imageSize.height.truncate(), + }, + 'scenarios': scenarios.map((s) => s.toJson()).toList(), + }; + } + + /// Derives the JSON file path from [goldenPath]. + String get jsonPath { + final path = + goldenPath is Uri + ? (goldenPath as Uri).toFilePath() + : goldenPath as String; + return path.replaceAll(RegExp(r'\.png$'), '.json'); + } + + /// Writes this metadata to a JSON file alongside the golden image. + Future writeToFile() async { + final file = File(jsonPath); + await file.parent.create(recursive: true); + const encoder = JsonEncoder.withIndent(' '); + await file.writeAsString(encoder.convert(toJson())); + } +} + +/// Data extracted from a single [SemanticsNode]. +/// +/// This is a pure data class that does not depend on Flutter's +/// widget or rendering layer. +class SemanticsNodeData { + /// Creates a [SemanticsNodeData] with the given fields. + const SemanticsNodeData({ + this.label, + this.value, + this.hint, + this.tooltip, + this.role, + this.actions, + }); + + /// Creates a [SemanticsNodeData] from Flutter's [SemanticsData]. + factory SemanticsNodeData.fromSemanticsData(SemanticsData data) { + return SemanticsNodeData( + label: data.label.isNotEmpty ? data.label : null, + value: data.value.isNotEmpty ? data.value : null, + hint: data.hint.isNotEmpty ? data.hint : null, + tooltip: data.tooltip.isNotEmpty ? data.tooltip : null, + role: _detectRole(data), + actions: _detectActions(data), + ); + } + + /// The accessibility label. + final String? label; + + /// The current value. + final String? value; + + /// The usage hint for screen readers. + final String? hint; + + /// The tooltip text. + final String? tooltip; + + /// The semantic role derived from [SemanticsFlag]s. + final String? role; + + /// Available actions (tap, longPress, etc.). + final List? actions; + + /// Serializes to JSON, omitting null fields and empty action lists. + Map toJson() { + final json = {}; + if (label != null) json['label'] = label; + if (value != null) json['value'] = value; + if (hint != null) json['hint'] = hint; + if (tooltip != null) json['tooltip'] = tooltip; + if (role != null) json['role'] = role; + if (actions != null && actions!.isNotEmpty) json['actions'] = actions; + return json; + } + + static String? _detectRole(SemanticsData data) { + final flags = data.flagsCollection; + if (flags.isButton) return 'button'; + if (flags.isTextField) return 'textField'; + if (flags.isHeader) return 'header'; + if (flags.isLink) return 'link'; + if (flags.isSlider) return 'slider'; + if (flags.isImage) return 'image'; + if (flags.isChecked != CheckedState.none) return 'checkbox'; + if (flags.isToggled != Tristate.none) return 'switch'; + return null; + } + + static List? _detectActions(SemanticsData data) { + final result = []; + if (data.hasAction(SemanticsAction.tap)) result.add('tap'); + if (data.hasAction(SemanticsAction.longPress)) result.add('longPress'); + if (data.hasAction(SemanticsAction.scrollLeft)) result.add('scrollLeft'); + if (data.hasAction(SemanticsAction.scrollRight)) result.add('scrollRight'); + if (data.hasAction(SemanticsAction.scrollUp)) result.add('scrollUp'); + if (data.hasAction(SemanticsAction.scrollDown)) result.add('scrollDown'); + if (data.hasAction(SemanticsAction.increase)) result.add('increase'); + if (data.hasAction(SemanticsAction.decrease)) result.add('decrease'); + if (data.hasAction(SemanticsAction.copy)) result.add('copy'); + if (data.hasAction(SemanticsAction.cut)) result.add('cut'); + if (data.hasAction(SemanticsAction.paste)) result.add('paste'); + return result.isEmpty ? null : result; + } +} diff --git a/lib/src/golden_test.dart b/lib/src/golden_test.dart index acdacfc..7d10fc1 100644 --- a/lib/src/golden_test.dart +++ b/lib/src/golden_test.dart @@ -187,6 +187,8 @@ Future goldenTest( forceUpdate: config.forceUpdateGoldenFiles, obscureText: variantConfig.obscureText, renderShadows: variantConfig.renderShadows, + metadataEnabled: config.metadataEnabled, + semanticsEnabled: config.semanticsEnabled, textScaleFactor: textScaleFactor, constraints: constraints, pumpBeforeTest: pumpBeforeTest, diff --git a/lib/src/golden_test_runner.dart b/lib/src/golden_test_runner.dart index f13ebde..9310e58 100644 --- a/lib/src/golden_test_runner.dart +++ b/lib/src/golden_test_runner.dart @@ -1,11 +1,14 @@ import 'dart:ui' as ui; +import 'package:alchemist/src/golden_metadata.dart'; import 'package:alchemist/src/golden_test_adapter.dart'; +import 'package:alchemist/src/golden_test_scenario.dart'; import 'package:alchemist/src/golden_test_theme.dart'; import 'package:alchemist/src/interactions.dart'; import 'package:alchemist/src/pumps.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; import 'package:flutter_test/flutter_test.dart'; /// Default golden test adapter used to interface with Flutter's testing @@ -37,6 +40,8 @@ abstract class GoldenTestRunner { bool forceUpdate = false, bool obscureText = false, bool renderShadows = false, + bool metadataEnabled = false, + bool semanticsEnabled = false, double textScaleFactor = 1.0, BoxConstraints constraints = const BoxConstraints(), PumpAction pumpBeforeTest = onlyPumpAndSettle, @@ -64,6 +69,8 @@ class FlutterGoldenTestRunner extends GoldenTestRunner { bool forceUpdate = false, bool obscureText = false, bool renderShadows = false, + bool metadataEnabled = false, + bool semanticsEnabled = false, double textScaleFactor = 1.0, BoxConstraints constraints = const BoxConstraints(), PumpAction pumpBeforeTest = onlyPumpAndSettle, @@ -96,6 +103,24 @@ class FlutterGoldenTestRunner extends GoldenTestRunner { widget: widget, ); + // Capture metadata BEFORE interactions (bounds + resting-state semantics) + if (metadataEnabled) { + SemanticsHandle? semanticsHandle; + if (semanticsEnabled) { + semanticsHandle = tester.ensureSemantics(); + await tester.pump(); + } + + final metadata = _captureMetadata( + tester, + goldenPath, + captureSemantics: semanticsEnabled, + ); + await metadata.writeToFile(); + + semanticsHandle?.dispose(); + } + AsyncCallback? cleanup; if (whilePerforming != null) { cleanup = await whilePerforming(tester); @@ -136,4 +161,72 @@ class FlutterGoldenTestRunner extends GoldenTestRunner { }); } } + + GoldenMetadata _captureMetadata( + WidgetTester tester, + Object goldenPath, { + bool captureSemantics = false, + }) { + final metadata = GoldenMetadata(goldenPath: goldenPath); + final scenarioFinder = find.byType(GoldenTestScenario); + + // Capture image size from the root render box + final rootKey = FlutterGoldenTestAdapter.rootKey; + final rootFinder = find.byKey(rootKey); + if (rootFinder.evaluate().isNotEmpty) { + final rootRect = tester.getRect(rootFinder); + metadata.imageSize = rootRect.size; + } + + for (var i = 0; i < scenarioFinder.evaluate().length; i++) { + final finder = scenarioFinder.at(i); + final scenario = tester.widget(finder); + final bounds = tester.getRect(finder); + + List? semantics; + if (captureSemantics) { + semantics = _collectSemantics(tester, finder, scenario.name); + } + + metadata.addScenario( + name: scenario.name, + bounds: bounds, + semantics: semantics, + ); + } + + return metadata; + } + + List _collectSemantics( + WidgetTester tester, + Finder scenarioFinder, + String scenarioName, + ) { + final rootNode = tester.getSemantics(scenarioFinder); + final results = []; + + bool walk(SemanticsNode node) { + final data = node.getSemanticsData(); + final label = data.label; + final value = data.value; + final hint = data.hint; + final tooltip = data.tooltip; + + final hasContent = + label.isNotEmpty || + value.isNotEmpty || + hint.isNotEmpty || + tooltip.isNotEmpty; + if (hasContent && label != scenarioName) { + results.add(SemanticsNodeData.fromSemanticsData(data)); + } + + node.visitChildren(walk); + return true; + } + + walk(rootNode); + return results; + } } diff --git a/test/src/alchemist_config_test.dart b/test/src/alchemist_config_test.dart index 117cc7c..1268e90 100644 --- a/test/src/alchemist_config_test.dart +++ b/test/src/alchemist_config_test.dart @@ -36,6 +36,16 @@ void main() { (c) => c.ciGoldensConfig, 'ciGoldensConfig', equals(const CiGoldensConfig()), + ) + .having( + (c) => c.metadataEnabled, + 'metadataEnabled', + isFalse, + ) + .having( + (c) => c.semanticsEnabled, + 'semanticsEnabled', + isFalse, ), ); }); @@ -132,6 +142,50 @@ void main() { ), ); }); + + test('replaces metadataEnabled', () { + expect( + const AlchemistConfig().copyWith(metadataEnabled: true), + isA().having( + (c) => c.metadataEnabled, + 'metadataEnabled', + isTrue, + ), + ); + }); + + test('preserves metadataEnabled when not replaced', () { + expect( + const AlchemistConfig(metadataEnabled: true).copyWith(), + isA().having( + (c) => c.metadataEnabled, + 'metadataEnabled', + isTrue, + ), + ); + }); + + test('replaces semanticsEnabled', () { + expect( + const AlchemistConfig().copyWith(semanticsEnabled: true), + isA().having( + (c) => c.semanticsEnabled, + 'semanticsEnabled', + isTrue, + ), + ); + }); + + test('preserves semanticsEnabled when not replaced', () { + expect( + const AlchemistConfig(semanticsEnabled: true).copyWith(), + isA().having( + (c) => c.semanticsEnabled, + 'semanticsEnabled', + isTrue, + ), + ); + }); }); group('merge', () { @@ -196,6 +250,58 @@ void main() { ), ); }); + + test('merges metadataEnabled', () { + expect( + const AlchemistConfig(metadataEnabled: false).merge( + const AlchemistConfig(metadataEnabled: true), + ), + isA().having( + (c) => c.metadataEnabled, + 'metadataEnabled', + isTrue, + ), + ); + }); + + test('preserves metadataEnabled when merge has no value', () { + expect( + const AlchemistConfig(metadataEnabled: true).merge( + const AlchemistConfig(), + ), + isA().having( + (c) => c.metadataEnabled, + 'metadataEnabled', + isTrue, + ), + ); + }); + + test('merges semanticsEnabled', () { + expect( + const AlchemistConfig(semanticsEnabled: false).merge( + const AlchemistConfig(semanticsEnabled: true), + ), + isA().having( + (c) => c.semanticsEnabled, + 'semanticsEnabled', + isTrue, + ), + ); + }); + + test('preserves semanticsEnabled when merge has no value', () { + expect( + const AlchemistConfig(semanticsEnabled: true).merge( + const AlchemistConfig(), + ), + isA().having( + (c) => c.semanticsEnabled, + 'semanticsEnabled', + isTrue, + ), + ); + }); }); }); diff --git a/test/src/golden_metadata_test.dart b/test/src/golden_metadata_test.dart new file mode 100644 index 0000000..f7215f9 --- /dev/null +++ b/test/src/golden_metadata_test.dart @@ -0,0 +1,321 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:ui' show CheckedState, Rect, SemanticsFlags, SemanticsInputType, SemanticsRole, SemanticsValidationResult, Size, TextDirection, Tristate; + +import 'package:alchemist/src/golden_metadata.dart'; +import 'package:flutter/semantics.dart'; +import 'package:test/test.dart'; + +/// Helper to create SemanticsData with sensible defaults for testing. +SemanticsData _makeSemanticsData({ + SemanticsFlags? flagsCollection, + int actions = 0, + String label = '', + String value = '', + String hint = '', + String tooltip = '', +}) { + // textDirection is required when label/value/hint/tooltip are non-empty. + final needsDirection = + label.isNotEmpty || + value.isNotEmpty || + hint.isNotEmpty || + tooltip.isNotEmpty; + + return SemanticsData( + flagsCollection: flagsCollection ?? SemanticsFlags(), + actions: actions, + identifier: '', + attributedLabel: AttributedString(label), + attributedValue: AttributedString(value), + attributedHint: AttributedString(hint), + attributedIncreasedValue: AttributedString(''), + attributedDecreasedValue: AttributedString(''), + tooltip: tooltip, + textDirection: needsDirection ? TextDirection.ltr : null, + rect: Rect.zero, + textSelection: null, + scrollIndex: null, + scrollChildCount: null, + scrollPosition: null, + scrollExtentMax: null, + scrollExtentMin: null, + platformViewId: -1, + maxValueLength: -1, + currentValueLength: -1, + headingLevel: 0, + linkUrl: null, + role: SemanticsRole.none, + controlsNodes: null, + validationResult: SemanticsValidationResult.none, + inputType: SemanticsInputType.none, + locale: null, + ); +} + +void main() { + group('ScenarioMetadata', () { + test('toJson() serializes name and flat bounds', () { + final metadata = ScenarioMetadata( + name: 'Card - active', + bounds: const Rect.fromLTWH(10, 20, 400, 300), + ); + + expect(metadata.toJson(), { + 'name': 'Card - active', + 'x': 10, + 'y': 20, + 'w': 400, + 'h': 300, + }); + }); + + test('toJson() truncates fractional bounds to integers', () { + final metadata = ScenarioMetadata( + name: 'Fractional', + bounds: const Rect.fromLTWH(10.7, 20.9, 400.3, 300.1), + ); + + final json = metadata.toJson(); + expect(json['x'], 10); + expect(json['y'], 20); + expect(json['w'], 400); + expect(json['h'], 300); + }); + + test('toJson() omits semantics when null', () { + final metadata = ScenarioMetadata( + name: 'No semantics', + bounds: Rect.zero, + ); + + expect(metadata.toJson().containsKey('semantics'), isFalse); + }); + + test('toJson() omits semantics when empty list', () { + final metadata = ScenarioMetadata( + name: 'Empty semantics', + bounds: Rect.zero, + semantics: const [], + ); + + expect(metadata.toJson().containsKey('semantics'), isFalse); + }); + + test('toJson() includes semantics when present', () { + final metadata = ScenarioMetadata( + name: 'With semantics', + bounds: Rect.zero, + semantics: const [ + SemanticsNodeData(label: 'Buy', role: 'button', actions: ['tap']), + ], + ); + + final json = metadata.toJson(); + expect(json['semantics'], isList); + expect(json['semantics'], hasLength(1)); + expect(json['semantics'][0]['label'], 'Buy'); + }); + }); + + group('GoldenMetadata', () { + test('creates from String path', () { + final metadata = GoldenMetadata(goldenPath: 'goldens/ci/card.png'); + expect(metadata.jsonPath, 'goldens/ci/card.json'); + }); + + test('creates from Uri path', () { + final metadata = GoldenMetadata( + goldenPath: Uri.file('/tmp/goldens/ci/card.png'), + ); + expect(metadata.jsonPath, '/tmp/goldens/ci/card.json'); + }); + + test('addScenario() preserves insertion order', () { + final metadata = GoldenMetadata(goldenPath: 'test.png'); + metadata.addScenario( + name: 'First', + bounds: const Rect.fromLTWH(0, 0, 100, 100), + ); + metadata.addScenario( + name: 'Second', + bounds: const Rect.fromLTWH(100, 0, 100, 100), + ); + + expect(metadata.scenarios, hasLength(2)); + expect(metadata.scenarios[0].name, 'First'); + expect(metadata.scenarios[1].name, 'Second'); + }); + + test('toJson() includes version, image, and scenarios', () { + final metadata = GoldenMetadata(goldenPath: 'test.png'); + metadata.imageSize = const Size(1600, 1200); + metadata.addScenario( + name: 'Card', + bounds: const Rect.fromLTWH(0, 0, 400, 398), + ); + + final json = metadata.toJson(); + expect(json['version'], 1); + expect(json['image'], {'w': 1600, 'h': 1200}); + expect(json['scenarios'], hasLength(1)); + expect(json['scenarios'][0]['name'], 'Card'); + }); + + test('writeToFile() creates JSON file with correct content', () async { + final tempDir = Directory.systemTemp.createTempSync('alchemist_test_'); + try { + final goldenPath = '${tempDir.path}/goldens/ci/test.png'; + final metadata = GoldenMetadata(goldenPath: goldenPath); + metadata.imageSize = const Size(800, 600); + metadata.addScenario( + name: 'Button', + bounds: const Rect.fromLTWH(0, 0, 200, 150), + ); + + await metadata.writeToFile(); + + final jsonFile = File('${tempDir.path}/goldens/ci/test.json'); + expect(jsonFile.existsSync(), isTrue); + + final content = json.decode(jsonFile.readAsStringSync()) + as Map; + expect(content['version'], 1); + expect(content['image']['w'], 800); + expect(content['scenarios'], hasLength(1)); + expect(content['scenarios'][0]['name'], 'Button'); + } finally { + tempDir.deleteSync(recursive: true); + } + }); + + test('writeToFile() creates parent directories', () async { + final tempDir = Directory.systemTemp.createTempSync('alchemist_test_'); + try { + final goldenPath = '${tempDir.path}/deep/nested/path/test.png'; + final metadata = GoldenMetadata(goldenPath: goldenPath); + metadata.imageSize = Size.zero; + + await metadata.writeToFile(); + + final jsonFile = File('${tempDir.path}/deep/nested/path/test.json'); + expect(jsonFile.existsSync(), isTrue); + } finally { + tempDir.deleteSync(recursive: true); + } + }); + }); + + group('SemanticsNodeData', () { + test('toJson() includes only non-null fields', () { + const data = SemanticsNodeData(label: 'Submit', role: 'button'); + + expect(data.toJson(), {'label': 'Submit', 'role': 'button'}); + }); + + test('toJson() includes all fields when present', () { + const data = SemanticsNodeData( + label: 'Volume', + value: '50%', + hint: 'Adjust volume', + tooltip: 'Volume control', + role: 'slider', + actions: ['increase', 'decrease'], + ); + + expect(data.toJson(), { + 'label': 'Volume', + 'value': '50%', + 'hint': 'Adjust volume', + 'tooltip': 'Volume control', + 'role': 'slider', + 'actions': ['increase', 'decrease'], + }); + }); + + test('toJson() excludes empty actions list', () { + const data = SemanticsNodeData(label: 'Text', actions: []); + + expect(data.toJson().containsKey('actions'), isFalse); + }); + + test('toJson() returns empty map when all fields are null', () { + const data = SemanticsNodeData(); + + expect(data.toJson(), isEmpty); + }); + + test('fromSemanticsData() extracts label and value', () { + final semData = _makeSemanticsData(label: 'Hello', value: 'World'); + + final result = SemanticsNodeData.fromSemanticsData(semData); + expect(result.label, 'Hello'); + expect(result.value, 'World'); + expect(result.hint, isNull); + expect(result.role, isNull); + }); + + test('fromSemanticsData() detects button role', () { + final semData = _makeSemanticsData( + label: 'Submit', + flagsCollection: SemanticsFlags(isButton: true), + actions: SemanticsAction.tap.index, + ); + + final result = SemanticsNodeData.fromSemanticsData(semData); + expect(result.label, 'Submit'); + expect(result.role, 'button'); + expect(result.actions, contains('tap')); + }); + + test('fromSemanticsData() detects checkbox role', () { + final semData = _makeSemanticsData( + label: 'Accept terms', + flagsCollection: SemanticsFlags(isChecked: CheckedState.isTrue), + ); + + final result = SemanticsNodeData.fromSemanticsData(semData); + expect(result.role, 'checkbox'); + }); + + test('fromSemanticsData() detects switch role', () { + final semData = _makeSemanticsData( + label: 'Dark mode', + flagsCollection: SemanticsFlags(isToggled: Tristate.isTrue), + ); + + final result = SemanticsNodeData.fromSemanticsData(semData); + expect(result.role, 'switch'); + }); + + test('fromSemanticsData() detects header role', () { + final semData = _makeSemanticsData( + label: 'Section Title', + flagsCollection: SemanticsFlags(isHeader: true), + ); + + final result = SemanticsNodeData.fromSemanticsData(semData); + expect(result.role, 'header'); + }); + + test('fromSemanticsData() extracts tooltip', () { + final semData = _makeSemanticsData( + label: 'Info', + tooltip: 'More information', + ); + + final result = SemanticsNodeData.fromSemanticsData(semData); + expect(result.tooltip, 'More information'); + }); + + test('fromSemanticsData() extracts hint', () { + final semData = _makeSemanticsData( + label: 'Search', + hint: 'Enter search term', + ); + + final result = SemanticsNodeData.fromSemanticsData(semData); + expect(result.hint, 'Enter search term'); + }); + }); +} diff --git a/test/src/golden_test_test.dart b/test/src/golden_test_test.dart index 979c6b0..84e7892 100644 --- a/test/src/golden_test_test.dart +++ b/test/src/golden_test_test.dart @@ -112,6 +112,8 @@ void main() { forceUpdate: any(named: 'forceUpdate'), obscureText: any(named: 'obscureText'), renderShadows: any(named: 'renderShadows'), + metadataEnabled: any(named: 'metadataEnabled'), + semanticsEnabled: any(named: 'semanticsEnabled'), textScaleFactor: any(named: 'textScaleFactor'), constraints: any(named: 'constraints'), pumpBeforeTest: any(named: 'pumpBeforeTest'), @@ -197,6 +199,8 @@ void main() { forceUpdate: any(named: 'forceUpdate'), obscureText: any(named: 'obscureText'), renderShadows: ciRenderShadows, + metadataEnabled: any(named: 'metadataEnabled'), + semanticsEnabled: any(named: 'semanticsEnabled'), textScaleFactor: any(named: 'textScaleFactor'), pumpBeforeTest: any(named: 'pumpBeforeTest'), pumpWidget: any(named: 'pumpWidget'), @@ -216,6 +220,8 @@ void main() { forceUpdate: any(named: 'forceUpdate'), obscureText: any(named: 'obscureText'), renderShadows: any(named: 'renderShadows'), + metadataEnabled: any(named: 'metadataEnabled'), + semanticsEnabled: any(named: 'semanticsEnabled'), textScaleFactor: any(named: 'textScaleFactor'), constraints: any(named: 'constraints'), pumpBeforeTest: any(named: 'pumpBeforeTest'),