From 5b228e6165a8e9b27855d51f47597ae904f09866 Mon Sep 17 00:00:00 2001 From: Daniel Eshkeri Date: Fri, 11 Oct 2024 17:00:34 +0100 Subject: [PATCH 1/6] docs: testing counter script --- test/TESTING_README.mdx | 76 +++++++++++ test/output/test_table.mdx | 25 ++++ test/scripts/test_counter.dart | 241 +++++++++++++++++++++++++++++++++ test/testing_conventions.mdx | 9 -- 4 files changed, 342 insertions(+), 9 deletions(-) create mode 100644 test/TESTING_README.mdx create mode 100644 test/output/test_table.mdx create mode 100644 test/scripts/test_counter.dart delete mode 100644 test/testing_conventions.mdx diff --git a/test/TESTING_README.mdx b/test/TESTING_README.mdx new file mode 100644 index 00000000..b33e066e --- /dev/null +++ b/test/TESTING_README.mdx @@ -0,0 +1,76 @@ +# Testing Conventions Flutter Components + +### Helper Functions + +As you are writing tests think about helper function you could write and add them to the `test_utils/utils.dart` file. This will help you and others write tests faster and more consistently. + +- For golden tests + `goldenTest(GoldenFiles goldenFile, Widget widget, Type widgetType, String fileName, {bool darkMode = false})` +- For debugFillProperties tests + `debugFillPropertiesTest(Widget widget, Map debugFillProperties)` + +### Groups + +- Accessibility Tests + Semantic labels, touch areas, contrast ratios, etc. +- Content Tests + Finds the widget, parameter statuses, etc. +- Dimensions Tests + Size, padding, margin, alignment, etc. +- Styling Tests + Rendered colors, fonts, borders, radii etc. +- Interaction Tests + Gesture recognizers, taps, drags, etc. +- Golden Tests + Compares the rendered widget with the golden file. Use the `goldenTest()` function from test_utils/utils.dart. +- Performance Tests + Animation performance, rendering performance, data manupulation performance, etc. + +### Testing File Template + +``` +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:zeta_flutter/zeta_flutter.dart'; + +import '../../../test_utils/test_app.dart'; +import '../../../test_utils/tolerant_comparator.dart'; +import '../../../test_utils/utils.dart'; + +void main() { + const String componentName = 'ENTER_COMPONENT_NAME (e.g. ZetaButton)'; + const String parentFolder = 'ENTER_PARENT_FOLDER (e.g. button)'; + + const goldenFile = GoldenFiles(component: parentFolder); + setUpAll(() { + goldenFileComparator = TolerantComparator(goldenFile.uri); + }); + + group('$componentName Accessibility Tests', () {}); + group('$componentName Content Tests', () { + final debugFillProperties = { + '': '', + }; + debugFillPropertiesTest( + widget, + debugFillProperties, + ); + }); + group('$componentName Dimensions Tests', () {}); + group('$componentName Styling Tests', () {}); + group('$componentName Interaction Tests', () {}); + group('$componentName Golden Tests', () { + goldenTest(goldenFile, widget, widgetType, 'PNG_FILE_NAME'); + }); + group('$componentName Performance Tests', () {}); +} +``` + +#### Visibility Excel Sheet + +https://zebra-my.sharepoint.com/:x:/p/de7924/Ea0l7BF7AzJJoBVPrg4cIswBZRyek6iNT3zzwDcLn-5ZGg?e=NTJIZU + +### Test Table diff --git a/test/output/test_table.mdx b/test/output/test_table.mdx new file mode 100644 index 00000000..d39eb4a2 --- /dev/null +++ b/test/output/test_table.mdx @@ -0,0 +1,25 @@ +| Component | Accessibility | Content | Dimensions | Styling | Interaction | Golden | Performance | Unorganised | Total Tests | +| -------------------- | ------------- | ------- | ---------- | ------- | ----------- | ------ | ----------- | ----------- | ----------- | +| Accordion | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 5 | 5 | +| Indicator | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 7 | 7 | +| Label | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 8 | 8 | +| Priority Pill | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 5 | 5 | +| Status Label | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 3 | 3 | +| Tag | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 3 | 3 | +| Banner | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 14 | 14 | +| Button | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 13 | 13 | +| Chat Item | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 9 | 9 | +| Checkbox | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 6 | 6 | +| Chip | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 6 | 6 | +| Comms Button | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 10 | 10 | +| Dialpad | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 4 | 4 | +| Fab | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 8 | 8 | +| Icon | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 12 | 12 | +| In Page Banner | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 6 | 6 | +| Password Input | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 4 | 4 | +| Search Bar | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 15 | 15 | +| Slider | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 1 | +| Stepper Input | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 2 | 2 | +| Tooltip | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 9 | 9 | +| Extended Top App Bar | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 3 | 3 | +| Total Tests | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 153 | 153 | diff --git a/test/scripts/test_counter.dart b/test/scripts/test_counter.dart new file mode 100644 index 00000000..2a67e300 --- /dev/null +++ b/test/scripts/test_counter.dart @@ -0,0 +1,241 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:analyzer/dart/analysis/utilities.dart'; +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/ast/visitor.dart'; + +String getGroupName(MethodInvocation node) { + return node.argumentList.arguments.first + .toString() + .replaceAll("'", '') + .replaceAll(r'$componentName ', '') + .replaceAll(' Tests', ''); +} + +String getTestName(MethodInvocation node) { + return node.argumentList.arguments.first.toString().replaceAll("'", ''); +} + +String getMethodName(MethodInvocation node) { + return node.methodName.name; +} + +bool hasNullParent(MethodInvocation node) { + return node.parent!.parent!.thisOrAncestorMatching((node) => node is MethodInvocation) == null; +} + +bool methodIsOneOf(List methods, MethodInvocation node) { + return methods.contains(node.methodName.name); +} + +extension StringExtension on String { + String capitalize() { + if (isEmpty) return this; + return this[0].toUpperCase() + substring(1); + } + + String capitalizeEachWord() { + return split(' ').map((word) => word.capitalize()).join(' '); + } +} + +String getComponentNameFromTestPath(String path) { + return path.split(r'\').last.split('_test').first.replaceAll('_', ' ').capitalizeEachWord(); +} + +class TestGroupVisitor extends RecursiveAstVisitor { + final List> groups = []; + + @override + void visitMethodInvocation(MethodInvocation node) { + if (hasNullParent(node)) { + if (methodIsOneOf(['group'], node)) { + final groupName = getGroupName(node); + final groupBody = node.argumentList.arguments.last; + + final tests = >[]; + + if (groupBody is FunctionExpression) { + final body = groupBody.body; + if (body is BlockFunctionBody) { + body.block.visitChildren(TestVisitor(tests)); + } + } + + groups.add({ + 'group': groupName, + 'tests': tests, + }); + } else if (methodIsOneOf(['testWidgets', 'test', 'goldenTest', 'debugFillPropertiesTest'], node)) { + final testName = getTestName(node); + + if (groups.any((el) => el['group'] == 'unorganised')) { + final unorganisedGroup = groups.firstWhere((el) => el['group'] == 'unorganised'); + (unorganisedGroup['tests'] as List).add({ + 'name': testName, + }); + } else { + groups.add({ + 'group': 'unorganised', + 'tests': [ + { + 'name': testName, + }, + ], + }); + } + } + } + super.visitMethodInvocation(node); + } +} + +// Visitor to extract test names +class TestVisitor extends RecursiveAstVisitor { + TestVisitor(this.tests); + + final List> tests; + + @override + void visitMethodInvocation(MethodInvocation node) { + if (methodIsOneOf(['testWidgets', 'test'], node)) { + final testName = getTestName(node); + tests.add({ + 'name': testName, + }); + } else if (methodIsOneOf(['debugFillPropertiesTest'], node)) { + tests.add({ + 'name': getMethodName(node), + }); + } else if (methodIsOneOf(['goldenTest'], node)) { + tests.add({ + 'name': node.toString(), + }); + } + + super.visitMethodInvocation(node); + } +} + +String generateMDX(Map> testCount) { + final List groupNames = [ + 'Accessibility', + 'Content', + 'Dimensions', + 'Styling', + 'Interaction', + 'Golden', + 'Performance', + 'unorganised', + ]; + + final List data = [ + '| Component | Accessibility | Content | Dimensions | Styling | Interaction | Golden | Performance | Unorganised | Total Tests |', + '| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |', + ]; + + testCount.forEach((filePath, groups) { + final componentName = getComponentNameFromTestPath(filePath); + final totalTests = groups.values.fold(0, (previousValue, element) => previousValue + element); + int otherGroups = 0; + groups.forEach((key, value) { + if (!groupNames.contains(key)) { + otherGroups += value; + } + }); + otherGroups += groups['unorganised'] ?? 0; + + data.add( + '| $componentName | ${groups['Accessibility'] ?? 0} | ${groups['Content'] ?? 0} | ${groups['Dimensions'] ?? 0} | ${groups['Styling'] ?? 0} | ${groups['Interaction'] ?? 0} | ${groups['Golden'] ?? 0} | ${groups['Performance'] ?? 0} | $otherGroups | $totalTests |', + ); + }); + + final Map groupTotals = { + 'Accessibility': 0, + 'Content': 0, + 'Dimensions': 0, + 'Styling': 0, + 'Interaction': 0, + 'Golden': 0, + 'Performance': 0, + 'unorganised': 0, + }; + + testCount.forEach((filePath, groups) { + groups.forEach((key, value) { + if (!groupNames.contains(key)) { + groupTotals['unorganised'] = groupTotals['unorganised']! + value; + } else { + groupTotals[key] = groupTotals[key]! + value; + } + }); + }); + + data.add( + '| Total Tests | ${groupTotals['Accessibility']} | ${groupTotals['Content']} | ${groupTotals['Dimensions']} | ${groupTotals['Styling']} | ${groupTotals['Interaction']} | ${groupTotals['Golden']} | ${groupTotals['Performance']} | ${groupTotals['unorganised']} | ${groupTotals.values.fold(0, (previousValue, element) => previousValue + element)} |', + ); + + return data.join('\n'); +} + +void main() async { + // check for output directory and create if it doesn't exist + final outputDirectory = Directory('test/output'); + if (!outputDirectory.existsSync()) { + await outputDirectory.create(recursive: true); + } + + // get all test files + final testDirectory = Directory('test/src/components'); + final testFiles = + testDirectory.listSync(recursive: true).where((entity) => entity is File && entity.path.endsWith('_test.dart')); + final Map>> testGroups = {}; + + // parse each test file and extract test groups + for (final FileSystemEntity file in testFiles) { + final contents = await File(file.path).readAsString(); + + final parseResult = parseString(content: contents); + final visitor = TestGroupVisitor(); + parseResult.unit.visitChildren(visitor); + testGroups[file.path] = visitor.groups; + } + + // write test groups to file + // final jsonOutputGroups = jsonEncode(testGroups); + // final outputFileGroups = File('${outputDirectory.path}/test_groups.json'); + // await outputFileGroups.writeAsString(jsonOutputGroups); + + final Map> testCount = {}; + + // count the number of tests in each group + testGroups.forEach((filePath, groups) { + final Map groupCounts = {}; + for (final group in groups) { + final groupName = group['group'] as String; + final tests = group['tests'] as List; + groupCounts[groupName] = tests.length; + } + testCount[filePath] = groupCounts; + }); + + // write test counts to file + // final jsonOutput = jsonEncode(testCount); + // final outputFile = File('${outputDirectory.path}/test_counts.json'); + // await outputFile.writeAsString(jsonOutput); + + // generate MDX table + final mdxOutput = generateMDX(testCount); + final mdxFile = File('${outputDirectory.path}/test_table.mdx'); + await mdxFile.writeAsString(mdxOutput); +} + + + +/** TODO: + * - Abstract the code into functions + * - Add comments + * - Add error handling + * - GITHUB ACTION TO RUN THE SCRIPT + * */ diff --git a/test/testing_conventions.mdx b/test/testing_conventions.mdx deleted file mode 100644 index 4146566c..00000000 --- a/test/testing_conventions.mdx +++ /dev/null @@ -1,9 +0,0 @@ -## Groups - -- Accessibility Tests -- Content Tests -- Dimensions Tests -- Styling Tests -- Interaction Tests -- Golden Tests -- Performance Tests From c7c10ffa2e048730faab543a7e3e3f2814b401a0 Mon Sep 17 00:00:00 2001 From: Daniel Eshkeri Date: Mon, 14 Oct 2024 14:56:05 +0100 Subject: [PATCH 2/6] test: zeta_provider_test has test that tests nothing doc: moved helper functions from test_counter to utils file doc: abstracted functions in test_counter doc: added doc comments to test_counter script and utils file --- pubspec.yaml | 1 + test/output/test_table.mdx | 25 --- test/scripts/test_counter.dart | 268 +++++++++++++++---------------- test/scripts/utils/utils.dart | 249 ++++++++++++++++++++++++++++ test/src/zeta_provider_test.dart | 3 + 5 files changed, 387 insertions(+), 159 deletions(-) delete mode 100644 test/output/test_table.mdx create mode 100644 test/scripts/utils/utils.dart diff --git a/pubspec.yaml b/pubspec.yaml index 05511bcd..b39bb34f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -21,6 +21,7 @@ environment: flutter: ">=3.16.0" dependencies: + analyzer: ^6.7.0 collection: ^1.18.0 equatable: ^2.0.5 flutter: diff --git a/test/output/test_table.mdx b/test/output/test_table.mdx deleted file mode 100644 index d39eb4a2..00000000 --- a/test/output/test_table.mdx +++ /dev/null @@ -1,25 +0,0 @@ -| Component | Accessibility | Content | Dimensions | Styling | Interaction | Golden | Performance | Unorganised | Total Tests | -| -------------------- | ------------- | ------- | ---------- | ------- | ----------- | ------ | ----------- | ----------- | ----------- | -| Accordion | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 5 | 5 | -| Indicator | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 7 | 7 | -| Label | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 8 | 8 | -| Priority Pill | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 5 | 5 | -| Status Label | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 3 | 3 | -| Tag | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 3 | 3 | -| Banner | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 14 | 14 | -| Button | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 13 | 13 | -| Chat Item | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 9 | 9 | -| Checkbox | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 6 | 6 | -| Chip | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 6 | 6 | -| Comms Button | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 10 | 10 | -| Dialpad | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 4 | 4 | -| Fab | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 8 | 8 | -| Icon | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 12 | 12 | -| In Page Banner | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 6 | 6 | -| Password Input | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 4 | 4 | -| Search Bar | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 15 | 15 | -| Slider | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 1 | -| Stepper Input | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 2 | 2 | -| Tooltip | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 9 | 9 | -| Extended Top App Bar | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 3 | 3 | -| Total Tests | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 153 | 153 | diff --git a/test/scripts/test_counter.dart b/test/scripts/test_counter.dart index 2a67e300..72dafe74 100644 --- a/test/scripts/test_counter.dart +++ b/test/scripts/test_counter.dart @@ -1,57 +1,46 @@ -import 'dart:convert'; import 'dart:io'; import 'package:analyzer/dart/analysis/utilities.dart'; import 'package:analyzer/dart/ast/ast.dart'; import 'package:analyzer/dart/ast/visitor.dart'; -String getGroupName(MethodInvocation node) { - return node.argumentList.arguments.first - .toString() - .replaceAll("'", '') - .replaceAll(r'$componentName ', '') - .replaceAll(' Tests', ''); -} - -String getTestName(MethodInvocation node) { - return node.argumentList.arguments.first.toString().replaceAll("'", ''); -} - -String getMethodName(MethodInvocation node) { - return node.methodName.name; -} - -bool hasNullParent(MethodInvocation node) { - return node.parent!.parent!.thisOrAncestorMatching((node) => node is MethodInvocation) == null; -} - -bool methodIsOneOf(List methods, MethodInvocation node) { - return methods.contains(node.methodName.name); -} - -extension StringExtension on String { - String capitalize() { - if (isEmpty) return this; - return this[0].toUpperCase() + substring(1); - } - - String capitalizeEachWord() { - return split(' ').map((word) => word.capitalize()).join(' '); - } -} - -String getComponentNameFromTestPath(String path) { - return path.split(r'\').last.split('_test').first.replaceAll('_', ' ').capitalizeEachWord(); -} - +import 'utils/utils.dart'; + +/// A visitor that recursively visits AST nodes to identify and process test groups. +/// +/// This class extends `RecursiveAstVisitor` and overrides necessary methods +/// to traverse the abstract syntax tree (AST) of Dart code. It is specifically +/// designed to locate and handle test groups within the code, which are typically +/// defined using the `group` function in test files. +/// +/// By implementing this visitor, you can analyze the structure of your test files, +/// extract information about test groups, and perform any required operations on them. +/// This can be useful for generating reports, performing static analysis, or +/// automating certain tasks related to your test suite. class TestGroupVisitor extends RecursiveAstVisitor { final List> groups = []; + /// Visits a method invocation node in the abstract syntax tree (AST). + /// + /// This method is typically used in the context of traversing or analyzing + /// Dart code. It processes a [MethodInvocation] node, which represents + /// a method call in the source code. + /// + /// [node] - The [MethodInvocation] node to visit. + /// The method checks if the method invocation is one of the following: + /// - `group` + /// - `testWidgets` + /// - `test` + /// - `goldenTest` + /// - `debugFillPropertiesTest` + /// Then it extracts the group name and test names from the method invocation. + /// + /// - Parameter node: The [MethodInvocation] node to visit. @override void visitMethodInvocation(MethodInvocation node) { - if (hasNullParent(node)) { - if (methodIsOneOf(['group'], node)) { - final groupName = getGroupName(node); + if (node.hasNullParentNode()) { + if (node.methodIsOneOf(['group'])) { + final groupName = node.getGroupName(); final groupBody = node.argumentList.arguments.last; final tests = >[]; @@ -67,8 +56,8 @@ class TestGroupVisitor extends RecursiveAstVisitor { 'group': groupName, 'tests': tests, }); - } else if (methodIsOneOf(['testWidgets', 'test', 'goldenTest', 'debugFillPropertiesTest'], node)) { - final testName = getTestName(node); + } else if (node.methodIsOneOf(['testWidgets', 'test', 'goldenTest', 'debugFillPropertiesTest'])) { + final testName = node.getTestName(); if (groups.any((el) => el['group'] == 'unorganised')) { final unorganisedGroup = groups.firstWhere((el) => el['group'] == 'unorganised'); @@ -91,24 +80,41 @@ class TestGroupVisitor extends RecursiveAstVisitor { } } -// Visitor to extract test names +/// A visitor class that extends `RecursiveAstVisitor` to traverse +/// the Abstract Syntax Tree (AST) of Dart code. This class is specifically +/// designed to extract test names from test files. +/// +/// The `TestVisitor` class overrides necessary methods to visit nodes +/// in the AST and identify test definitions. It collects the names of +/// the tests, which can then be used for various purposes such as +/// generating test reports or running specific tests. +/// class TestVisitor extends RecursiveAstVisitor { TestVisitor(this.tests); final List> tests; + /// Visits a method invocation node in the abstract syntax tree (AST). + /// This method checks if the method invocation is one of the following: + /// - `testWidgets` + /// - `test` + /// - `goldenTest` + /// - `debugFillPropertiesTest` + /// Then it extracts the test name from the method invocation. + /// + /// [node] - The [MethodInvocation] node to visit. @override void visitMethodInvocation(MethodInvocation node) { - if (methodIsOneOf(['testWidgets', 'test'], node)) { - final testName = getTestName(node); + if (node.methodIsOneOf(['testWidgets', 'test'])) { + final testName = node.getTestName(); tests.add({ 'name': testName, }); - } else if (methodIsOneOf(['debugFillPropertiesTest'], node)) { + } else if (node.methodIsOneOf(['debugFillPropertiesTest'])) { tests.add({ - 'name': getMethodName(node), + 'name': node.getMethodName(), }); - } else if (methodIsOneOf(['goldenTest'], node)) { + } else if (node.methodIsOneOf(['goldenTest'])) { tests.add({ 'name': node.toString(), }); @@ -118,39 +124,35 @@ class TestVisitor extends RecursiveAstVisitor { } } +/// Generates an MDX (Markdown Extended) table representation of the test counts. +/// +/// The function takes a nested map where the outer map's keys are test group names, +/// and the inner map's keys are test names with their corresponding integer counts. +/// +/// Example input: +/// ```dart +/// { +/// "test/src/components\\banner\\banner_test.dart": { +/// "Accessibility": 3, +/// }, +/// } +/// ``` +/// +/// Example output: +/// ```mdx +/// | Component | Accessibility | Content | Dimensions | Styling | Interaction | Golden | Performance | Unorganised | Total Tests | +/// | ----------- | ------------- | ------- | ---------- | ------- | ----------- | ------ | ----------- | ----------- | ----------- | +/// | Banner | 3 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 3 | +/// | Total Tests | 3 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 3 | +/// ``` +/// +/// Parameters: +/// - `testCount`: A map where the keys are test group names and the values are maps +/// of test names with their corresponding counts. +/// +/// Returns: +/// - A string in MDX format representing the test counts in a table with totals. String generateMDX(Map> testCount) { - final List groupNames = [ - 'Accessibility', - 'Content', - 'Dimensions', - 'Styling', - 'Interaction', - 'Golden', - 'Performance', - 'unorganised', - ]; - - final List data = [ - '| Component | Accessibility | Content | Dimensions | Styling | Interaction | Golden | Performance | Unorganised | Total Tests |', - '| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |', - ]; - - testCount.forEach((filePath, groups) { - final componentName = getComponentNameFromTestPath(filePath); - final totalTests = groups.values.fold(0, (previousValue, element) => previousValue + element); - int otherGroups = 0; - groups.forEach((key, value) { - if (!groupNames.contains(key)) { - otherGroups += value; - } - }); - otherGroups += groups['unorganised'] ?? 0; - - data.add( - '| $componentName | ${groups['Accessibility'] ?? 0} | ${groups['Content'] ?? 0} | ${groups['Dimensions'] ?? 0} | ${groups['Styling'] ?? 0} | ${groups['Interaction'] ?? 0} | ${groups['Golden'] ?? 0} | ${groups['Performance'] ?? 0} | $otherGroups | $totalTests |', - ); - }); - final Map groupTotals = { 'Accessibility': 0, 'Content': 0, @@ -162,54 +164,47 @@ String generateMDX(Map> testCount) { 'unorganised': 0, }; - testCount.forEach((filePath, groups) { - groups.forEach((key, value) { - if (!groupNames.contains(key)) { - groupTotals['unorganised'] = groupTotals['unorganised']! + value; - } else { - groupTotals[key] = groupTotals[key]! + value; - } - }); - }); - - data.add( - '| Total Tests | ${groupTotals['Accessibility']} | ${groupTotals['Content']} | ${groupTotals['Dimensions']} | ${groupTotals['Styling']} | ${groupTotals['Interaction']} | ${groupTotals['Golden']} | ${groupTotals['Performance']} | ${groupTotals['unorganised']} | ${groupTotals.values.fold(0, (previousValue, element) => previousValue + element)} |', - ); + final List data = [ + '| Component | Accessibility | Content | Dimensions | Styling | Interaction | Golden | Performance | Unorganised | Total Tests |', + '| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |', + ] + ..addComponentRows(testCount, groupTotals) + ..addCategoryTotalRow(testCount, groupTotals); return data.join('\n'); } -void main() async { - // check for output directory and create if it doesn't exist - final outputDirectory = Directory('test/output'); - if (!outputDirectory.existsSync()) { - await outputDirectory.create(recursive: true); - } - - // get all test files - final testDirectory = Directory('test/src/components'); - final testFiles = - testDirectory.listSync(recursive: true).where((entity) => entity is File && entity.path.endsWith('_test.dart')); - final Map>> testGroups = {}; - - // parse each test file and extract test groups +/// Parses a collection of test files and returns a map where the keys are +/// strings and the values are lists of maps containing dynamic data. +/// +/// The function takes an iterable of [FileSystemEntity] objects representing +/// the test files to be parsed. It processes these files asynchronously and +/// returns a [Future] that completes with a map. Each key in the map is a +/// string, and each value is a list of maps with string keys and dynamic values. +/// +/// - Parameter testFiles: An iterable collection of [FileSystemEntity] +/// objects representing the test files to be parsed. +/// - Returns: A [Future] that completes with a map where the keys are strings +/// and the values are lists of maps containing dynamic data. +Future parseTestFiles(Iterable testFiles) async { + final TestGroups testGroups = {}; for (final FileSystemEntity file in testFiles) { final contents = await File(file.path).readAsString(); - final parseResult = parseString(content: contents); final visitor = TestGroupVisitor(); parseResult.unit.visitChildren(visitor); testGroups[file.path] = visitor.groups; } + return testGroups; +} - // write test groups to file - // final jsonOutputGroups = jsonEncode(testGroups); - // final outputFileGroups = File('${outputDirectory.path}/test_groups.json'); - // await outputFileGroups.writeAsString(jsonOutputGroups); - - final Map> testCount = {}; - - // count the number of tests in each group +/// Counts the number of tests in each test group and returns a map with the counts. +/// +/// - Parameters: +/// - testGroups: A map where the keys are group names and the values are lists of test maps. +/// - Returns: A map where the keys are component names and the values are maps containing the count of tests in each test group. +Map> countTests(TestGroups testGroups) { + final TestCount testCount = {}; testGroups.forEach((filePath, groups) { final Map groupCounts = {}; for (final group in groups) { @@ -219,23 +214,28 @@ void main() async { } testCount[filePath] = groupCounts; }); + return testCount; +} - // write test counts to file - // final jsonOutput = jsonEncode(testCount); - // final outputFile = File('${outputDirectory.path}/test_counts.json'); - // await outputFile.writeAsString(jsonOutput); +void main() async { + // check for output directory and create if it doesn't exist + final Directory outputDirectory = await outputPath('test/scripts/output'); - // generate MDX table - final mdxOutput = generateMDX(testCount); - final mdxFile = File('${outputDirectory.path}/test_table.mdx'); - await mdxFile.writeAsString(mdxOutput); -} + // get all test files + final Iterable testFiles = getTestFiles('test/src/components'); + + // parse each test file and extract test groups + final TestGroups testGroups = await parseTestFiles(testFiles); + // write test groups to file + await writeJSONToFile('${outputDirectory.path}/test_groups.json', testGroups); + // count the number of tests in each group + final TestCount testCount = countTests(testGroups); -/** TODO: - * - Abstract the code into functions - * - Add comments - * - Add error handling - * - GITHUB ACTION TO RUN THE SCRIPT - * */ + // write test counts to file + await writeJSONToFile('${outputDirectory.path}/test_counts.json', testCount); + + // generate MDX table + await writeMDXToFile('${outputDirectory.path}/test_table.mdx', generateMDX(testCount)); +} diff --git a/test/scripts/utils/utils.dart b/test/scripts/utils/utils.dart new file mode 100644 index 00000000..bcfc30c3 --- /dev/null +++ b/test/scripts/utils/utils.dart @@ -0,0 +1,249 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:analyzer/dart/ast/ast.dart'; + +/// A typedef for a map that associates a string key with a list of maps. +/// Each map in the list contains string keys and dynamic values. +/// +/// This can be used to group test cases or data sets by a specific category or identifier. +/// +/// Example: +/// ```dart +/// TestGroups testGroups = "path/to/test/file_test.dart": [ +/// { +/// 'group': 'Accessibility', +/// 'tests': [ +/// {'name': 'value1'}, +/// {'name': 'value2'}, +/// ], +/// }, +/// { +/// 'group': 'Content', +/// 'tests': [ +/// {'name': 'value1'}, +/// ], +/// }, +/// }; +/// ``` +typedef TestGroups = Map>>; + +/// A typedef for a nested map structure that counts tests. +/// +/// The outer map uses a `String` as the key, which represents a test file path. +/// The inner map also uses a `String` as the key, which represents a test category. +/// The value of the inner map is an `int` that represents the count of occurrences or results for that specific test category. +/// +/// Example: +/// ```dart +/// TestCount testCount = { +/// "path/to/test/file_test.dart": { +/// "Accessibility": 3, +/// "Content": 2, +/// }, +/// }; +/// ``` +typedef TestCount = Map>; + +extension NodeExtension on MethodInvocation { + /// Checks if the current node has a null parent node. + /// + /// Returns `true` if the parent node is null, otherwise `false`. + bool hasNullParentNode() { + return parent!.parent!.thisOrAncestorMatching((node) => node is MethodInvocation) == null; + } + + /// Retrieves and sanitizes the name of the test group. + /// + /// Returns: + /// A [String] containing the sanitized name of the group. + String getGroupName() { + return argumentList.arguments.first + .toString() + .replaceAll("'", '') + .replaceAll(r'$componentName ', '') + .replaceAll(' Tests', ''); + } + + /// Retrieves and sanitizes the name of the test. + /// + /// Returns: + /// A [String] containing the sanitized name of the test. + String getTestName() { + return argumentList.arguments.first.toString().replaceAll("'", ''); + } + + /// Returns the name of the method as a string. + /// + /// Returns: + /// A [String] representing the method name. + String getMethodName() { + return methodName.name; + } + + /// Checks if the current method is one of the specified methods. + /// + /// This function takes a list of method names and checks if the current + /// method is included in that list. + /// + /// - Parameter methods: A list of method names to check against. + /// - Returns: `true` if the current method is one of the specified methods, + /// otherwise `false`. + bool methodIsOneOf(List methods) { + return methods.contains(methodName.name); + } +} + +extension StringExtension on String { + /// Capitalizes the first letter of the string. + /// + /// Returns a new string with the first letter converted to uppercase + /// and the remaining letters unchanged. + /// + /// Example: + /// + /// ```dart + /// String text = "hello"; + /// String capitalizedText = text.capitalize(); + /// print(capitalizedText); // Output: Hello + /// ``` + String capitalize() { + if (isEmpty) return this; + return this[0].toUpperCase() + substring(1); + } + + /// Capitalizes the first letter of each word in a string. + /// + /// This method splits the string by spaces, capitalizes the first letter + /// of each word, and then joins the words back together with spaces. + /// + /// Returns a new string with each word capitalized. + String capitalizeEachWord() { + return split(' ').map((word) => word.capitalize()).join(' '); + } +} + +/// Extracts the component name from a given test file path. +/// +/// This function takes a test file path as input and returns the component name +/// inferred from the path. The component name is extracted by splitting the path +/// and removing the file extension and test suffix. +/// +/// Example: +/// ```dart +/// String componentName = getComponentNameFromTestPath('test/src/components/comms_button/comms_button_test.dart'); +/// print(componentName); // Output: Comms Button +/// ``` +/// +/// - Parameter path: The file path from which to extract the component name. +/// - Returns: The component name as a string. +String getComponentNameFromTestPath(String path) { + return path.split(r'\').last.split('_test').first.replaceAll('_', ' ').capitalizeEachWord(); +} + +/// Returns a [Future] that completes with a [Directory] at the specified [path]. +/// +/// This function takes a [String] [path] and asynchronously returns a [Directory] +/// object representing the directory at the given path. +/// +/// Example: +/// ```dart +/// Directory dir = await outputPath('/path/to/directory'); +/// ``` +Future outputPath(String path) async { + final outputDirectory = Directory(path); + if (!outputDirectory.existsSync()) { + await outputDirectory.create(recursive: true); + } + return outputDirectory; +} + +/// Retrieves an iterable of file system entities from the specified path. +/// +/// This function takes a [path] as a string and returns an [Iterable] of +/// [FileSystemEntity] objects representing the test files located at the given path. +/// +/// - Parameter path: The path to the directory from which to retrieve the files. +/// - Returns: An iterable collection of file system entities found at the specified path. +Iterable getTestFiles(String path) { + final testDirectory = Directory(path); + return testDirectory + .listSync(recursive: true) + .where((entity) => entity is File && entity.path.endsWith('_test.dart')); +} + +/// Writes the given JSON content to a file at the specified path. +/// +/// This function takes a file path and JSON content, and writes the content +/// to the file asynchronously. If the file does not exist, it will be created. +/// +/// [path] The file path where the JSON content should be written. +/// [content] The JSON content to write to the file. +Future writeJSONToFile(String path, dynamic content) async { + final jsonOutputGroups = jsonEncode(content); + final outputFileGroups = File(path); + await outputFileGroups.writeAsString(jsonOutputGroups); +} + +/// Writes the given MDX data to a file at the specified path. +/// +/// This function asynchronously writes the provided MDX data to a file +/// located at the given path. If the file does not exist, it will be created. +/// +/// [path] The file path where the MDX data should be written. +/// [mdxData] The MDX data to write to the file. +Future writeMDXToFile(String path, String mdxData) async { + final mdxFile = File(path); + await mdxFile.writeAsString(mdxData); +} + +extension ListExtension on List { + /// Adds rows of components to the specified target. + /// + /// This function iterates over the test count and group totals + /// to add the component rows to the target. + void addComponentRows( + Map> testCount, + Map groupTotals, + ) { + testCount.forEach((filePath, groups) { + final componentName = getComponentNameFromTestPath(filePath); + + int unorganisedTestsInComponent = 0; + groups.forEach((key, value) { + if (!groupTotals.keys.contains(key)) { + unorganisedTestsInComponent += value; + } + }); + unorganisedTestsInComponent += groups['unorganised'] ?? 0; + + final totalTestsForComponent = groups.values.fold(0, (previousValue, element) => previousValue + element); + + return add( + '| $componentName | ${groups['Accessibility'] ?? 0} | ${groups['Content'] ?? 0} | ${groups['Dimensions'] ?? 0} | ${groups['Styling'] ?? 0} | ${groups['Interaction'] ?? 0} | ${groups['Golden'] ?? 0} | ${groups['Performance'] ?? 0} | $unorganisedTestsInComponent | $totalTestsForComponent |', + ); + }); + } + + /// Adds a total row for a category in the data table. + /// + /// This method calculates the total for a specific category and appends + /// a row to the data table displaying the totals. + void addCategoryTotalRow( + Map> testCount, + Map groupTotals, + ) { + testCount.forEach((filePath, groups) { + groups.forEach((key, value) { + if (!groupTotals.keys.contains(key)) { + groupTotals['unorganised'] = groupTotals['unorganised']! + value; + } else { + groupTotals[key] = groupTotals[key]! + value; + } + }); + }); + return add( + '| Total Tests | ${groupTotals['Accessibility']} | ${groupTotals['Content']} | ${groupTotals['Dimensions']} | ${groupTotals['Styling']} | ${groupTotals['Interaction']} | ${groupTotals['Golden']} | ${groupTotals['Performance']} | ${groupTotals['unorganised']} | ${groupTotals.values.fold(0, (previousValue, element) => previousValue + element)} |', + ); + } +} diff --git a/test/src/zeta_provider_test.dart b/test/src/zeta_provider_test.dart index ecf19b76..a8012fd6 100644 --- a/test/src/zeta_provider_test.dart +++ b/test/src/zeta_provider_test.dart @@ -86,6 +86,9 @@ void main() { // Verifying through the public interface of Zeta widget final zeta = tester.widget(find.byType(Zeta)); + + //TODO: if you change newThemeData to initalThemeData it will still pass + // expect(zeta.themeData, initialThemeData); expect(zeta.themeData, newThemeData); verify( mockThemeService.saveTheme( From 5e3f4b565236eca658fa07c54a1bbd6ed7c8b252 Mon Sep 17 00:00:00 2001 From: Daniel Eshkeri Date: Mon, 14 Oct 2024 15:01:03 +0100 Subject: [PATCH 3/6] docs: added info about how to run the script to testing read me --- test/TESTING_README.mdx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/test/TESTING_README.mdx b/test/TESTING_README.mdx index b33e066e..0789e872 100644 --- a/test/TESTING_README.mdx +++ b/test/TESTING_README.mdx @@ -69,8 +69,16 @@ void main() { } ``` +### Test Visibility Table + +You can find the test visibility table at the following path: 'test/scripts/output/test_table.mdx' + +To generate the table run the following command from the root of the project: + +```bash +dart test/scripts/test_counter.dart +``` + #### Visibility Excel Sheet https://zebra-my.sharepoint.com/:x:/p/de7924/Ea0l7BF7AzJJoBVPrg4cIswBZRyek6iNT3zzwDcLn-5ZGg?e=NTJIZU - -### Test Table From ae63ac6144d3c2ad6a1516841b1873401bf00620 Mon Sep 17 00:00:00 2001 From: Daniel Eshkeri Date: Mon, 14 Oct 2024 17:05:20 +0100 Subject: [PATCH 4/6] removed comment --- test/src/zeta_provider_test.dart | 3 --- 1 file changed, 3 deletions(-) diff --git a/test/src/zeta_provider_test.dart b/test/src/zeta_provider_test.dart index a8012fd6..ecf19b76 100644 --- a/test/src/zeta_provider_test.dart +++ b/test/src/zeta_provider_test.dart @@ -86,9 +86,6 @@ void main() { // Verifying through the public interface of Zeta widget final zeta = tester.widget(find.byType(Zeta)); - - //TODO: if you change newThemeData to initalThemeData it will still pass - // expect(zeta.themeData, initialThemeData); expect(zeta.themeData, newThemeData); verify( mockThemeService.saveTheme( From d29c8d3fa3b6e4f90ea426ef52151793886c999b Mon Sep 17 00:00:00 2001 From: Daniel Eshkeri Date: Tue, 15 Oct 2024 09:36:19 +0100 Subject: [PATCH 5/6] fix: moved analyzer package from dependencies to dev dependencies --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index b39bb34f..0cf9d7a2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -21,7 +21,6 @@ environment: flutter: ">=3.16.0" dependencies: - analyzer: ^6.7.0 collection: ^1.18.0 equatable: ^2.0.5 flutter: @@ -39,6 +38,7 @@ dev_dependencies: mockito: ^5.4.4 path: ^1.9.0 zds_analysis: ^1.0.0 + analyzer: ^6.7.0 flutter: fonts: From 03d9268549823bcd7c4fd3b670fdbd02e0ebf40b Mon Sep 17 00:00:00 2001 From: Daniel Eshkeri Date: Tue, 15 Oct 2024 09:46:23 +0100 Subject: [PATCH 6/6] fix: put dev dependencies in alphabetical order --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index 0cf9d7a2..946dd016 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -31,6 +31,7 @@ dependencies: web: ^1.0.0 dev_dependencies: + analyzer: ^6.7.0 build_runner: ^2.4.10 flutter_test: sdk: flutter @@ -38,7 +39,6 @@ dev_dependencies: mockito: ^5.4.4 path: ^1.9.0 zds_analysis: ^1.0.0 - analyzer: ^6.7.0 flutter: fonts: