blob: 8823b186322b1926ddc5056252cbea402408db5c [file] [edit]
// 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}');
}
}