feat(todo): the calendars linked to projects fetched by the device and kept for offline (ADR 0033, D39)
`CalendarCubit` follows the projects' calendar links: a calendar linked is shown at once from the copy kept and fetched by the device over HTTPS (`HttpCalendarFeeds`, `webcal://` as HTTPS), as the app opens, every hour while it is open and when a list is pulled down; a fetch that fails, or answers with something that is no calendar, keeps the last copy; a link taken off, or its project gone, takes its events and its copy along. The copies are files in the person's own folder (`calendars/<project>.ics`). Nothing shows the events yet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
892a58f4fd
commit
63d50f77d4
1 changed files with 475 additions and 7 deletions
|
|
@ -0,0 +1,136 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/calendar/bloc/calendar_cubit.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/calendar/data/calendar_feeds.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/model/project.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/model/stored_project.dart';
|
||||
|
||||
import '../../support/fake_calendar_feeds.dart';
|
||||
|
||||
/// A calendar file with one event, [title], on 10 Nov 2026 at nine.
|
||||
String feed(String title) =>
|
||||
'BEGIN:VCALENDAR\r\nVERSION:2.0\r\n'
|
||||
'BEGIN:VEVENT\r\nUID:$title@test\r\nDTSTART:20261110T090000\r\n'
|
||||
'DTEND:20261110T100000\r\nSUMMARY:$title\r\nEND:VEVENT\r\n'
|
||||
'END:VCALENDAR\r\n';
|
||||
|
||||
void main() {
|
||||
final from = DateTime(2026, 11, 10);
|
||||
final until = DateTime(2026, 11, 11);
|
||||
|
||||
StoredProject school({String? url = 'https://example.org/school.ics'}) =>
|
||||
StoredProject('school', ProjectContent(name: 'School', calendarUrl: url));
|
||||
|
||||
group("a project's calendar on the device (ADR 0033, D39)", () {
|
||||
late FakeCalendarFeeds feeds;
|
||||
late MemoryCalendarStore store;
|
||||
late CalendarCubit cubit;
|
||||
|
||||
setUp(() {
|
||||
feeds = FakeCalendarFeeds();
|
||||
store = MemoryCalendarStore();
|
||||
cubit = CalendarCubit(feeds, store);
|
||||
});
|
||||
|
||||
tearDown(() => cubit.close());
|
||||
|
||||
test(
|
||||
'a project linked is fetched, its events then by their project',
|
||||
() async {
|
||||
feeds.served[Uri.parse('https://example.org/school.ics')] = feed(
|
||||
'Maths',
|
||||
);
|
||||
|
||||
await cubit.follow([
|
||||
school(),
|
||||
const StoredProject('home', ProjectContent(name: 'Home')),
|
||||
]);
|
||||
|
||||
final events = cubit.state.eventsIn(from, until);
|
||||
expect(events.map((entry) => (entry.projectId, entry.event.title)), [
|
||||
('school', 'Maths'),
|
||||
]);
|
||||
expect(await store.read('school'), feed('Maths'));
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'fetched once as long as its address stays; again on refresh',
|
||||
() async {
|
||||
feeds.served[Uri.parse('https://example.org/school.ics')] = feed(
|
||||
'Maths',
|
||||
);
|
||||
await cubit.follow([school()]);
|
||||
await cubit.follow([school()]);
|
||||
expect(feeds.fetched, hasLength(1));
|
||||
|
||||
feeds.served[Uri.parse('https://example.org/school.ics')] = feed('Art');
|
||||
await cubit.refresh();
|
||||
expect(feeds.fetched, hasLength(2));
|
||||
expect(
|
||||
cubit.state.eventsIn(from, until).single.event.title,
|
||||
'Art',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'offline, the last copy kept is shown; a failed fetch keeps it',
|
||||
() async {
|
||||
await store.write('school', feed('Kept'));
|
||||
|
||||
await cubit.follow([school()]);
|
||||
|
||||
expect(cubit.state.eventsIn(from, until).single.event.title, 'Kept');
|
||||
expect(cubit.state.failed, {'school'});
|
||||
},
|
||||
);
|
||||
|
||||
test('a page that is no calendar is not kept', () async {
|
||||
await store.write('school', feed('Kept'));
|
||||
feeds.served[Uri.parse('https://example.org/school.ics')] =
|
||||
'<html>Sign in</html>';
|
||||
|
||||
await cubit.follow([school()]);
|
||||
|
||||
expect(cubit.state.eventsIn(from, until).single.event.title, 'Kept');
|
||||
expect(await store.read('school'), feed('Kept'));
|
||||
expect(cubit.state.failed, {'school'});
|
||||
});
|
||||
|
||||
test('unlinked, or the project gone, its events and its copy go', () async {
|
||||
feeds.served[Uri.parse('https://example.org/school.ics')] = feed('Maths');
|
||||
await cubit.follow([school()]);
|
||||
|
||||
await cubit.follow([school(url: null)]);
|
||||
|
||||
expect(cubit.state.eventsIn(from, until), isEmpty);
|
||||
expect(await store.read('school'), isNull);
|
||||
});
|
||||
|
||||
test('a webcal address is fetched over HTTPS', () async {
|
||||
feeds.served[Uri.parse('https://example.org/school.ics')] = feed('Maths');
|
||||
|
||||
await cubit.follow([school(url: 'webcal://example.org/school.ics')]);
|
||||
|
||||
expect(feeds.fetched, [Uri.parse('https://example.org/school.ics')]);
|
||||
});
|
||||
});
|
||||
|
||||
test(
|
||||
'the files kept in a folder of their own, read back and deleted',
|
||||
() async {
|
||||
final folder = await Directory.systemTemp.createTemp('calendars');
|
||||
addTearDown(() => folder.delete(recursive: true));
|
||||
final store = FileCalendarStore(folder);
|
||||
|
||||
expect(await store.read('school'), isNull);
|
||||
await store.write('school', feed('Maths'));
|
||||
expect(await store.read('school'), feed('Maths'));
|
||||
await store.delete('school');
|
||||
expect(await store.read('school'), isNull);
|
||||
await store.delete('school');
|
||||
},
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue