Merge feature/source-kinds into ticket/3-fun-section (#3)
Keeps the Learning catalog from #6 and puts the Fun group after it, in the catalog and the README. Adds the end-to-end check #4 left for Fun: a custom Fun feed added on the Sources tab shows up in the Learn tab's Fun section, not in Today. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
1048e4e384
8 files changed
+499
-27
No files matched your search
@@ -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();
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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.
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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<AppState> _launch(List<NewsSource> 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,
|
||||
'xkcd', 'thedailywtf',
|
||||
});
|
||||
});
|
||||
@@ -103,9 +110,10 @@ void main() {
|
||||
|
||||
expect(state.enabledSourceIds, {
|
||||
'heise', 'golem', 't3n', 'verge',
|
||||
'techmeme', 'lobsters', 'the-decoder', 'simonwillison', 'bytebytego',
|
||||
'techmeme', 'lobsters', 'the-decoder', 'simonwillison', ..._learningOnByDefault,
|
||||
'xkcd', 'thedailywtf',
|
||||
});
|
||||
expect(state.enabledSources.map((s) => s.id), isNot(contains('wired')));
|
||||
expect(state.enabledSourceIds, isNot(contains('infoq')));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
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';
|
||||
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<AppState> launch(WidgetTester tester, List<NewsSource> sources) async =>
|
||||
(await tester.runAsync(() => launchApp(sources)))!;
|
||||
|
||||
Future<void> 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<Finder> 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<void> 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);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user