| // Copyright (c) 2011, 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 'package:io/ansi.dart' as ansi; |
| import 'package:markdown/markdown.dart'; |
| import 'package:path/path.dart' as p; |
| import 'package:test/test.dart'; |
| |
| import '../tool/expected_output.dart'; |
| |
| /// Runs tests defined in "*.unit" files inside directory [name]. |
| void testDirectory(String name, {ExtensionSet? extensionSet}) { |
| for (final dataCase in dataCasesUnder(testDirectory: name)) { |
| final description = |
| '${dataCase.directory}/${dataCase.file}.unit ${dataCase.description}'; |
| |
| final inlineSyntaxes = <InlineSyntax>[]; |
| final blockSyntaxes = <BlockSyntax>[]; |
| var enableTagfilter = false; |
| |
| if (dataCase.file.endsWith('_extension')) { |
| final extension = dataCase.file.substring( |
| 0, |
| dataCase.file.lastIndexOf('_extension'), |
| ); |
| switch (extension) { |
| case 'autolinks': |
| inlineSyntaxes.add(AutolinkExtensionSyntax()); |
| break; |
| case 'strikethrough': |
| inlineSyntaxes.add(StrikethroughSyntax()); |
| break; |
| case 'tables': |
| blockSyntaxes.add(const TableSyntax()); |
| break; |
| case 'disallowed_raw_html': |
| enableTagfilter = true; |
| break; |
| default: |
| throw UnimplementedError('Unimplemented extension "$extension"'); |
| } |
| } |
| |
| validateCore( |
| description, |
| dataCase.input, |
| dataCase.expectedOutput, |
| extensionSet: extensionSet, |
| inlineSyntaxes: inlineSyntaxes, |
| blockSyntaxes: blockSyntaxes, |
| enableTagfilter: enableTagfilter, |
| ); |
| } |
| } |
| |
| void testFile( |
| String file, { |
| Iterable<BlockSyntax> blockSyntaxes = const [], |
| Iterable<InlineSyntax> inlineSyntaxes = const [], |
| }) { |
| for (final dataCase in dataCasesInFile( |
| path: p.join(p.current, 'test', file), |
| )) { |
| final description = |
| '${dataCase.directory}/${dataCase.file}.unit ${dataCase.description}'; |
| validateCore( |
| description, |
| dataCase.input, |
| dataCase.expectedOutput, |
| blockSyntaxes: blockSyntaxes, |
| inlineSyntaxes: inlineSyntaxes, |
| ); |
| } |
| } |
| |
| void validateCore( |
| String description, |
| String markdown, |
| String html, { |
| Iterable<BlockSyntax> blockSyntaxes = const [], |
| Iterable<InlineSyntax> inlineSyntaxes = const [], |
| ExtensionSet? extensionSet, |
| Resolver? linkResolver, |
| LinkBuilder? linkBuilder, |
| Resolver? imageLinkResolver, |
| LinkBuilder? imageLinkBuilder, |
| bool inlineOnly = false, |
| bool enableTagfilter = false, |
| }) { |
| test(description, () { |
| final result = markdownToHtml( |
| markdown, |
| blockSyntaxes: blockSyntaxes, |
| inlineSyntaxes: inlineSyntaxes, |
| extensionSet: extensionSet, |
| linkResolver: linkResolver, |
| linkBuilder: linkBuilder, |
| imageLinkResolver: imageLinkResolver, |
| imageLinkBuilder: imageLinkBuilder, |
| inlineOnly: inlineOnly, |
| enableTagfilter: enableTagfilter, |
| ); |
| |
| markdownPrintOnFailure(markdown, html, result); |
| |
| expect(result, html); |
| }); |
| } |
| |
| String whitespaceColor(String input) => input |
| .replaceAll(' ', ansi.lightBlue.wrap('ยท')!) |
| .replaceAll('\t', ansi.backgroundDarkGray.wrap('\t')!); |
| |
| void markdownPrintOnFailure(String markdown, String expected, String actual) { |
| printOnFailure(""" |
| INPUT: |
| '''r |
| ${whitespaceColor(markdown)}''' |
| |
| EXPECTED: |
| '''r |
| ${whitespaceColor(expected)}''' |
| |
| GOT: |
| '''r |
| ${whitespaceColor(actual)}''' |
| """); |
| } |
| |
| // Matchers for element structures. |
| // -------------------------------------------------------------------- |
| |
| /// Matcher for an [Element] with a matching [Element.tag]. |
| /// |
| /// The [tag] can be a literal string or any matcher which matches as string. |
| /// The `tag` value can also be an [Element], in which case [children] and |
| /// [attributes] are ignored and the values taken from the element instead. |
| /// |
| /// If [children] and [attributes] are provided, |
| /// they're matched against the element's [Element.children] and |
| /// [Element.attributes]. |
| /// |
| /// The [children] can be a [List] of matchers for nodes, which includes nodes |
| /// and [String] values, which are matched as [Text] nodes with that content, |
| /// or it can be any [Matcher] which matches such a list. |
| /// |
| /// The [attributes] can be a [Map] of matchers for attributes, |
| /// or it can be any [Matcher] which matches such a map. |
| /// |
| /// If [isEmpty] is true, the element is matched as self closing. |
| ElementMatcher isElement( |
| Object tag, [ |
| Object? children, |
| Object? attributes, |
| bool? isEmpty, |
| ]) => ElementMatcher( |
| tag, |
| children: children, |
| attributes: attributes, |
| isEmpty: isEmpty, |
| ); |
| |
| /// A matcher for a self-closing element. |
| /// |
| /// Same as `isElement(tag, null, attributes, true)`. |
| ElementMatcher isEmptyElement(Object tag, Object? attributes) => |
| isElement(tag, null, attributes, true); |
| |
| /// Matcher for a [Text] with a matching [Text.textContent]. |
| /// |
| /// The [isElement] `children` list interprets [String] values |
| /// as [Text] elements. |
| /// |
| /// The [text] can be a verbatim text string, or any matcher which can |
| /// match a [String]. |
| TextMatcher isText(Object text) => TextMatcher(text); |
| |
| abstract class NodeMatcher extends Matcher { |
| NodeMatcher(); |
| factory NodeMatcher.of(Node node) => node is Element |
| ? ElementMatcher.of(node) |
| : node is Text |
| ? TextMatcher.of(node) |
| : throw UnsupportedError('Unexpected node type: ${node.runtimeType}'); |
| } |
| |
| class ElementMatcher extends NodeMatcher { |
| final Matcher tag; |
| final Matcher? children; // Matches a list of nodes. |
| final Matcher? attributes; |
| bool? isEmpty; |
| |
| /// Matcher for an [Element] where [tag] matches the element's tag. |
| /// |
| /// If [tag] is an [Element], then [children], [attributes] and [isEmpty] |
| /// are ignored and the matcher is the same as [ElementMatcher.of] |
| /// of that element. |
| /// |
| /// The [children] must match the child nodes if supplied, |
| /// and ignores them if `null`. |
| /// If [children] is a list, [Node] entries match nodes |
| /// and [String] entries match [Text] nodes with that text. |
| /// To check that an element is empty/self closing, |
| /// you can use `equals(null)` as children matcher, |
| /// use pass `true` to [isEmpty]. |
| /// |
| ///The [attributes] must match the attributes if supplied, |
| /// and ignores them if `null`. |
| factory ElementMatcher( |
| Object tag, { |
| Object? children, |
| Object? attributes, |
| bool? isEmpty, |
| }) => tag is Element |
| ? ElementMatcher.of(tag) |
| : ElementMatcher._(tag, children, attributes, isEmpty); |
| |
| /// Mataches an element equivalent to [node]. |
| /// |
| /// A matched element has the same tag, equivalent children |
| /// and the same attributes as [node]. |
| ElementMatcher.of(Element node) |
| : this._(node.tag, node.children, node.attributes, node.isEmpty); |
| |
| ElementMatcher._( |
| Object tag, |
| Object? children, |
| Object? attributes, |
| this.isEmpty, |
| ) : tag = _asMatcher(tag), |
| children = children == null |
| ? null |
| : children is List<Object?> |
| ? equals([ |
| for (var childMatch in children) |
| if (childMatch is Node) |
| NodeMatcher.of(childMatch) |
| else if (childMatch is String) |
| TextMatcher(childMatch) |
| else |
| childMatch, |
| ]) |
| : _asMatcher(children), |
| attributes = attributes == null ? null : _asMatcher(attributes); |
| |
| @override |
| bool matches(Object? item, Map<Object?, Object?> matchState) => |
| item is Element && |
| tag.matches(item.tag, matchState) && |
| (isEmpty == null || isEmpty == item.isEmpty) && |
| (children?.matches(item.children, matchState) ?? true) && |
| (attributes?.matches(item.attributes, matchState) ?? true); |
| |
| @override |
| Description describe(Description description) { |
| description.add('Element('); |
| tag.describe(description); |
| description.add(', '); |
| if (children case final children?) { |
| children.describe(description); |
| } else { |
| description.add('_'); |
| } |
| if (attributes case final attributes?) { |
| description.add(', '); |
| attributes.describe(description); |
| } |
| if (isEmpty != null) { |
| description |
| ..add(', isEmpty: ') |
| ..add(isEmpty.toString()); |
| } |
| description.add(')'); |
| |
| return description; |
| } |
| |
| @override |
| Description describeMismatch( |
| Object? item, |
| Description mismatchDescription, |
| Map<Object?, Object?> matchState, |
| bool verbose, |
| ) { |
| if (item is Node) { |
| mismatchDescription = mismatchDescription.add('is: \n'); |
| mismatchDescription = mismatchDescription.add(nodeToString(item)); |
| mismatchDescription = mismatchDescription.add('\n'); |
| return mismatchDescription; |
| } |
| return super.describeMismatch( |
| item, |
| mismatchDescription, |
| matchState, |
| verbose, |
| ); |
| } |
| } |
| |
| class TextMatcher extends NodeMatcher { |
| final Matcher textMatcher; |
| |
| /// Matches a [Text] object by [textMatch]. |
| /// |
| /// The [textMatch] can be any matcher which matches a [String], |
| /// or a [Text] object, in which case the matcher matches the same |
| /// [Text.textContent] as the given [Text] object. |
| factory TextMatcher(Object textMatch) => |
| textMatch is Text ? TextMatcher.of(textMatch) : TextMatcher._(textMatch); |
| |
| /// Matcher for a [Text] where [textMatcher] matches its [Text.textContent]. |
| TextMatcher._(Object textMatch) : textMatcher = _asMatcher(textMatch); |
| |
| /// Matcher for a [Text] where with the same [Text.textContent]. |
| TextMatcher.of(Text node) : this._(node.textContent); |
| |
| @override |
| bool matches(Object? item, Map<Object?, Object?> matchState) => |
| item is Text && textMatcher.matches(item.textContent, matchState); |
| |
| @override |
| Description describe(Description description) { |
| description.add('Text('); |
| textMatcher.describe(description); |
| description.add(')'); |
| return description; |
| } |
| } |
| |
| /// The [value] as a [Matcher], if it isn't one already. |
| Matcher _asMatcher(Object? value) => value is Matcher ? value : equals(value); |
| |
| /// Creates a textual representation of a node, useful for debugging mis-matches |
| /// with the matchers above. |
| /// |
| /// If [depth] is set, recursion is limited to that many levels. |
| String nodeToString(Node node, {int indent = 0, int depth = -1}) { |
| final buffer = StringBuffer(); |
| _writeNode(node, buffer, ' ' * indent, depth); |
| return buffer.toString(); |
| } |
| |
| void _writeNode(Node node, StringBuffer buffer, String indent, int depth) { |
| if (node is Text) { |
| buffer |
| ..write('Text("') |
| ..write(node.textContent) |
| ..write('"),'); |
| } else if (node is Element) { |
| buffer.write('Element("${node.tag}", ['); |
| final nextIndent = '$indent '; |
| if (node.children case final children? when children.isNotEmpty) { |
| if (depth == 0) { |
| buffer.write('...'); |
| } else { |
| buffer.write('\n'); |
| for (final child in children) { |
| buffer.write(nextIndent); |
| _writeNode(child, buffer, nextIndent, depth - 1); |
| buffer.write('\n'); |
| } |
| buffer.write(indent); |
| } |
| } |
| buffer.write(']'); |
| if (node.attributes.isNotEmpty) { |
| buffer.write(', {\n'); |
| for (final MapEntry(:key, :value) in node.attributes.entries) { |
| buffer |
| ..write(nextIndent) |
| ..write('"') |
| ..write(key) |
| ..write('": "') |
| ..write(value) |
| ..write('",\n'); |
| } |
| buffer |
| ..write(indent) |
| ..write('}'); |
| } |
| buffer.write('),'); |
| } else { |
| throw UnsupportedError('Unexpected node type: ${node.runtimeType}'); |
| } |
| } |