Merge ticket/3-fun-section into feature/source-kinds (#3)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
1a3442ea02
11 files changed
+418
-20
No files matched your search
@@ -102,8 +102,86 @@ void main() {
|
||||
isFragment: true);
|
||||
expect(content.blocks, hasLength(2));
|
||||
});
|
||||
|
||||
test('an xkcd page gives the comic and its title text, without the page around it', () {
|
||||
final content = ArticleExtractor.parse(xkcdPage, Uri.parse('https://xkcd.com/3312/'));
|
||||
|
||||
expect(content.blocks.map((b) => (b.type, b.text)), [
|
||||
(BlockType.image, 'https://imgs.xkcd.com/comics/momentum_demonstration.png'),
|
||||
(BlockType.caption, xkcdTitleText),
|
||||
]);
|
||||
});
|
||||
|
||||
test("The Daily WTF's Error'd screenshots show between their stories", () {
|
||||
final content = ArticleExtractor.parse(
|
||||
errordPage, Uri.parse('https://thedailywtf.com/articles/scrollpocalypse'));
|
||||
|
||||
expect(content.blocks.map((b) => b.type), [
|
||||
BlockType.paragraph,
|
||||
BlockType.image,
|
||||
BlockType.paragraph,
|
||||
BlockType.image,
|
||||
]);
|
||||
expect(content.blocks[1].text, 'https://cdn.example.com/errord/first.png');
|
||||
});
|
||||
}
|
||||
|
||||
const xkcdTitleText = '"I\'m doing really well on the Hertzsprung-Russell academic diagram, '
|
||||
'which has spectral class on one axis and Rate My Professors rating on the other."';
|
||||
|
||||
/// Trimmed from https://xkcd.com/3312/.
|
||||
const xkcdPage = '''
|
||||
<!DOCTYPE html>
|
||||
<html><head><title>xkcd: Momentum Demonstration</title></head>
|
||||
<body>
|
||||
<div id="topContainer">
|
||||
<div id="topLeft"><ul><li><a href="/archive">Archive</a></li><li><a href="/about">About</a></li></ul></div>
|
||||
<div id="topRight">
|
||||
<div id="masthead"><span><a href="/"><img src="/s/0b7742.png" alt="xkcd.com logo" height="83" width="185"/></a></span>
|
||||
<span id="slogan">A webcomic of romance,<br/> sarcasm, math, and language.</span></div>
|
||||
<div id="news"><div id="xkcdNews"><a href="https://store.xkcd.com/"><img src="https://imgs.xkcd.com/store_news/news_back.png" alt="The xkcd store is back, with shirts, stickers, and posters for sale." title="xkcd store news" /></a></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="middleContainer" class="box">
|
||||
<div id="ctitle">Momentum Demonstration</div>
|
||||
<ul class="comicNav"><li><a href="/1/">|<</a></li><li><a rel="prev" href="/3311/">< Prev</a></li></ul>
|
||||
<div id="comic">
|
||||
<img src="//imgs.xkcd.com/comics/momentum_demonstration.png" title=""I'm doing really well on the Hertzsprung-Russell academic diagram, which has spectral class on one axis and Rate My Professors rating on the other."" alt="Momentum Demonstration" srcset="//imgs.xkcd.com/comics/momentum_demonstration_2x.png 2x" style="image-orientation:none" />
|
||||
</div>
|
||||
<ul class="comicNav"><li><a href="/1/">|<</a></li><li><a rel="prev" href="/3311/">< Prev</a></li></ul>
|
||||
<br />
|
||||
Permanent link to this comic: <a href="https://xkcd.com/3312">https://xkcd.com/3312/</a><br />
|
||||
Image URL (for hotlinking/embedding): <a href= "https://imgs.xkcd.com/comics/momentum_demonstration.png">https://imgs.xkcd.com/comics/momentum_demonstration.png</a>
|
||||
<div id="transcript" style="display: none"></div>
|
||||
</div>
|
||||
<div id="bottom" class="box">
|
||||
<img src="//imgs.xkcd.com/s/a899e84.jpg" width="520" height="100" alt="Selected Comics" usemap="#comicmap"/>
|
||||
<map id="comicmap" name="comicmap"><area shape="rect" coords="0,0,100,100" href="/150/" alt="Grownups"/></map>
|
||||
<div id="comicLinks">Comics I enjoy:<br/><a href="http://threewordphrase.com/">Three Word Phrase</a></div>
|
||||
<div id="footnote" style="width:70%">xkcd.com is best viewed with Netscape Navigator 4.0 or below on a Pentium 3±1 emulated in Javascript on an Apple IIGS</div>
|
||||
<div id="licenseText">
|
||||
<p>
|
||||
This work is licensed under a
|
||||
<a href="https://creativecommons.org/licenses/by-nc/2.5/">Creative Commons Attribution-NonCommercial 2.5 License</a>.
|
||||
</p><p>
|
||||
This means you're free to copy and share these comics (but not to sell them). <a rel="license" href="/license.html">More details</a>.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body></html>''';
|
||||
|
||||
/// Trimmed from https://thedailywtf.com/articles/scrollpocalypse.
|
||||
const errordPage = '''
|
||||
<html><body>
|
||||
<div itemscope itemtype="http://schema.org/Article" class="article-container errord">
|
||||
<div itemprop="articleBody" class="article-body">
|
||||
<p>Dr. Doom...scroller titled this one "Scrollbar-ception". Said he "Screenshot of the TikTok webpage."</p>
|
||||
<blockquote><p><a href="#d6711f9e"><img itemprop="image" border="0" alt="d6711f9e" src="https://cdn.example.com/errord/first.png"/></a></p><p> </p></blockquote>
|
||||
<p>An anonymous reader clarified "I was reorganizing the document by drag-and-dropping table cells."</p>
|
||||
<blockquote><p><a href="#01270f37"><img itemprop="image" border="0" alt="01270f37" src="https://cdn.example.com/errord/second.png"/></a></p><p> </p></blockquote>
|
||||
</div>
|
||||
</div>
|
||||
</body></html>''';
|
||||
|
||||
/// Answers every request as if the server had redirected to [finalUrl].
|
||||
class _RedirectingClient extends http.BaseClient {
|
||||
_RedirectingClient(this.finalUrl, this.body);
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:daily_tech_news/models/article.dart';
|
||||
import 'package:daily_tech_news/models/news_source.dart';
|
||||
import 'package:daily_tech_news/services/article_extractor.dart';
|
||||
import 'package:daily_tech_news/ui/article_screen.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'support/fake_app.dart';
|
||||
|
||||
/// A plain PNG of the given size, standing in for a comic strip or meme.
|
||||
Future<List<int>> _png(int width, int height) async {
|
||||
final recorder = ui.PictureRecorder();
|
||||
Canvas(recorder).drawRect(
|
||||
Rect.fromLTWH(0, 0, width.toDouble(), height.toDouble()), Paint()..color = Colors.black);
|
||||
final image = await recorder.endRecording().toImage(width, height);
|
||||
return (await image.toByteData(format: ui.ImageByteFormat.png))!.buffer.asUint8List();
|
||||
}
|
||||
|
||||
/// Serves a [width] x [height] PNG from this machine, so the reader view can
|
||||
/// load and lay out a real picture. Returns its URL.
|
||||
Future<String> _serveImage(WidgetTester tester, int width, int height) async {
|
||||
HttpOverrides.global = null;
|
||||
final server = (await tester.runAsync(() => HttpServer.bind(InternetAddress.loopbackIPv4, 0)))!;
|
||||
addTearDown(() => tester.runAsync(server.close));
|
||||
final png = (await tester.runAsync(() => _png(width, height)))!;
|
||||
server.listen((request) => request.response
|
||||
// No idle keep-alive timer left behind in the test's fake time.
|
||||
..persistentConnection = false
|
||||
..headers.contentType = ContentType('image', 'png')
|
||||
..add(png)
|
||||
..close());
|
||||
return 'http://127.0.0.1:${server.port}/picture.png';
|
||||
}
|
||||
|
||||
/// Opens [article] in the reader, with the article page answered by [page],
|
||||
/// and waits until the picture at [imageUrl] has loaded. Returns its finder.
|
||||
Future<Finder> _openAndLoad(
|
||||
WidgetTester tester, Article article, String imageUrl, http.Response page) async {
|
||||
final state = (await tester.runAsync(() => launchApp([article.source],
|
||||
extractor: ArticleExtractor(client: MockClient((_) async => page)))))!;
|
||||
await tester.pumpWidget(MaterialApp(home: ArticleScreen(state: state, article: article)));
|
||||
final picture = find.descendant(
|
||||
of: find.byWidgetPredicate((w) =>
|
||||
w is Image && w.image is NetworkImage && (w.image as NetworkImage).url == imageUrl),
|
||||
matching: find.byType(RawImage));
|
||||
for (var i = 0; i < 100; i++) {
|
||||
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 20)));
|
||||
await tester.pump();
|
||||
if (picture.evaluate().isNotEmpty && tester.widget<RawImage>(picture).image != null) break;
|
||||
}
|
||||
return picture;
|
||||
}
|
||||
|
||||
double _aspect(WidgetTester tester, Finder picture) {
|
||||
final size = tester.getSize(picture);
|
||||
return size.height / size.width;
|
||||
}
|
||||
|
||||
const _titleText = 'The title text is the last joke, hidden in the mouse-over.';
|
||||
|
||||
void main() {
|
||||
setUp(() => SharedPreferences.setMockInitialValues({}));
|
||||
|
||||
testWidgets('opening an xkcd comic shows the whole comic and its title text', (tester) async {
|
||||
final comicUrl = await _serveImage(tester, 300, 100);
|
||||
final page = '''
|
||||
<html><body>
|
||||
<div id="ctitle">Strip</div>
|
||||
<div id="comic"><img src="$comicUrl" title="$_titleText" alt="Strip"></div>
|
||||
<div id="licenseText"><p>This work is licensed under a Creative Commons Attribution-NonCommercial 2.5 License.</p></div>
|
||||
</body></html>''';
|
||||
// What the xkcd feed gives: a title, a link and the comic as the image.
|
||||
final article = Article(
|
||||
title: 'Strip',
|
||||
link: 'https://xkcd.com/9999/',
|
||||
source: testSource('xkcd', SourceKind.fun),
|
||||
imageUrl: comicUrl);
|
||||
|
||||
final comic = await _openAndLoad(tester, article, comicUrl,
|
||||
http.Response(page, 200, headers: {'content-type': 'text/html; charset=utf-8'}));
|
||||
|
||||
expect(tester.widget<RawImage>(comic).image, isNotNull, reason: 'the comic loads');
|
||||
expect(_aspect(tester, comic), closeTo(100 / 300, 0.01), reason: 'the comic is not cropped');
|
||||
expect(find.text(_titleText), findsOneWidget);
|
||||
expect(find.textContaining("couldn't be loaded"), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('a meme whose page blocks the reader still shows the picture from the feed',
|
||||
(tester) async {
|
||||
final memeUrl = await _serveImage(tester, 100, 200);
|
||||
final article = Article(
|
||||
title: 'vibeCoding',
|
||||
link: 'https://www.reddit.com/r/ProgrammerHumor/comments/1/vibecoding/',
|
||||
source: testSource('programmerhumor', SourceKind.fun),
|
||||
imageUrl: memeUrl);
|
||||
|
||||
final meme = await _openAndLoad(tester, article, memeUrl, http.Response('Blocked', 403));
|
||||
|
||||
expect(tester.widget<RawImage>(meme).image, isNotNull, reason: 'the meme loads');
|
||||
expect(_aspect(tester, meme), closeTo(200 / 100, 0.01), reason: 'the meme is not cropped');
|
||||
expect(find.textContaining("couldn't be loaded"), findsNothing);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import 'package:daily_tech_news/data/default_sources.dart';
|
||||
import 'package:daily_tech_news/models/article.dart';
|
||||
import 'package:daily_tech_news/models/briefing.dart';
|
||||
import 'package:daily_tech_news/models/news_source.dart';
|
||||
import 'package:daily_tech_news/services/summarizer.dart';
|
||||
import 'package:daily_tech_news/state/app_state.dart';
|
||||
import 'package:daily_tech_news/ui/feed_screen.dart';
|
||||
import 'package:daily_tech_news/ui/learn_screen.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'support/fake_app.dart';
|
||||
|
||||
/// Remembers which articles a Briefing was asked for.
|
||||
class _RecordingSummarizer implements Summarizer {
|
||||
List<Article>? briefingInput;
|
||||
|
||||
@override
|
||||
String get name => 'test';
|
||||
|
||||
@override
|
||||
Future<String> summarizeArticle(Article article, String text, SourceLanguage language) async =>
|
||||
'';
|
||||
|
||||
@override
|
||||
Future<DailyBriefing> createBriefing(List<Article> articles, SourceLanguage language) async {
|
||||
briefingInput = articles;
|
||||
return DailyBriefing(createdAt: DateTime.now(), intro: '', topics: const [], generatedBy: name);
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
final verge = testSource('verge', SourceKind.news);
|
||||
final bytebytego = testSource('bytebytego', SourceKind.learning);
|
||||
final xkcd = testSource('xkcd', SourceKind.fun);
|
||||
final feeds = {
|
||||
verge.feedUrl: [('News from this morning', const Duration(hours: 2))],
|
||||
bytebytego.feedUrl: [('Explainer from 2 days ago', const Duration(days: 2))],
|
||||
xkcd.feedUrl: [('Comic from today', const Duration(hours: 5)),
|
||||
('Comic from 2 days ago', const Duration(days: 2)),
|
||||
('Comic from 4 days ago', const Duration(days: 4))],
|
||||
};
|
||||
|
||||
setUp(() => SharedPreferences.setMockInitialValues({}));
|
||||
|
||||
Future<AppState> launch(WidgetTester tester, {Summarizer? summarizer}) async =>
|
||||
(await tester.runAsync(() => launchApp([verge, bytebytego, xkcd],
|
||||
feeds: feeds, summarizer: summarizer)))!;
|
||||
|
||||
double top(WidgetTester tester, String text) => tester.getTopLeft(find.text(text)).dy;
|
||||
|
||||
test('the curated Fun sources are xkcd and The Daily WTF on, r/ProgrammerHumor off', () {
|
||||
final fun = {
|
||||
for (final s in defaultSources)
|
||||
if (s.kind == SourceKind.fun) s.name: (s.feedUrl, s.language, s.enabledByDefault),
|
||||
};
|
||||
|
||||
expect(fun, {
|
||||
'xkcd': ('https://xkcd.com/atom.xml', SourceLanguage.en, true),
|
||||
'The Daily WTF': ('https://thedailywtf.com/rss', SourceLanguage.en, true),
|
||||
'r/ProgrammerHumor': (
|
||||
'https://www.reddit.com/r/ProgrammerHumor/top/.rss?t=day',
|
||||
SourceLanguage.en,
|
||||
false,
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('the Learn tab has a Fun section below Learning with the last 3 days of Fun',
|
||||
(tester) async {
|
||||
final state = await launch(tester);
|
||||
|
||||
await tester.pumpWidget(MaterialApp(home: LearnScreen(state: state)));
|
||||
|
||||
expect(find.text('Fun'), findsOneWidget);
|
||||
expect(find.text('2 articles · last 3 days'), findsOneWidget);
|
||||
expect(top(tester, 'Learning'), lessThan(top(tester, 'Fun')));
|
||||
expect(top(tester, 'Explainer from 2 days ago'), lessThan(top(tester, 'Fun')));
|
||||
expect(top(tester, 'Fun'), lessThan(top(tester, 'Comic from today')));
|
||||
expect(top(tester, 'Comic from today'), lessThan(top(tester, 'Comic from 2 days ago')));
|
||||
expect(find.text('Comic from 4 days ago'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('Today leaves out Fun articles', (tester) async {
|
||||
final state = await launch(tester);
|
||||
|
||||
await tester.pumpWidget(MaterialApp(home: FeedScreen(state: state)));
|
||||
|
||||
expect(find.text('News from this morning'), findsOneWidget);
|
||||
expect(find.text('Comic from today'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('the Briefing leaves out Fun articles', (tester) async {
|
||||
final summarizer = _RecordingSummarizer();
|
||||
final state = await launch(tester, summarizer: summarizer);
|
||||
|
||||
await tester.runAsync(state.generateBriefing);
|
||||
|
||||
expect(summarizer.briefingInput!.map((a) => a.title), ['News from this morning']);
|
||||
});
|
||||
}
|
||||
@@ -95,6 +95,7 @@ void main() {
|
||||
'heise', 'verge', 'mit-tr', 'new-source',
|
||||
// Added to the catalog later, on by default.
|
||||
'techmeme', 'lobsters', 'the-decoder', 'simonwillison', ..._learningOnByDefault,
|
||||
'xkcd', 'thedailywtf',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -110,6 +111,7 @@ void main() {
|
||||
expect(state.enabledSourceIds, {
|
||||
'heise', 'golem', 't3n', 'verge',
|
||||
'techmeme', 'lobsters', 'the-decoder', 'simonwillison', ..._learningOnByDefault,
|
||||
'xkcd', 'thedailywtf',
|
||||
});
|
||||
expect(state.enabledSources.map((s) => s.id), isNot(contains('wired')));
|
||||
expect(state.enabledSourceIds, isNot(contains('infoq')));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:daily_tech_news/models/news_source.dart';
|
||||
import 'package:daily_tech_news/state/app_state.dart';
|
||||
import 'package:daily_tech_news/ui/home_shell.dart';
|
||||
import 'package:daily_tech_news/ui/learn_screen.dart';
|
||||
import 'package:daily_tech_news/ui/sources_screen.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
@@ -150,4 +151,34 @@ void main() {
|
||||
expect(find.text('News from this morning'), findsOneWidget);
|
||||
expect(find.text('Explainer from my blog'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('a custom Fun source shows up in the Fun section of the Learn tab, not in Today',
|
||||
(tester) async {
|
||||
final feeds = FakeFeeds({
|
||||
verge.feedUrl: [('News from this morning', const Duration(hours: 2))],
|
||||
'https://example.com/mycomic.xml': [('Comic from my feed', const Duration(days: 1))],
|
||||
});
|
||||
final state = (await tester.runAsync(() => launchApp([verge], feedService: feeds)))!;
|
||||
tester.view.physicalSize = const Size(800, 2400);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
await tester.pumpWidget(MaterialApp(home: HomeShell(state: state)));
|
||||
Finder tab(String label) =>
|
||||
find.descendant(of: find.byType(NavigationBar), matching: find.text(label));
|
||||
Finder onLearn(String text) =>
|
||||
find.descendant(of: find.byType(LearnScreen), matching: find.text(text));
|
||||
|
||||
await tester.tap(tab('Sources'));
|
||||
await tester.pump();
|
||||
await addFeed(tester, 'mycomic', kind: 'Fun');
|
||||
await tester.tap(tab('Learn'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expectInOrder(tester, [onLearn('Learning'), onLearn('Fun'), onLearn('Comic from my feed')]);
|
||||
|
||||
await tester.tap(tab('Today'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('News from this morning'), findsOneWidget);
|
||||
expect(find.text('Comic from my feed'), findsNothing);
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:daily_tech_news/models/article.dart';
|
||||
import 'package:daily_tech_news/models/news_source.dart';
|
||||
import 'package:daily_tech_news/services/article_extractor.dart';
|
||||
import 'package:daily_tech_news/services/feed_service.dart';
|
||||
import 'package:daily_tech_news/services/storage.dart';
|
||||
import 'package:daily_tech_news/services/summarizer.dart';
|
||||
@@ -58,11 +59,13 @@ Future<AppState> launchApp(
|
||||
Map<String, List<(String, Duration)>> feeds = const {},
|
||||
FeedService? feedService,
|
||||
Summarizer? summarizer,
|
||||
ArticleExtractor? extractor,
|
||||
}) async {
|
||||
final state = AppState(await Storage.open(),
|
||||
feedService: feedService ?? FakeFeeds(feeds),
|
||||
curatedSources: curatedSources,
|
||||
summarizer: summarizer);
|
||||
summarizer: summarizer,
|
||||
extractor: extractor);
|
||||
await state.init();
|
||||
return state;
|
||||
}
|
||||
Reference in new issue
Block a user