From f205452b254becf1ee22e13ab1c9f15a243d6c33 Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Fri, 16 Oct 2026 15:50:43 +0200 Subject: [PATCH 1/2] Group the Sources tab by kind and pick a kind when adding a feed (#4) The Sources tab shows News, Learning and Fun sections, each split by language; kinds or languages without Sources get no header. The add-feed dialog has a kind picker that defaults to News. Co-Authored-By: Claude Opus 5.5 --- README.md | 3 +- lib/ui/sources_screen.dart | 74 ++++++++++++------ test/sources_tab_test.dart | 153 +++++++++++++++++++++++++++++++++++++ 3 files changed, 205 insertions(+), 25 deletions(-) create mode 100644 test/sources_tab_test.dart diff --git a/README.md b/README.md index a2998bc..6820bc8 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,8 @@ or write a daily briefing. It's meant to replace the n8n → Discord workflow. valid for a day) and reuses the cookie for reader view and the in-app page. With it off, the app shows the teaser and an "Open website anyway" option. - **Sources**: 23 curated feeds (13 German, 10 English), toggle each one, or add any - RSS/Atom feed (it's validated before being added). + RSS/Atom feed as a News, Learning or Fun source (it's validated before being + added). The tab groups Sources by kind, then by language. - **Dark mode**: System / Light / Dark in Settings (applies instantly). The original-page view follows it too: when the phone is dark, sites use their own dark design or Android darkens them; when only the app is dark, the app inverts diff --git a/lib/ui/sources_screen.dart b/lib/ui/sources_screen.dart index a7927c3..3c3fea8 100644 --- a/lib/ui/sources_screen.dart +++ b/lib/ui/sources_screen.dart @@ -40,29 +40,7 @@ class SourcesScreen extends StatelessWidget { body: ListView( padding: const EdgeInsets.only(bottom: 96), children: [ - for (final lang in SourceLanguage.values) ...[ - Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 4), - child: Text('${lang.flag} ${lang.label}', - style: theme.textTheme.titleSmall - ?.copyWith(color: theme.colorScheme.primary)), - ), - for (final source in state.allSources.where((s) => s.language == lang)) - _SourceTile( - source: source, - enabled: state.enabledSourceIds.contains(source.id), - onToggle: (v) { - state.setSourceEnabled(source, v); - onChanged(); - }, - onDelete: source.isCustom - ? () { - state.removeCustomSource(source); - onChanged(); - } - : null, - ), - ], + for (final kind in SourceKind.values) ..._kindSection(context, kind), ], ), ); @@ -70,6 +48,44 @@ class SourcesScreen extends StatelessWidget { ); } + /// One section per kind, split by language. Kinds and languages without + /// Sources are left out instead of showing an empty header. + List _kindSection(BuildContext context, SourceKind kind) { + final theme = Theme.of(context); + final sources = state.allSources.where((s) => s.kind == kind).toList(); + if (sources.isEmpty) return const []; + return [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 24, 16, 0), + child: Text(kind.label, style: theme.textTheme.titleMedium), + ), + for (final lang in SourceLanguage.values) + if (sources.any((s) => s.language == lang)) ...[ + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), + child: Text('${lang.flag} ${lang.label}', + style: theme.textTheme.titleSmall + ?.copyWith(color: theme.colorScheme.primary)), + ), + for (final source in sources.where((s) => s.language == lang)) + _SourceTile( + source: source, + enabled: state.enabledSourceIds.contains(source.id), + onToggle: (v) { + state.setSourceEnabled(source, v); + onChanged(); + }, + onDelete: source.isCustom + ? () { + state.removeCustomSource(source); + onChanged(); + } + : null, + ), + ], + ]; + } + Future _addFeed(BuildContext context) async { final added = await showDialog( context: context, @@ -134,6 +150,7 @@ class _AddFeedDialogState extends State<_AddFeedDialog> { final _name = TextEditingController(); final _url = TextEditingController(); SourceLanguage _language = SourceLanguage.de; + SourceKind _kind = SourceKind.news; bool _busy = false; String? _error; @@ -156,7 +173,7 @@ class _AddFeedDialogState extends State<_AddFeedDialog> { _error = null; }); final error = await widget.state - .addCustomSource(name: name, feedUrl: url, language: _language); + .addCustomSource(name: name, feedUrl: url, language: _language, kind: _kind); if (!mounted) return; if (error == null) { Navigator.pop(context, true); @@ -199,6 +216,15 @@ class _AddFeedDialogState extends State<_AddFeedDialog> { selected: {_language}, onSelectionChanged: (s) => setState(() => _language = s.first), ), + const SizedBox(height: 12), + SegmentedButton( + segments: [ + for (final k in SourceKind.values) + ButtonSegment(value: k, label: Text(k.label)), + ], + selected: {_kind}, + onSelectionChanged: (s) => setState(() => _kind = s.first), + ), if (_error != null) ...[ const SizedBox(height: 12), Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), diff --git a/test/sources_tab_test.dart b/test/sources_tab_test.dart new file mode 100644 index 0000000..005398d --- /dev/null +++ b/test/sources_tab_test.dart @@ -0,0 +1,153 @@ +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/sources_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'; + +NewsSource _source(String id, SourceKind kind, SourceLanguage language) => NewsSource( + id: id, + name: id, + feedUrl: 'https://example.com/$id.xml', + homepage: 'https://example.com', + language: language, + kind: kind, + enabledByDefault: true, + ); + +void main() { + final heise = _source('heise', SourceKind.news, SourceLanguage.de); + final verge = _source('verge', SourceKind.news, SourceLanguage.en); + final bytebytego = _source('bytebytego', SourceKind.learning, SourceLanguage.en); + final xkcd = _source('xkcd', SourceKind.fun, SourceLanguage.en); + + const german = '🇩🇪 Deutsch'; + const english = '🇬🇧 English'; + + setUp(() => SharedPreferences.setMockInitialValues({})); + + Future launch(WidgetTester tester, List sources) async => + (await tester.runAsync(() => launchApp(sources)))!; + + Future showSources(WidgetTester tester, AppState state) async { + tester.view.physicalSize = const Size(800, 2400); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.pumpWidget( + MaterialApp(home: SourcesScreen(state: state, onChanged: () {}))); + } + + /// Asserts the [finders] each match once on screen, from top to bottom. + void expectInOrder(WidgetTester tester, List finders) { + final ys = [for (final f in finders) tester.getTopLeft(f).dy]; + expect(ys, [...ys]..sort()); + expect(ys.toSet().length, ys.length); + } + + testWidgets('the Sources tab groups News, Learning and Fun, each split by language', + (tester) async { + final state = await launch(tester, [xkcd, bytebytego, verge, heise]); + + await showSources(tester, state); + + expectInOrder(tester, [ + find.text('News'), + find.text(german).at(0), + find.text('heise'), + find.text(english).at(0), + find.text('verge'), + find.text('Learning'), + find.text(english).at(1), + find.text('bytebytego'), + find.text('Fun'), + find.text(english).at(2), + find.text('xkcd'), + ]); + }); + + testWidgets('a kind without Sources gets no section', (tester) async { + final state = await launch(tester, [verge]); + + await showSources(tester, state); + + expect(find.text('News'), findsOneWidget); + expect(find.text('Learning'), findsNothing); + expect(find.text('Fun'), findsNothing); + expect(find.text(german), findsNothing); + }); + + Future addFeed(WidgetTester tester, String name, {String? kind}) async { + await tester.tap(find.text('Add feed')); + await tester.pumpAndSettle(); + final dialog = find.byType(AlertDialog); + await tester.enterText( + find.descendant(of: dialog, matching: find.widgetWithText(TextField, 'Name')), name); + await tester.enterText( + find.descendant(of: dialog, matching: find.widgetWithText(TextField, 'Feed URL')), + 'https://example.com/$name.xml'); + if (kind != null) { + await tester.tap(find.descendant(of: dialog, matching: find.text(kind))); + await tester.pump(); + } + await tester.tap(find.descendant(of: dialog, matching: find.text('Add'))); + await tester.pumpAndSettle(); + } + + testWidgets('a feed added without picking a kind is a News source', (tester) async { + final state = await launch(tester, [verge, bytebytego]); + await showSources(tester, state); + + await addFeed(tester, 'myfeed'); + + expectInOrder(tester, [find.text('News'), find.text('myfeed'), find.text('Learning')]); + }); + + testWidgets('a feed added as Learning shows up in the Learning section', (tester) async { + final state = await launch(tester, [verge, bytebytego]); + await showSources(tester, state); + + await addFeed(tester, 'myblog', kind: 'Learning'); + + expectInOrder(tester, [find.text('Learning'), find.text('myblog')]); + }); + + testWidgets('a feed added as Fun gets a Fun section', (tester) async { + final state = await launch(tester, [verge]); + await showSources(tester, state); + + await addFeed(tester, 'mycomic', kind: 'Fun'); + + expectInOrder(tester, [find.text('News'), find.text('Fun'), find.text('mycomic')]); + }); + + testWidgets('a custom Learning source shows up in the Learn tab, not in Today', + (tester) async { + final feeds = FakeFeeds({ + verge.feedUrl: [('News from this morning', const Duration(hours: 2))], + 'https://example.com/myblog.xml': [('Explainer from my blog', const Duration(days: 2))], + }); + 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)); + + await tester.tap(tab('Sources')); + await tester.pump(); + await addFeed(tester, 'myblog', kind: 'Learning'); + await tester.tap(tab('Learn')); + await tester.pumpAndSettle(); + + expect(find.text('Explainer from my blog'), findsOneWidget); + + await tester.tap(tab('Today')); + await tester.pumpAndSettle(); + expect(find.text('News from this morning'), findsOneWidget); + expect(find.text('Explainer from my blog'), findsNothing); + }); +} From b634118ed87f33bccdff93834450a0dc20ad5a45 Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Fri, 16 Oct 2026 15:50:50 +0200 Subject: [PATCH 2/2] Add the curated Learning sources (#6) Fifteen English Learning sources join ByteByteGo in the catalog: System Design One, The Pragmatic Engineer (blog), Martin Fowler, Julia Evans, Dan Luu, The Cloudflare Blog, Netflix Technology Blog, Engineering at Meta, GitHub Blog: Engineering, Latent Space, Import AI, Interconnects, Ahead of AI and Lilian Weng (on by default), and InfoQ (off). Tests pin the Learning catalog, check that existing installs get the new on-by-default Sources and that the Learn tab shows their posts from the last 14 days. A live test times a full refresh. The README gets a Learn feature bullet and a Learning sources table. Co-Authored-By: Claude Opus 5.5 --- README.md | 33 +++++++ lib/data/default_sources.dart | 149 ++++++++++++++++++++++++++++++++ test/default_sources_test.dart | 34 ++++++++ test/live_feeds_test.dart | 17 ++++ test/source_kinds_test.dart | 20 +++++ test/source_selection_test.dart | 12 ++- 6 files changed, 263 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a2998bc..d36dad8 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,9 @@ or write a daily briefing. It's meant to replace the n8n → Discord workflow. - **Today**: all articles from the enabled sources in the last 12/24/48 h, newest first. Filter by language or source, pull to refresh. +- **Learn**: articles from the enabled Learning sources (engineering blogs, + system design explainers, AI research write-ups) from the last 14 days, newest + first. Learning sources aren't part of the Briefing. - **Reading in the app**: tapping an article opens a clean **reader view** (text, headings, lists, quotes, images) without ads or cookie banners. The globe icon switches to the **original page** in an in-app WebView, for paywalls or sites @@ -180,6 +183,36 @@ 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. +## Learning sources + +Evergreen reading for building skills: research, engineering depth and system +design rather than product news. The Learn tab shows their posts from the last +14 days. All are English; ★ marks the ones enabled by default. + +| | Source | Feed | Focus | +|---|---|---|---| +| 🇬🇧 ★ | ByteByteGo | `blog.bytebytego.com/feed` | System design explained with diagrams | +| 🇬🇧 ★ | System Design One | `newsletter.systemdesign.one/feed` | System design case studies of real systems | +| 🇬🇧 ★ | The Pragmatic Engineer (blog) | `blog.pragmaticengineer.com/rss/` | How big tech and startups build software (free posts) | +| 🇬🇧 ★ | Martin Fowler | `martinfowler.com/feed.atom` | Software architecture, refactoring, delivery | +| 🇬🇧 ★ | Julia Evans | `jvns.ca/atom.xml` | Systems, networking and Unix tools, explained plainly | +| 🇬🇧 ★ | Dan Luu | `danluu.com/atom.xml` | Long, data-driven essays on computers and engineering | +| 🇬🇧 ★ | The Cloudflare Blog | `blog.cloudflare.com/rss/` | Networking, security, incident write-ups | +| 🇬🇧 ★ | Netflix Technology Blog | `netflixtechblog.com/feed` | Distributed systems, data, streaming at scale | +| 🇬🇧 ★ | Engineering at Meta | `engineering.fb.com/feed/` | Infrastructure, AI, production engineering | +| 🇬🇧 ★ | GitHub Blog: Engineering | `github.blog/engineering/feed/` | How GitHub builds and runs GitHub | +| 🇬🇧 ★ | Latent Space | `www.latent.space/feed` | AI engineering: interviews and deep dives | +| 🇬🇧 ★ | Import AI | `importai.substack.com/feed` | Weekly AI research digest by Jack Clark | +| 🇬🇧 ★ | Interconnects | `www.interconnects.ai/feed` | Language model training and research | +| 🇬🇧 ★ | Ahead of AI | `magazine.sebastianraschka.com/feed` | LLM research explained by Sebastian Raschka | +| 🇬🇧 ★ | Lilian Weng | `lilianweng.github.io/index.xml` | Thorough surveys of machine learning topics | +| 🇬🇧 | InfoQ | `feed.infoq.com/` | Architecture and engineering talks, high volume | + +Left out on purpose: The Pragmatic Engineer newsletter (mostly paid), One Useful +Thing (applied AI), vendor blogs (OpenAI, DeepMind, Hugging Face) and Chip Huyen +(inactive since January 2025). Some feeds are long (Dan Luu ~130 posts, Lilian +Weng ~50); the 14-day window keeps the list short. + ## Project layout ``` diff --git a/lib/data/default_sources.dart b/lib/data/default_sources.dart index 447ad2f..fe52741 100644 --- a/lib/data/default_sources.dart +++ b/lib/data/default_sources.dart @@ -212,6 +212,155 @@ const defaultSources = [ description: 'System design explained with diagrams', enabledByDefault: true, ), + NewsSource( + id: 'systemdesignone', + name: 'System Design One', + feedUrl: 'https://newsletter.systemdesign.one/feed', + homepage: 'https://newsletter.systemdesign.one', + language: SourceLanguage.en, + kind: SourceKind.learning, + description: 'System design case studies of real systems', + enabledByDefault: true, + ), + NewsSource( + id: 'pragmaticengineer-blog', + name: 'The Pragmatic Engineer (blog)', + feedUrl: 'https://blog.pragmaticengineer.com/rss/', + homepage: 'https://blog.pragmaticengineer.com', + language: SourceLanguage.en, + kind: SourceKind.learning, + description: 'How big tech and startups build software (free posts)', + enabledByDefault: true, + ), + NewsSource( + id: 'martinfowler', + name: 'Martin Fowler', + feedUrl: 'https://martinfowler.com/feed.atom', + homepage: 'https://martinfowler.com', + language: SourceLanguage.en, + kind: SourceKind.learning, + description: 'Software architecture, refactoring, delivery practices', + enabledByDefault: true, + ), + NewsSource( + id: 'juliaevans', + name: 'Julia Evans', + feedUrl: 'https://jvns.ca/atom.xml', + homepage: 'https://jvns.ca', + language: SourceLanguage.en, + kind: SourceKind.learning, + description: 'Systems, networking and Unix tools, explained plainly', + enabledByDefault: true, + ), + NewsSource( + id: 'danluu', + name: 'Dan Luu', + feedUrl: 'https://danluu.com/atom.xml', + homepage: 'https://danluu.com', + language: SourceLanguage.en, + kind: SourceKind.learning, + description: 'Long, data-driven essays on computers and engineering', + enabledByDefault: true, + ), + NewsSource( + id: 'cloudflare-blog', + name: 'The Cloudflare Blog', + feedUrl: 'https://blog.cloudflare.com/rss/', + homepage: 'https://blog.cloudflare.com', + language: SourceLanguage.en, + kind: SourceKind.learning, + description: 'Networking, security and incident write-ups in depth', + enabledByDefault: true, + ), + NewsSource( + id: 'netflix-techblog', + name: 'Netflix Technology Blog', + feedUrl: 'https://netflixtechblog.com/feed', + homepage: 'https://netflixtechblog.com', + language: SourceLanguage.en, + kind: SourceKind.learning, + description: 'Distributed systems, data and streaming at scale', + enabledByDefault: true, + ), + NewsSource( + id: 'meta-engineering', + name: 'Engineering at Meta', + feedUrl: 'https://engineering.fb.com/feed/', + homepage: 'https://engineering.fb.com', + language: SourceLanguage.en, + kind: SourceKind.learning, + description: 'Infrastructure, AI and production engineering at Meta', + enabledByDefault: true, + ), + NewsSource( + id: 'github-engineering', + name: 'GitHub Blog: Engineering', + feedUrl: 'https://github.blog/engineering/feed/', + homepage: 'https://github.blog/engineering/', + language: SourceLanguage.en, + kind: SourceKind.learning, + description: 'How GitHub builds and runs GitHub', + enabledByDefault: true, + ), + NewsSource( + id: 'latent-space', + name: 'Latent Space', + feedUrl: 'https://www.latent.space/feed', + homepage: 'https://www.latent.space', + language: SourceLanguage.en, + kind: SourceKind.learning, + description: 'AI engineering: interviews and deep dives', + enabledByDefault: true, + ), + NewsSource( + id: 'import-ai', + name: 'Import AI', + feedUrl: 'https://importai.substack.com/feed', + homepage: 'https://importai.substack.com', + language: SourceLanguage.en, + kind: SourceKind.learning, + description: 'Weekly AI research digest by Jack Clark', + enabledByDefault: true, + ), + NewsSource( + id: 'interconnects', + name: 'Interconnects', + feedUrl: 'https://www.interconnects.ai/feed', + homepage: 'https://www.interconnects.ai', + language: SourceLanguage.en, + kind: SourceKind.learning, + description: 'Language model training and research, analysed', + enabledByDefault: true, + ), + NewsSource( + id: 'ahead-of-ai', + name: 'Ahead of AI', + feedUrl: 'https://magazine.sebastianraschka.com/feed', + homepage: 'https://magazine.sebastianraschka.com', + language: SourceLanguage.en, + kind: SourceKind.learning, + description: 'LLM research explained, by Sebastian Raschka', + enabledByDefault: true, + ), + NewsSource( + id: 'lilianweng', + name: 'Lilian Weng', + feedUrl: 'https://lilianweng.github.io/index.xml', + homepage: 'https://lilianweng.github.io', + language: SourceLanguage.en, + kind: SourceKind.learning, + description: 'Thorough surveys of machine learning topics', + enabledByDefault: true, + ), + NewsSource( + id: 'infoq', + name: 'InfoQ', + feedUrl: 'https://feed.infoq.com/', + homepage: 'https://www.infoq.com', + language: SourceLanguage.en, + kind: SourceKind.learning, + description: 'Software architecture and engineering talks, high volume', + ), ]; /// The curated sources before the app remembered which ones it had seen. diff --git a/test/default_sources_test.dart b/test/default_sources_test.dart index 0c2579c..7d55215 100644 --- a/test/default_sources_test.dart +++ b/test/default_sources_test.dart @@ -43,6 +43,40 @@ void main() { expect(ids, isNot(contains('engadget'))); }); + test('the Learning sources are the curated set, all English, with the agreed defaults', () { + final learning = { + for (final s in defaultSources) + if (s.kind == SourceKind.learning) s.name: (s.feedUrl, s.language, s.enabledByDefault), + }; + + const en = SourceLanguage.en; + expect(learning, { + 'ByteByteGo': ('https://blog.bytebytego.com/feed', en, true), + 'System Design One': ('https://newsletter.systemdesign.one/feed', en, true), + 'The Pragmatic Engineer (blog)': ('https://blog.pragmaticengineer.com/rss/', en, true), + 'Martin Fowler': ('https://martinfowler.com/feed.atom', en, true), + 'Julia Evans': ('https://jvns.ca/atom.xml', en, true), + 'Dan Luu': ('https://danluu.com/atom.xml', en, true), + 'The Cloudflare Blog': ('https://blog.cloudflare.com/rss/', en, true), + 'Netflix Technology Blog': ('https://netflixtechblog.com/feed', en, true), + 'Engineering at Meta': ('https://engineering.fb.com/feed/', en, true), + 'GitHub Blog: Engineering': ('https://github.blog/engineering/feed/', en, true), + 'Latent Space': ('https://www.latent.space/feed', en, true), + 'Import AI': ('https://importai.substack.com/feed', en, true), + 'Interconnects': ('https://www.interconnects.ai/feed', en, true), + 'Ahead of AI': ('https://magazine.sebastianraschka.com/feed', en, true), + 'Lilian Weng': ('https://lilianweng.github.io/index.xml', en, true), + 'InfoQ': ('https://feed.infoq.com/', en, false), + }); + }); + + test('every Learning source says what it is for and links its homepage', () { + for (final s in defaultSources.where((s) => s.kind == SourceKind.learning)) { + expect(s.description, isNotEmpty, reason: s.name); + expect(Uri.parse(s.homepage).isScheme('https'), isTrue, reason: s.name); + } + }); + test('every Source has its own id', () { final ids = defaultSources.map((s) => s.id).toList(); diff --git a/test/live_feeds_test.dart b/test/live_feeds_test.dart index d68d734..70ffe28 100644 --- a/test/live_feeds_test.dart +++ b/test/live_feeds_test.dart @@ -2,10 +2,14 @@ import 'dart:io'; import 'package:daily_tech_news/data/default_sources.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/consent_service.dart'; import 'package:daily_tech_news/services/feed_service.dart'; +import 'package:daily_tech_news/services/storage.dart'; +import 'package:daily_tech_news/state/app_state.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; void main() { final live = Platform.environment['LIVE_FEEDS'] == '1'; @@ -29,6 +33,19 @@ void main() { expect(failures, isEmpty); }, skip: live ? false : 'set LIVE_FEEDS=1 to run', timeout: const Timeout(Duration(minutes: 3))); + test('a full refresh with all on-by-default Sources stays fast', () async { + SharedPreferences.setMockInitialValues({}); + final state = AppState(await Storage.open(), feedService: FeedService()); + final clock = Stopwatch()..start(); + await state.init(); + clock.stop(); + // ignore: avoid_print + print('${state.enabledSources.length} Sources, ${state.articles.length} articles ' + '(${state.articlesOf(SourceKind.learning).length} Learning) in ' + '${clock.elapsedMilliseconds} ms; errors: ${state.feedErrors}'); + expect(clock.elapsed, lessThan(const Duration(seconds: 15))); + }, skip: live ? false : 'set LIVE_FEEDS=1 to run', timeout: const Timeout(Duration(minutes: 1))); + test('reader view extracts the article body', () async { final service = FeedService(); final extractor = ArticleExtractor(consent: ConsentService()); diff --git a/test/source_kinds_test.dart b/test/source_kinds_test.dart index f11a2e1..066064d 100644 --- a/test/source_kinds_test.dart +++ b/test/source_kinds_test.dart @@ -135,4 +135,24 @@ void main() { expect(bytebytego.language, SourceLanguage.en); expect(bytebytego.enabledByDefault, isTrue); }); + + test('a fresh install shows posts of the last 14 days from the curated Learning sources', + () async { + final learning = defaultSources.where((s) => s.kind == SourceKind.learning); + final state = await launchApp(defaultSources, feeds: { + for (final s in learning) + s.feedUrl: [('${s.name}: recent', const Duration(days: 13)), + ('${s.name}: old', const Duration(days: 15))], + }); + + expect(_titles(state.articlesOf(SourceKind.learning)).toSet(), { + 'ByteByteGo: recent', 'System Design One: recent', + 'The Pragmatic Engineer (blog): recent', 'Martin Fowler: recent', 'Julia Evans: recent', + 'Dan Luu: recent', 'The Cloudflare Blog: recent', 'Netflix Technology Blog: recent', + 'Engineering at Meta: recent', 'GitHub Blog: Engineering: recent', 'Latent Space: recent', + 'Import AI: recent', 'Interconnects: recent', 'Ahead of AI: recent', + 'Lilian Weng: recent', + // InfoQ is off by default. + }); + }); } diff --git a/test/source_selection_test.dart b/test/source_selection_test.dart index 7be7764..f252d8b 100644 --- a/test/source_selection_test.dart +++ b/test/source_selection_test.dart @@ -23,6 +23,13 @@ NewsSource _source(String id, {bool onByDefault = false}) => NewsSource( enabledByDefault: onByDefault, ); +/// The curated Learning sources that are on by default (all but InfoQ). +const _learningOnByDefault = { + 'bytebytego', 'systemdesignone', 'pragmaticengineer-blog', 'martinfowler', 'juliaevans', + 'danluu', 'cloudflare-blog', 'netflix-techblog', 'meta-engineering', 'github-engineering', + 'latent-space', 'import-ai', 'interconnects', 'ahead-of-ai', 'lilianweng', +}; + /// Starts the app the way a launch does, against whatever is in preferences. Future _launch(List curatedSources) async { final state = @@ -87,7 +94,7 @@ void main() { expect(state.enabledSourceIds, { 'heise', 'verge', 'mit-tr', 'new-source', // Added to the catalog later, on by default. - 'techmeme', 'lobsters', 'the-decoder', 'simonwillison', 'bytebytego', + 'techmeme', 'lobsters', 'the-decoder', 'simonwillison', ..._learningOnByDefault, }); }); @@ -102,8 +109,9 @@ void main() { expect(state.enabledSourceIds, { 'heise', 'golem', 't3n', 'verge', - 'techmeme', 'lobsters', 'the-decoder', 'simonwillison', 'bytebytego', + 'techmeme', 'lobsters', 'the-decoder', 'simonwillison', ..._learningOnByDefault, }); expect(state.enabledSources.map((s) => s.id), isNot(contains('wired'))); + expect(state.enabledSourceIds, isNot(contains('infoq'))); }); }