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:
Tizian 「ティツィアーン」 2026-11-10 12:12:10 +01:00
commit 63d50f77d4
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -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');
},
);
}