mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-07-04 11:43:10 +02:00
Notes list & filtering: - Text-search filter plus independent date-range filters for both created and last-updated (AND-combined), a priority filter, and a new status filter. Default view hides Done/Abandoned and renders as "unfiltered" (no badge for the default state); fixed badge clipping. - NoteSort options wired into the list UI; watchCount() for the "N saved". Status & priority: - New Status enum (toDo/inProgress/Done/Abandoned) as a settable + filterable attribute on every note, with capture-screen dropdown. - Removed "None" priority: every note is Low/Medium/High, default Medium. Schema migration v2->v3 rewrites legacy priority 0 -> Medium. Export / import: - NotesMarkdown round-trippable single-file format with HTML-comment markers. - Settings "Export notes" (mobile share sheet / desktop writes ~/todo/BACKLOG.md) and "Import notes" (file picker + safe newer-wins merge by id). Structured template: - Every new note pre-fills the richer what/where/must/nice/out/done/depends/ estimate/refs scaffold. Tests: - New fast (~5s), deterministic suite via FakeNoteRepository (no DB timers) and injected http/file-selector/url-launcher fakes. 86 tests, 96.2% line coverage (note.dart & sync_service.dart at 100%, settings 98.7%). Mobile-only share branch excluded via coverage:ignore (unreachable on the Linux test host). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
153 lines
4.7 KiB
Dart
153 lines
4.7 KiB
Dart
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:http/testing.dart';
|
|
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
|
import 'package:todo/data/note.dart';
|
|
import 'package:todo/data/note_repository.dart';
|
|
import 'package:todo/sync/github_client.dart';
|
|
import 'package:todo/sync/sync_service.dart';
|
|
|
|
void main() {
|
|
setUpAll(sqfliteFfiInit);
|
|
|
|
test('sync pulls and merges another device, then pushes its own', () async {
|
|
final dir = await Directory.systemTemp.createTemp('todo_sync');
|
|
addTearDown(() => dir.delete(recursive: true));
|
|
|
|
// Build a second device's changeset and serialise it the way it would
|
|
// be stored in the repo (hlc as a String, base64 in the API response).
|
|
final other = await NoteRepository.open('${dir.path}/other.db');
|
|
await other.upsert(
|
|
Note(
|
|
id: 'x',
|
|
text: 'from other device',
|
|
priority: Priority.medium,
|
|
status: Status.todo,
|
|
createdAt: DateTime(2026, 6, 15),
|
|
updatedAt: DateTime(2026, 6, 15),
|
|
),
|
|
);
|
|
final otherJson = const JsonEncoder.withIndent(
|
|
' ',
|
|
).convert(await other.getChangeset());
|
|
await other.close();
|
|
final fileResponse = jsonEncode({
|
|
'content': base64.encode(utf8.encode(otherJson)),
|
|
});
|
|
|
|
const otherFile = 'otherNode.json';
|
|
final listResponse = jsonEncode([
|
|
{
|
|
'type': 'file',
|
|
'name': otherFile,
|
|
'path': 'changesets/$otherFile',
|
|
'sha': 'sha-other',
|
|
},
|
|
]);
|
|
|
|
var putCount = 0;
|
|
final mock = MockClient((req) async {
|
|
if (req.method == 'PUT') {
|
|
putCount++;
|
|
return http.Response('{}', 200);
|
|
}
|
|
if (req.url.path.endsWith('/contents/changesets')) {
|
|
return http.Response(listResponse, 200); // directory listing
|
|
}
|
|
return http.Response(fileResponse, 200); // the other device's file
|
|
});
|
|
|
|
final local = await NoteRepository.open('${dir.path}/local.db');
|
|
addTearDown(local.close);
|
|
final github = GitHubClient(
|
|
owner: 'o',
|
|
repo: 'r',
|
|
token: 't',
|
|
httpClient: mock,
|
|
);
|
|
|
|
final result = await const SyncService().sync(local, github);
|
|
|
|
expect(result.mergedDevices, 1);
|
|
expect(result.pushed, isTrue);
|
|
expect(result.toString(), contains('mergedDevices: 1'));
|
|
expect(putCount, 1); // pushed our own changeset
|
|
final texts = (await local.listNotes()).map((n) => n.text);
|
|
expect(texts, contains('from other device'));
|
|
});
|
|
|
|
test('sync with no remote files still pushes own changeset', () async {
|
|
final dir = await Directory.systemTemp.createTemp('todo_sync_empty');
|
|
addTearDown(() => dir.delete(recursive: true));
|
|
|
|
var putCount = 0;
|
|
final mock = MockClient((req) async {
|
|
if (req.method == 'PUT') {
|
|
putCount++;
|
|
return http.Response('{}', 200);
|
|
}
|
|
return http.Response('', 404); // empty/missing changesets dir
|
|
});
|
|
|
|
final local = await NoteRepository.open('${dir.path}/local.db');
|
|
addTearDown(local.close);
|
|
final github = GitHubClient(
|
|
owner: 'o',
|
|
repo: 'r',
|
|
token: 't',
|
|
httpClient: mock,
|
|
);
|
|
|
|
final result = await const SyncService().sync(local, github);
|
|
expect(result.mergedDevices, 0);
|
|
expect(result.pushed, isTrue);
|
|
expect(putCount, 1);
|
|
});
|
|
|
|
test('sync updates its own already-present changeset file', () async {
|
|
final dir = await Directory.systemTemp.createTemp('todo_sync_own');
|
|
addTearDown(() => dir.delete(recursive: true));
|
|
|
|
final local = await NoteRepository.open('${dir.path}/local.db');
|
|
addTearDown(local.close);
|
|
|
|
// The remote listing already contains *this* device's changeset file.
|
|
// The sync must recognise it (by node id), skip merging itself, remember
|
|
// the sha, and PUT an update rather than treating it as a peer device.
|
|
final ownFile = '${local.nodeId}.json';
|
|
final listResponse = jsonEncode([
|
|
{
|
|
'type': 'file',
|
|
'name': ownFile,
|
|
'path': 'changesets/$ownFile',
|
|
'sha': 'own-sha-123',
|
|
},
|
|
]);
|
|
|
|
String? putSha;
|
|
final mock = MockClient((req) async {
|
|
if (req.method == 'PUT') {
|
|
final body = jsonDecode(req.body) as Map<String, dynamic>;
|
|
putSha = body['sha'] as String?;
|
|
return http.Response('{}', 200);
|
|
}
|
|
return http.Response(listResponse, 200);
|
|
});
|
|
|
|
final github = GitHubClient(
|
|
owner: 'o',
|
|
repo: 'r',
|
|
token: 't',
|
|
httpClient: mock,
|
|
);
|
|
|
|
final result = await const SyncService().sync(local, github);
|
|
expect(result.mergedDevices, 0); // own file is not a peer to merge
|
|
expect(result.pushed, isTrue);
|
|
expect(putSha, 'own-sha-123'); // updated in place using the remembered sha
|
|
});
|
|
}
|