Source kinds, Learn tab and catalog update #9
No files matched your search
@@ -180,6 +180,21 @@ page. Hacker News and Lobsters link to articles all over the web, so reader view
|
||||
depends on the linked site. Techmeme links to its own summary page, which only
|
||||
has the teaser; the original page links on to the full story.
|
||||
|
||||
## Fun sources
|
||||
|
||||
Tech humour for the Fun section of the Learn tab, which reaches back 3 days. Fun
|
||||
articles never show up in Today or the Briefing. ★ = enabled by default.
|
||||
|
||||
| | Source | Feed | Focus |
|
||||
|---|---|---|---|
|
||||
| 🇬🇧 ★ | xkcd | `xkcd.com/atom.xml` | Webcomic of romance, sarcasm, math and language |
|
||||
| 🇬🇧 ★ | The Daily WTF | `thedailywtf.com/rss` | IT horror stories, bad code and error messages |
|
||||
| 🇬🇧 | r/ProgrammerHumor | `reddit.com/r/ProgrammerHumor/top/.rss?t=day` | The day's top programming memes on Reddit |
|
||||
|
||||
Reader view shows comics and memes whole instead of as a cropped banner: xkcd
|
||||
with its title text underneath, The Daily WTF's Error'd with its screenshots.
|
||||
Reddit blocks reader view, so a meme shows the picture from the feed.
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
|
||||
@@ -212,6 +212,37 @@ const defaultSources = <NewsSource>[
|
||||
description: 'System design explained with diagrams',
|
||||
enabledByDefault: true,
|
||||
),
|
||||
|
||||
// --- Fun ----------------------------------------------------------------
|
||||
NewsSource(
|
||||
id: 'xkcd',
|
||||
name: 'xkcd',
|
||||
feedUrl: 'https://xkcd.com/atom.xml',
|
||||
homepage: 'https://xkcd.com',
|
||||
language: SourceLanguage.en,
|
||||
kind: SourceKind.fun,
|
||||
description: 'Webcomic of romance, sarcasm, math and language',
|
||||
enabledByDefault: true,
|
||||
),
|
||||
NewsSource(
|
||||
id: 'thedailywtf',
|
||||
name: 'The Daily WTF',
|
||||
feedUrl: 'https://thedailywtf.com/rss',
|
||||
homepage: 'https://thedailywtf.com',
|
||||
language: SourceLanguage.en,
|
||||
kind: SourceKind.fun,
|
||||
description: 'IT horror stories, bad code and error messages',
|
||||
enabledByDefault: true,
|
||||
),
|
||||
NewsSource(
|
||||
id: 'programmerhumor',
|
||||
name: 'r/ProgrammerHumor',
|
||||
feedUrl: 'https://www.reddit.com/r/ProgrammerHumor/top/.rss?t=day',
|
||||
homepage: 'https://www.reddit.com/r/ProgrammerHumor/',
|
||||
language: SourceLanguage.en,
|
||||
kind: SourceKind.fun,
|
||||
description: "The day's top programming memes on Reddit",
|
||||
),
|
||||
];
|
||||
|
||||
/// The curated sources before the app remembered which ones it had seen.
|
||||
|
||||
@@ -49,9 +49,10 @@ class ArticleExtractor {
|
||||
/// Hard cap on what we send to the model per article (~10k tokens).
|
||||
static const maxChars = 40000;
|
||||
|
||||
/// Loads the article page and returns whichever is longer: the extracted
|
||||
/// page or the full content some feeds ship. Never throws; on failure
|
||||
/// (paywall, bot protection, CORS on web) it falls back to feed content.
|
||||
/// Loads the article page and returns whichever has more text (on a tie,
|
||||
/// more blocks): the extracted page or the full content some feeds ship.
|
||||
/// Never throws; on failure (paywall, bot protection, CORS on web) it falls
|
||||
/// back to feed content.
|
||||
///
|
||||
/// With [acceptConsentWalls], a redirect to a consent page the
|
||||
/// [ConsentService] knows is accepted once and the article fetched again.
|
||||
@@ -81,7 +82,12 @@ class ArticleExtractor {
|
||||
if (page.wallUrl != null) return ArticleContent(fromFeed.blocks, consentWall: true);
|
||||
if (page.status != 200) return fromFeed;
|
||||
final fromPage = parse(FeedService.decodeBody(page.bytes), base);
|
||||
return fromPage.plainText.length > fromFeed.plainText.length ? fromPage : fromFeed;
|
||||
final pageText = fromPage.plainText.length, feedText = fromFeed.plainText.length;
|
||||
// Without text either way (a comic), the one with the picture wins.
|
||||
return pageText > feedText ||
|
||||
(pageText == feedText && fromPage.blocks.length > fromFeed.blocks.length)
|
||||
? fromPage
|
||||
: fromFeed;
|
||||
} catch (_) {
|
||||
return fromFeed;
|
||||
}
|
||||
@@ -119,7 +125,8 @@ class ArticleExtractor {
|
||||
/// Class/id fragments of page furniture that sits inside article bodies.
|
||||
static final _junkMarker = RegExp(
|
||||
r'comment|related|newsletter|share|social|promo|advert|paywall|subscribe|'
|
||||
r'recommend|author-box|byline|breadcrumb|tags|toolbar|consent|cookie|card|native-ad',
|
||||
r'recommend|author-box|byline|breadcrumb|tags|toolbar|consent|cookie|card|native-ad|'
|
||||
r'license',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
@@ -155,14 +162,15 @@ class ArticleExtractor {
|
||||
final blocks = <ReaderBlock>[];
|
||||
final seenImages = <String>{};
|
||||
var chars = 0;
|
||||
void add(BlockType type, String text) {
|
||||
if (chars > maxChars) return;
|
||||
bool add(BlockType type, String text) {
|
||||
if (chars > maxChars) return false;
|
||||
if (type == BlockType.image) {
|
||||
if (!seenImages.add(text)) return;
|
||||
if (!seenImages.add(text)) return false;
|
||||
} else {
|
||||
chars += text.length;
|
||||
}
|
||||
blocks.add(ReaderBlock(type, text));
|
||||
return true;
|
||||
}
|
||||
|
||||
void walk(dom.Element el) {
|
||||
@@ -190,6 +198,8 @@ class ArticleExtractor {
|
||||
case 'blockquote':
|
||||
final t = _clean(el.text);
|
||||
if (t.isNotEmpty) add(BlockType.quote, t);
|
||||
// Quoted screenshots, as in The Daily WTF's Error'd.
|
||||
el.querySelectorAll('img').forEach((img) => _addImage(img, base, add));
|
||||
return;
|
||||
case 'pre':
|
||||
final t = el.text.trimRight();
|
||||
@@ -221,7 +231,7 @@ class ArticleExtractor {
|
||||
static String _clean(String s) => s.replaceAll(RegExp(r'\s+'), ' ').trim();
|
||||
|
||||
static void _addImage(
|
||||
dom.Element img, Uri base, void Function(BlockType, String) add) {
|
||||
dom.Element img, Uri base, bool Function(BlockType, String) add) {
|
||||
// Lazy-loading sites keep the real URL in data attributes.
|
||||
final raw = img.attributes['data-src'] ??
|
||||
img.attributes['data-lazy-src'] ??
|
||||
@@ -232,19 +242,25 @@ class ArticleExtractor {
|
||||
}
|
||||
final width = int.tryParse(img.attributes['width'] ?? '');
|
||||
if (width != null && width < 120) return; // icons, avatars, tracking pixels
|
||||
if (img.attributes.containsKey('usemap')) return; // clickable navigation
|
||||
// Images linking to another page are author avatars or related-story
|
||||
// thumbnails; links to the full-size image file are fine.
|
||||
// thumbnails; links to the full-size image file or within the page are fine.
|
||||
for (var p = img.parent; p != null; p = p.parent) {
|
||||
if (p.localName == 'a') {
|
||||
final href = p.attributes['href'] ?? '';
|
||||
if (!RegExp(r'\.(jpe?g|png|webp|gif|avif)(\?|$)', caseSensitive: false)
|
||||
.hasMatch(href)) {
|
||||
if (!href.startsWith('#') &&
|
||||
!RegExp(r'\.(jpe?g|png|webp|gif|avif)(\?|$)', caseSensitive: false).hasMatch(href)) {
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
final url = base.resolve(raw).toString();
|
||||
if (url.startsWith('http')) add(BlockType.image, url);
|
||||
if (!url.startsWith('http') || !add(BlockType.image, url)) return;
|
||||
// Comics such as xkcd put a last joke in the title text.
|
||||
final title = _clean(img.attributes['title'] ?? '');
|
||||
if (title.length >= 20 && title != _clean(img.attributes['alt'] ?? '')) {
|
||||
add(BlockType.caption, title);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||
|
||||
import '../models/article.dart';
|
||||
import '../models/news_source.dart';
|
||||
import '../services/article_extractor.dart';
|
||||
import '../services/consent_service.dart';
|
||||
import '../services/content_blocking.dart';
|
||||
@@ -275,12 +276,22 @@ class _ReaderView extends StatelessWidget {
|
||||
builder: (context, snapshot) {
|
||||
final loaded = snapshot.data;
|
||||
var blocks = loaded?.blocks ?? const <ReaderBlock>[];
|
||||
// Comics and memes are the picture: show it whole in the body
|
||||
// rather than as a cropped banner.
|
||||
final pictureLed = article.source.kind == SourceKind.fun;
|
||||
final banner = pictureLed ? null : article.imageUrl;
|
||||
// The page's own hero image duplicates the feed image shown on top.
|
||||
if (article.imageUrl != null) {
|
||||
if (banner != null) {
|
||||
blocks = blocks.skipWhile((b) => b.type == BlockType.image).toList();
|
||||
}
|
||||
if (pictureLed &&
|
||||
article.imageUrl != null &&
|
||||
!blocks.any((b) => b.type == BlockType.image)) {
|
||||
blocks = [ReaderBlock(BlockType.image, article.imageUrl!), ...blocks];
|
||||
}
|
||||
final isFull = loaded != null &&
|
||||
loaded.plainText.length >= max(400, article.excerpt.length * 2);
|
||||
(loaded.plainText.length >= max(400, article.excerpt.length * 2) ||
|
||||
(pictureLed && blocks.any((b) => b.type == BlockType.image)));
|
||||
// Skip the lead if the body already starts with it.
|
||||
final firstText = blocks.where((b) => b.type == BlockType.paragraph).firstOrNull;
|
||||
final showLead = article.excerpt.isNotEmpty &&
|
||||
@@ -291,9 +302,9 @@ class _ReaderView extends StatelessWidget {
|
||||
return ListView(
|
||||
padding: const EdgeInsets.only(bottom: 48),
|
||||
children: [
|
||||
if (article.imageUrl != null)
|
||||
if (banner != null)
|
||||
Image.network(
|
||||
article.imageUrl!,
|
||||
banner,
|
||||
fit: BoxFit.cover,
|
||||
webHtmlElementStrategy: WebHtmlElementStrategy.prefer,
|
||||
// Take up space only once the image has loaded, so a broken
|
||||
@@ -336,7 +347,7 @@ class _ReaderView extends StatelessWidget {
|
||||
),
|
||||
if (isFull)
|
||||
for (final block in blocks)
|
||||
if (block.text != article.imageUrl) _BlockView(block: block),
|
||||
if (block.text != banner) _BlockView(block: block),
|
||||
if (loaded != null) ...[
|
||||
const SizedBox(height: 24),
|
||||
const Divider(),
|
||||
|
||||
@@ -13,7 +13,7 @@ class LearnScreen extends StatelessWidget {
|
||||
final AppState state;
|
||||
|
||||
/// The sections, top to bottom.
|
||||
static const kinds = [SourceKind.learning];
|
||||
static const kinds = [SourceKind.learning, SourceKind.fun];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
}
|
||||
@@ -88,6 +88,7 @@ void main() {
|
||||
'heise', 'verge', 'mit-tr', 'new-source',
|
||||
// Added to the catalog later, on by default.
|
||||
'techmeme', 'lobsters', 'the-decoder', 'simonwillison', 'bytebytego',
|
||||
'xkcd', 'thedailywtf',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -103,6 +104,7 @@ void main() {
|
||||
expect(state.enabledSourceIds, {
|
||||
'heise', 'golem', 't3n', 'verge',
|
||||
'techmeme', 'lobsters', 'the-decoder', 'simonwillison', 'bytebytego',
|
||||
'xkcd', 'thedailywtf',
|
||||
});
|
||||
expect(state.enabledSources.map((s) => s.id), isNot(contains('wired')));
|
||||
});
|
||||
|
||||
@@ -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