feat(explore): the device tells the instance whether to count the person, and what their copies come from (ADR 0019, D3)
Count me stays in the person's settings, which follow them to every device and are now encrypted; after each sync the device mirrors it to the instance (`learningCount.setCounted`) and, only while it is on, reports which publications their copies come from, topics with them (`reportCopies`). Taken back, the instance forgets the copies. Only people with an identity report, as only they are counted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
64fdfa091d
commit
2f169ae899
4 changed files with 221 additions and 0 deletions
|
|
@ -0,0 +1,40 @@
|
||||||
|
import 'package:halkyon_learn_client/halkyon_learn_client.dart' as protocol;
|
||||||
|
|
||||||
|
/// Whether the person is counted as learning what their copies come from,
|
||||||
|
/// as the instance keeps it, and the copies their device reports (ADR 0019,
|
||||||
|
/// D3). Tests replace it with a fake.
|
||||||
|
abstract interface class LearningCountGateway() {
|
||||||
|
/// Whether the instance counts the person.
|
||||||
|
Future<bool> counted();
|
||||||
|
|
||||||
|
/// Counts the person from now on, or no longer.
|
||||||
|
Future<void> setCounted({required bool counted});
|
||||||
|
|
||||||
|
/// Replaces the copies reported before: each publication's studysets of
|
||||||
|
/// the person's that are part of a copy of it.
|
||||||
|
Future<void> reportCopies(Map<String, Set<String>> copies);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [LearningCountGateway] on the instance's `learningCount` endpoint.
|
||||||
|
class ServerpodLearningCountGateway(final protocol.Client _client)
|
||||||
|
implements LearningCountGateway {
|
||||||
|
@override
|
||||||
|
Future<bool> counted() => _client.learningCount.counted();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> setCounted({required bool counted}) =>
|
||||||
|
_client.learningCount.setCounted(counted);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> reportCopies(Map<String, Set<String>> copies) async {
|
||||||
|
await _client.learningCount.reportCopies([
|
||||||
|
for (final MapEntry(key: publication, value: studysets) in copies.entries)
|
||||||
|
protocol.CopyReport(
|
||||||
|
publicationId: publication,
|
||||||
|
studysetIds: [
|
||||||
|
for (final id in studysets) protocol.UuidValue.withValidation(id),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:developer';
|
||||||
|
|
||||||
|
import 'package:halkyon_core/halkyon_core.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/explore/data/learning_count_gateway.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_repository.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
|
||||||
|
|
||||||
|
/// Tells the instance whether the person chose to be counted as learning
|
||||||
|
/// what their copies come from ("Learn this here", ADR 0005, rule 8), and
|
||||||
|
/// which publications those copies are of (ADR 0019, D3): their settings
|
||||||
|
/// and private copies are encrypted, so the instance can't read either.
|
||||||
|
/// The choice stays in the person's settings, which follow them to every
|
||||||
|
/// device; this mirrors it after each sync, and reports the copies only
|
||||||
|
/// while it is on.
|
||||||
|
class LearningCounting(
|
||||||
|
final LearningCountGateway _gateway,
|
||||||
|
final SettingsRepository _settings,
|
||||||
|
final StudysetRepository _studysets,
|
||||||
|
) {
|
||||||
|
late final _runner = CoalescingRunner(
|
||||||
|
_report,
|
||||||
|
onError: (error, st) =>
|
||||||
|
log('Reporting copies failed', name: 'Explore', error: error),
|
||||||
|
);
|
||||||
|
StreamSubscription<void>? _subscription;
|
||||||
|
|
||||||
|
/// Reports now, and after every sync [synced] tells of.
|
||||||
|
void start(Stream<void> synced) {
|
||||||
|
_subscription = synced.listen((_) => unawaited(_runner.run()));
|
||||||
|
unawaited(_runner.run());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reports once more, as after a sync; tests wait for it.
|
||||||
|
Future<void> report() => _runner.run();
|
||||||
|
|
||||||
|
Future<void> _report() async {
|
||||||
|
final counted = (await _settings.read()).countLearning;
|
||||||
|
if (!counted) {
|
||||||
|
if (await _gateway.counted()) {
|
||||||
|
await _gateway.setCounted(counted: false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!await _gateway.counted()) {
|
||||||
|
await _gateway.setCounted(counted: true);
|
||||||
|
}
|
||||||
|
final copies = <String, Set<String>>{};
|
||||||
|
for (final studyset in await _studysets.all()) {
|
||||||
|
if (studyset.content.published?.publication case final publication?) {
|
||||||
|
(copies[publication] ??= {}).add(studyset.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await _gateway.reportCopies(copies);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stops following the syncs.
|
||||||
|
Future<void> dispose() async {
|
||||||
|
await _subscription?.cancel();
|
||||||
|
await _runner.idle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/content/published_ref.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/explore/data/learning_counting.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_repository.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
||||||
|
|
||||||
|
import '../../../support/fake_learning_count_gateway.dart';
|
||||||
|
import '../../../support/fake_settings_gateway.dart';
|
||||||
|
import '../../../support/fake_studyset_gateway.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late FakeLearningCountGateway instance;
|
||||||
|
late FakeStudysetGateway studysetRows;
|
||||||
|
late SettingsRepository settings;
|
||||||
|
late LearningCounting counting;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
instance = FakeLearningCountGateway();
|
||||||
|
studysetRows = FakeStudysetGateway();
|
||||||
|
settings = SettingsRepository(FakeSettingsGateway());
|
||||||
|
final studysets = StudysetRepository(studysetRows);
|
||||||
|
counting = LearningCounting(instance, settings, studysets);
|
||||||
|
addTearDown(() async {
|
||||||
|
await counting.dispose();
|
||||||
|
await studysets.dispose();
|
||||||
|
await settings.dispose();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/// A studyset copied from [publication] (or the person's own without),
|
||||||
|
/// inside [parent].
|
||||||
|
void studyset(String id, {String? publication, String? parent}) =>
|
||||||
|
studysetRows.receiveRemoteChange(
|
||||||
|
id,
|
||||||
|
encodeStudysetPayload(
|
||||||
|
StudysetContent(
|
||||||
|
title: id,
|
||||||
|
description: RichContent.empty(),
|
||||||
|
published: publication == null
|
||||||
|
? null
|
||||||
|
: PublishedRef(publication: publication, item: id, version: 1),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
parentId: parent,
|
||||||
|
);
|
||||||
|
|
||||||
|
test('while the person chose to be counted, the instance counts them and '
|
||||||
|
'learns which publications their copies come from', () async {
|
||||||
|
studyset('japanese', publication: 'pub-ja');
|
||||||
|
studyset('kanji', publication: 'pub-ja', parent: 'japanese');
|
||||||
|
studyset('greek', publication: 'pub-el');
|
||||||
|
studyset('mine');
|
||||||
|
await settings.update(
|
||||||
|
(settings) => settings.copyWith(countLearning: true),
|
||||||
|
);
|
||||||
|
|
||||||
|
await counting.report();
|
||||||
|
|
||||||
|
expect(instance.isCounted, isTrue);
|
||||||
|
expect(instance.reported, {
|
||||||
|
'pub-ja': {'japanese', 'kanji'},
|
||||||
|
'pub-el': {'greek'},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('without the choice, nothing about copies reaches the instance, and '
|
||||||
|
'a choice taken back forgets them there', () async {
|
||||||
|
studyset('japanese', publication: 'pub-ja');
|
||||||
|
|
||||||
|
await counting.report();
|
||||||
|
expect(instance.isCounted, isFalse);
|
||||||
|
expect(instance.reported, isNull);
|
||||||
|
|
||||||
|
await settings.update(
|
||||||
|
(settings) => settings.copyWith(countLearning: true),
|
||||||
|
);
|
||||||
|
await counting.report();
|
||||||
|
expect(instance.reported, isNotNull);
|
||||||
|
|
||||||
|
await settings.update(
|
||||||
|
(settings) => settings.copyWith(countLearning: false),
|
||||||
|
);
|
||||||
|
await counting.report();
|
||||||
|
expect(instance.isCounted, isFalse);
|
||||||
|
expect(instance.reported, isNull);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/explore/data/learning_count_gateway.dart';
|
||||||
|
|
||||||
|
/// [LearningCountGateway] in memory: what the instance would keep.
|
||||||
|
class FakeLearningCountGateway() implements LearningCountGateway {
|
||||||
|
/// Whether the instance counts the person.
|
||||||
|
bool isCounted = false;
|
||||||
|
|
||||||
|
/// The copies last reported, kept only while counted, as the instance
|
||||||
|
/// keeps them.
|
||||||
|
Map<String, Set<String>>? reported;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> counted() async => isCounted;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> setCounted({required bool counted}) async {
|
||||||
|
isCounted = counted;
|
||||||
|
if (!counted) {
|
||||||
|
reported = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> reportCopies(Map<String, Set<String>> copies) async {
|
||||||
|
if (isCounted) {
|
||||||
|
reported = copies;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue