// Copyright (c) 2020, the Dart project authors. Please see the AUTHORS file // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. import 'dart:io'; import 'package:path/path.dart' as p; import 'package:test/test.dart'; import 'package:yaml/yaml.dart'; import 'package:yaml_edit/src/utils.dart'; import 'package:yaml_edit/yaml_edit.dart'; import 'test_utils.dart'; /// Interface for creating golden Test cases class TestCases { final List<_TestCase> _testCases; /// Creates a [TestCases] object based on test directory and golden directory /// path. static Future getTestCases(Uri testDirUri, Uri goldDirUri) async { final testDir = Directory.fromUri(testDirUri); if (!testDir.existsSync()) return TestCases([]); /// Recursively grab all the files in the testing directory. return TestCases(await testDir .list(recursive: true, followLinks: false) .where((entity) => entity.path.endsWith('.test')) .map((entity) => entity.uri) .map((inputUri) { final inputWithoutExtension = p.basenameWithoutExtension(inputUri.toFilePath()); final goldenUri = goldDirUri.resolve('./$inputWithoutExtension.golden'); return _TestCase(inputUri, goldenUri); }).toList()); } /// Tests all the [_TestCase]s if the golden files exist, create the golden /// files otherwise. void test() { var tested = 0; var created = 0; for (final testCase in _testCases) { testCase.testOrCreate(); if (testCase.state == _TestCaseStates.testedGoldenFile) { tested++; } else if (testCase.state == _TestCaseStates.createdGoldenFile) { created++; } } print('Successfully tested $tested inputs against golden files, created ' '$created golden files'); } TestCases(this._testCases); int get length => _testCases.length; } /// Enum representing the different states of [_TestCase]s. enum _TestCaseStates { initialized, createdGoldenFile, testedGoldenFile } /// Interface for a golden test case. Handles the logic for test conduct/golden /// test update accordingly. class _TestCase { final Uri inputUri; final Uri goldenUri; final List states = []; late String info; late YamlEditor yamlBuilder; late List<_YamlModification> modifications; String inputLineEndings = '\n'; _TestCaseStates state = _TestCaseStates.initialized; _TestCase(this.inputUri, this.goldenUri) { final inputFile = File.fromUri(inputUri); if (!inputFile.existsSync()) { throw Exception('Input File does not exist!'); } _initialize(inputFile); } /// Initializes the [_TestCase] by reading the corresponding [inputFile] and /// parsing the different portions, and then running the input yaml against /// the specified modifications. /// /// Precondition: [inputFile] must exist, and inputs must be well-formatted. void _initialize(File inputFile) { final input = inputFile.readAsStringSync(); final inputLineEndings = getLineEnding(input); final inputElements = input.split('---$inputLineEndings'); if (inputElements.length != 3) { throw AssertionError('File ${inputFile.path} is not properly formatted.'); } info = inputElements[0]; yamlBuilder = YamlEditor(inputElements[1]); final rawModifications = _getValueFromYamlNode(loadYaml(inputElements[2]) as YamlNode) as List; modifications = _parseModifications(rawModifications); /// Adds the initial state as well, so we can check that the simplest /// parse -> immediately dump does not affect the string. states.add(yamlBuilder.toString()); _performModifications(); } void _performModifications() { for (final mod in modifications) { _performModification(mod); states.add(yamlBuilder.toString()); } } void _performModification(_YamlModification mod) { switch (mod.method) { case YamlModificationMethod.update: yamlBuilder.update(mod.path, mod.value); return; case YamlModificationMethod.remove: yamlBuilder.remove(mod.path); return; case YamlModificationMethod.appendTo: yamlBuilder.appendToList(mod.path, mod.value); return; case YamlModificationMethod.prependTo: yamlBuilder.prependToList(mod.path, mod.value); return; case YamlModificationMethod.insert: yamlBuilder.insertIntoList(mod.path, mod.index, mod.value); return; case YamlModificationMethod.splice: yamlBuilder.spliceList( mod.path, mod.index, mod.deleteCount, mod.value as List); return; } } void testOrCreate() { final goldenFile = File.fromUri(goldenUri); if (!goldenFile.existsSync()) { createGoldenFile(goldenFile); } else { testGoldenFile(goldenFile); } } void createGoldenFile(File goldenFile) { /// Assumes user wants the golden file to have the same line endings as /// the input file. final goldenOutput = states.join('---$inputLineEndings'); goldenFile.writeAsStringSync(goldenOutput); state = _TestCaseStates.createdGoldenFile; } /// Tests the golden file. Ensures that the number of states are the same, and /// that the individual states are the same. void testGoldenFile(File goldenFile) { final inputFileName = p.basename(inputUri.toFilePath()); final golden = goldenFile.readAsStringSync(); final goldenStates = golden.split('---${getLineEnding(golden)}'); group('testing $inputFileName - input and golden files have', () { test('same number of states', () { expect(states.length, equals(goldenStates.length)); }); for (var i = 0; i < states.length; i++) { test('same state $i', () { expect(states[i], equals(goldenStates[i])); }); } }); state = _TestCaseStates.testedGoldenFile; } } /// Converts [yamlList] into a Dart list. List _getValueFromYamlList(YamlList yamlList) { return yamlList.value.map((n) { if (n is YamlNode) return _getValueFromYamlNode(n); return n; }).toList(); } /// Converts [yamlMap] into a Dart Map. Map _getValueFromYamlMap(YamlMap yamlMap) { final keys = yamlMap.keys; final result = {}; for (final key in keys) { final value = yamlMap[key]; if (value is YamlNode) { result[key] = _getValueFromYamlNode(value); } else { result[key] = value; } } return result; } /// Converts a [YamlNode] into a Dart object. dynamic _getValueFromYamlNode(YamlNode node) { if (node is YamlList) { return _getValueFromYamlList(node); } if (node is YamlMap) { return _getValueFromYamlMap(node); } return node.value; } /// Converts the list of modifications from the raw input to [_YamlModification] /// objects. List<_YamlModification> _parseModifications(List modifications) { return modifications.map((mod) { if (mod is! List) throw UnimplementedError(); Object? value; var index = 0; var deleteCount = 0; final method = _getModificationMethod(mod[0] as String); final path = mod[1] as List; if (method == YamlModificationMethod.appendTo || method == YamlModificationMethod.update || method == YamlModificationMethod.prependTo) { value = mod[2]; } else if (method == YamlModificationMethod.insert) { index = mod[2] as int; value = mod[3]; } else if (method == YamlModificationMethod.splice) { index = mod[2] as int; deleteCount = mod[3] as int; if (mod[4] is! List) { throw ArgumentError('Invalid array ${mod[4]} used in splice'); } value = mod[4]; } return _YamlModification(method, path, index, value, deleteCount); }).toList(); } /// Gets the YAML modification method corresponding to [method] YamlModificationMethod _getModificationMethod(String method) { switch (method) { case 'update': return YamlModificationMethod.update; case 'remove': return YamlModificationMethod.remove; case 'append': case 'appendTo': return YamlModificationMethod.appendTo; case 'prepend': case 'prependTo': return YamlModificationMethod.prependTo; case 'insert': case 'insertIn': return YamlModificationMethod.insert; case 'splice': return YamlModificationMethod.splice; default: throw Exception('$method not recognized!'); } } /// Class representing an abstract YAML modification to be performed class _YamlModification { final YamlModificationMethod method; final List path; final int index; final dynamic value; final int deleteCount; _YamlModification( this.method, this.path, this.index, this.value, this.deleteCount); @override String toString() => 'method: $method, path: $path, index: $index, value: $value, ' 'deleteCount: $deleteCount'; }