the-mews/app/test/html_test.dart

265 lines
9 KiB
Dart

import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:mews/mews/document.dart';
import 'package:mews/mews/html.dart';
/// Ordinary web markup: a doctype, unclosed tags, text loose inside wrappers,
/// and plenty the Mews subset has nothing to draw for.
const webPage = '''
<!DOCTYPE html>
<html lang="en">
<head>
<title>A Journal</title>
<style>body { color: red }</style>
<script>ignore.me()</script>
</head>
<body>
<nav><a href="/">Home</a></nav>
<div class="wrap" @click="toggle()" v-bind:href="x">
Loose text in a div.
<h2>On old phones</h2>
<p>Notes on <em>old phones</em> and <a href="about.html">the about page</a>.
<ul>
<li>First
<li>Second
</ul>
<blockquote>Worth keeping.</blockquote>
<pre> kept as is</pre>
<table><tr><th>Model<td>Price<tr><td>3310<td>Ten</table>
<hr>
<svg><circle r="4"/></svg>
<iframe src="https://ads.example/frame"></iframe>
<button>Press me</button>
</div>
</body>
</html>
''';
/// Every bit of readable text in a page, so a test can say what survived the
/// conversion and what did not.
String textOf(List<MewsBlock> blocks) {
final parts = <String>[];
void addInline(List<MewsInline> runs) {
for (final run in runs) {
switch (run) {
case MewsText(:final text):
parts.add(text);
case MewsEmphasis(:final children):
addInline(children);
case MewsLink(:final children):
addInline(children);
case MewsLineBreak():
break;
}
}
}
void addBlocks(List<MewsBlock> blocks) {
for (final block in blocks) {
switch (block) {
case HeadingBlock(:final inline):
addInline(inline);
case ParagraphBlock(:final inline):
addInline(inline);
case AddressBlock(:final inline):
addInline(inline);
case QuoteBlock(:final children):
addBlocks(children);
case PreBlock(:final text):
parts.add(text);
case ListBlock(:final items):
for (final item in items) {
addBlocks(item.blocks);
}
case DefinitionListBlock(:final items):
for (final item in items) {
addInline(item.term);
addInline(item.definition);
}
case TableBlock(:final rows):
for (final row in rows) {
for (final cell in row.cells) {
addInline(cell.inline);
}
}
case ImageBlock(:final alt):
parts.add(alt);
case RuleBlock():
break;
case FormBlock():
break;
}
}
}
addBlocks(blocks);
return parts.join(' ');
}
({MewsPage page, bool declaresMews}) convert(String source,
{String url = 'https://example.com/journal'}) =>
convertHtmlPage(url: url, source: source);
void main() {
test('converts a web page into the blocks Mews supports', () {
final page = convert(webPage).page;
final kinds = page.body.map((b) => b.runtimeType.toString()).toList();
expect(page.title, 'A Journal');
expect(page.lang, 'en');
expect(kinds, contains('HeadingBlock'));
expect(kinds, contains('ParagraphBlock'));
expect(kinds, contains('ListBlock'));
expect(kinds, contains('QuoteBlock'));
expect(kinds, contains('PreBlock'));
expect(kinds, contains('TableBlock'));
expect(kinds, contains('RuleBlock'));
});
test('a converted page is marked as a simplified view', () {
expect(convert(webPage).page.specVersion, htmlSpecVersion);
// The marker is what makes a page Mews, and this one never claimed it.
expect(convert(webPage).declaresMews, isFalse);
});
test('a page claiming the Mews marker is told apart', () {
const claimed = '<html><head><title>T</title>'
'<meta name="mews-profile" content="0.1"></head>'
'<body><p>Never closed</body></html>';
expect(convert(claimed).declaresMews, isTrue);
});
test('keeps text sitting directly inside an unknown wrapper', () {
final page = convert(webPage).page;
final text = page.body
.whereType<ParagraphBlock>()
.expand((b) => b.inline.whereType<MewsText>())
.map((t) => t.text)
.join(' ');
expect(text, contains('Loose text in a div.'));
});
test('closes tags the page left open', () {
final page = convert('<body><p>One<p>Two<ul><li>A<li>B</ul></body>').page;
expect(page.body.whereType<ParagraphBlock>().length, 2);
expect(page.body.whereType<ListBlock>().single.items.length, 2);
});
test('leaves out markup that is not content', () {
final all = textOf(convert(webPage).page.body);
expect(all, isNot(contains('ignore.me')));
expect(all, isNot(contains('color: red')));
expect(all, isNot(contains('Press me')));
expect(all, isNot(contains('circle')));
// A nav is a list of real links, so it is kept rather than stripped.
expect(all, contains('Home'));
// The table's cells come through.
expect(all, contains('3310'));
});
test('resolves a relative link against the page address', () {
final page = convert(webPage).page;
final links = page.body
.whereType<ParagraphBlock>()
.expand((b) => b.inline.whereType<MewsLink>())
.map((l) => l.href);
expect(links, contains('https://example.com/about.html'));
});
test('an image on another site falls back to its alt text', () {
const source = '<body><img src="https://cdn.example/a.png" alt="A chart">'
'<img src="/own.png" alt="Ours"></body>';
final images = convert(source).page.body.whereType<ImageBlock>().toList();
expect(images.first.allowed, isFalse);
expect(images.first.alt, 'A chart');
expect(images.last.allowed, isTrue);
});
test('an attribute no XML name allows does not stop the page', () {
// The wrapper in webPage carries @click and v-bind:href.
expect(() => convert(webPage), returnsNormally);
expect(convert(webPage).page.body, isNotEmpty);
});
test('reads the charset the document declares, not the one assumed', () {
const html = '<html><head><meta charset="utf-8"><title>T</title></head>'
'<body><p>Beschädigte Seite</p></body></html>';
final page = convertHtmlPage(
url: 'https://example.com/', bytes: utf8.encode(html))
.page;
final text = page.body
.whereType<ParagraphBlock>()
.expand((b) => b.inline.whereType<MewsText>())
.map((t) => t.text)
.join();
expect(text, contains('Beschädigte Seite'));
});
test('a nested table becomes a table of its own', () {
// Old web layout markup: a table inside a cell of another.
const source = '<body><table>'
'<tr><th>Outer head</th><td>Outer cell</td></tr>'
'<tr><td>Holds one'
'<table><tr><td>Inner a</td><td>Inner b</td></tr></table>'
'</td><td>Beside it</td></tr>'
'</table></body>';
final tables = convert(source).page.body.whereType<TableBlock>().toList();
expect(tables.length, 2);
String cells(TableBlock t) => t.rows
.map((r) =>
r.cells.map((c) => textOf([ParagraphBlock(c.inline)])).join('|'))
.join(' / ');
// The outer table keeps its own two rows; the inner row is not hoisted
// into it, and the inner cells do not run together in the cell's text.
expect(tables.first.rows.length, 2);
expect(cells(tables.first), contains('Outer head'));
expect(cells(tables.first), contains('Holds one'));
expect(cells(tables.first), contains('Beside it'));
expect(cells(tables.first), isNot(contains('Inner a')));
// The inner table stands on its own, with its content intact.
expect(tables.last.rows.length, 1);
expect(cells(tables.last), contains('Inner a'));
expect(cells(tables.last), contains('Inner b'));
});
test('leaves a form out but keeps what it wrapped', () {
const source = '<body>'
'<form action="/search" method="get">'
'<label for="q">Search</label>'
'<input type="text" id="q" name="q">'
'<select name="in"><option>Titles</option></select>'
'<textarea name="notes" rows="2" cols="4">Draft</textarea>'
'<input type="submit" value="Go">'
'</form>'
'<form action="/wrap"><h2>Wrapped heading</h2>'
'<p>Real content inside a form.</p></form>'
'</body>';
final page = convert(source).page;
final text = textOf(page.body);
// No form survives, so nothing in the view invites an answer the reader
// cannot send.
expect(page.body.whereType<FormBlock>(), isEmpty);
expect(text, isNot(contains('Search')));
expect(text, isNot(contains('Titles')));
expect(text, isNot(contains('Draft')));
expect(text, isNot(contains('Go')));
// The form's wrapper goes, its content stays.
expect(page.body.whereType<HeadingBlock>(), isNotEmpty);
expect(text, contains('Wrapped heading'));
expect(text, contains('Real content inside a form.'));
});
test('markup with nothing in it still yields a page', () {
final page = convert('').page;
expect(page.url, 'https://example.com/journal');
expect(page.body, isEmpty);
});
}