266 lines
9 KiB
Dart
266 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);
|
||
|
|
});
|
||
|
|
}
|