fix(explore): releases in parts, and say what kept a copy from being made
A language's release, Japanese's thousands of words, came as one answer that a phone couldn't fetch within the client's 20 seconds. The instance now also gives a release out in parts of at most 500 files (`releasePart`), the first naming the version the rest ask for; the app fetches it part by part, its progress the first half of adding, asks again for a part whose connection dropped, and takes an older instance's release whole. Older apps keep the one-piece call. Explore said "Couldn't reach your instance" whatever went wrong. It now says whether the instance couldn't be reached, answered with an error, or the copy couldn't be written on the device. A copy's tags are written with one read for each studyset rather than two for each tag name: 85 reads of every card's tags for a release of 40 cards are now at most 4. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
6737a42ff7
commit
838f753bf2
4 changed files with 1097 additions and 60 deletions
|
|
@ -8,6 +8,7 @@ import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
|||
import 'package:halkyon_learn_flutter/src/feature/auth/model/auth_status.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/explore/data/explore_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/explore/model/explore_studyset.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/profile/model/public_profile.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
|
||||
|
|
@ -17,6 +18,7 @@ import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_scree
|
|||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
import '../../support/fake_auth_gateway.dart';
|
||||
import '../../support/fake_card_gateway.dart';
|
||||
import '../../support/fake_explore_gateway.dart';
|
||||
import '../../support/fake_profile_gateway.dart';
|
||||
import '../../support/fake_studyset_gateway.dart';
|
||||
|
|
@ -94,6 +96,7 @@ void main() {
|
|||
FakeExploreGateway explore, {
|
||||
bool identified = true,
|
||||
FakeProfileGateway? profiles,
|
||||
FakeCardGateway? cards,
|
||||
}) async {
|
||||
tester.view.physicalSize = const Size(900, 1400);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
|
|
@ -110,6 +113,7 @@ void main() {
|
|||
studysetGateway: studysets,
|
||||
exploreGateway: explore,
|
||||
profileGateway: profiles,
|
||||
cardGateway: cards,
|
||||
),
|
||||
location: RoutePaths.explore,
|
||||
);
|
||||
|
|
@ -395,6 +399,55 @@ void main() {
|
|||
expect(await StudysetRepository(studysets).all(), isEmpty);
|
||||
});
|
||||
|
||||
group('says what kept a studyset from being added', () {
|
||||
Future<void> add(
|
||||
WidgetTester tester, {
|
||||
Exception? failRelease,
|
||||
FakeCardGateway? cards,
|
||||
}) async {
|
||||
await showExplore(
|
||||
tester,
|
||||
FakeExploreGateway(
|
||||
published: [published('Japanese')],
|
||||
releases: [release('japanese', 'Japanese')],
|
||||
)..failRelease = failRelease,
|
||||
cards: cards,
|
||||
);
|
||||
await tester.tap(find.text('Add'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('no connection, or no answer in time', (tester) async {
|
||||
await add(tester, failRelease: const InstanceUnreachable());
|
||||
expect(
|
||||
find.text("Couldn't reach your instance. Try again."),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets("the instance's own error", (tester) async {
|
||||
await add(tester, failRelease: const InstanceFailed());
|
||||
expect(
|
||||
find.text(
|
||||
"Your instance couldn't give out this studyset. Try again later.",
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('this device, writing it', (tester) async {
|
||||
await add(
|
||||
tester,
|
||||
cards: FakeCardGateway()..failWrites = Exception('full'),
|
||||
);
|
||||
expect(
|
||||
find.text("Couldn't add it to your library on this device."),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.textContaining('reach'), findsNothing);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('without the instance, it says so and tries again', (
|
||||
tester,
|
||||
) async {
|
||||
|
|
|
|||
|
|
@ -72,14 +72,16 @@ void main() {
|
|||
late TagRepository tags;
|
||||
late AssetRepository assets;
|
||||
late ReleaseCopier copier;
|
||||
late FakeTagGateway tagGateway;
|
||||
|
||||
setUp(() {
|
||||
studysets = StudysetRepository(FakeStudysetGateway());
|
||||
cards = CardRepository(FakeCardGateway());
|
||||
notes = NoteRepository(FakeNoteGateway());
|
||||
paths = LearningPathRepository(FakeLearningPathGateway());
|
||||
tagGateway = FakeTagGateway();
|
||||
tags = TagRepository(
|
||||
FakeTagGateway(),
|
||||
tagGateway,
|
||||
cardChanges: cards.changes,
|
||||
noteChanges: notes.changes,
|
||||
pathChanges: paths.changes,
|
||||
|
|
@ -322,6 +324,46 @@ void main() {
|
|||
expect(tagging.of(copied.last.id).map((tag) => tag.id), contains(noun));
|
||||
});
|
||||
|
||||
test("a release's tags are written with one read of the tags for each "
|
||||
'studyset, not one for each tag: a language has dozens, and each read '
|
||||
"takes in every card's tags", () async {
|
||||
final release = PublishedRelease(
|
||||
publicId: 'publication-1',
|
||||
version: 1,
|
||||
items: {
|
||||
'studyset.json': item(
|
||||
'root',
|
||||
encodeStudysetPayload(StudysetContent.titled('Words')),
|
||||
),
|
||||
for (var index = 0; index < 40; index++)
|
||||
'cards/card-$index.json': item(
|
||||
'card-$index',
|
||||
encodeCardPayload(card('$index', 'number $index')),
|
||||
position: 'a${index.toString().padLeft(2, '0')}',
|
||||
tags: ['Tag $index', 'Numbers'],
|
||||
),
|
||||
},
|
||||
);
|
||||
final before = tagGateway.cardTagReads;
|
||||
|
||||
final id = await copier.copy(
|
||||
release,
|
||||
location: 'halkyon.eu/@alice/words',
|
||||
fileOf: fileOf,
|
||||
);
|
||||
|
||||
expect(tagGateway.cardTagReads - before, lessThanOrEqualTo(4));
|
||||
final tagging = await tags.read();
|
||||
final copied = await cards.cardsOf(id);
|
||||
expect(
|
||||
{
|
||||
for (final card in copied)
|
||||
for (final tag in tagging.of(card.id)) tag.name,
|
||||
},
|
||||
hasLength(41),
|
||||
);
|
||||
});
|
||||
|
||||
test("the files its content names become the person's own", () async {
|
||||
final id = await copier.copy(
|
||||
japanese(),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,131 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_client/halkyon_learn_client.dart' as protocol;
|
||||
import 'package:halkyon_learn_flutter/src/feature/explore/data/explore_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/explore/data/serverpod_explore_gateway.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
|
||||
const publicId = '01a0fc5b-0168-775a-aca8-9a36cdb471a4';
|
||||
|
||||
/// Release 2 of [publicId]: [count] JSON files, `cards/0000.json` on.
|
||||
List<protocol.ReleaseItem> items(int count) => [
|
||||
for (var index = 0; index < count; index++)
|
||||
protocol.ReleaseItem(
|
||||
path: 'cards/${index.toString().padLeft(4, '0')}.json',
|
||||
json: jsonEncode({'id': 'card-$index'}),
|
||||
),
|
||||
];
|
||||
|
||||
void main() {
|
||||
/// What the instance was asked, by method and arguments, in order.
|
||||
late List<(String, Map<String, Object?>)> asked;
|
||||
|
||||
ServerpodExploreGateway gateway(
|
||||
Future<http.Response> Function(String method, Map<String, Object?> args)
|
||||
answer,
|
||||
) {
|
||||
asked = [];
|
||||
final client = protocol.Client(
|
||||
'https://learn.example.org/',
|
||||
httpClientOverride: MockClient((request) {
|
||||
final method = request.url.pathSegments.last;
|
||||
final args = jsonDecode(request.body) as Map<String, Object?>;
|
||||
asked.add((method, args));
|
||||
return answer(method, args);
|
||||
}),
|
||||
);
|
||||
addTearDown(client.close);
|
||||
return ServerpodExploreGateway(client);
|
||||
}
|
||||
|
||||
http.Response ok(Object answer) =>
|
||||
http.Response(protocol.SerializationManager.encode(answer), 200);
|
||||
|
||||
/// The parts of a release of [total] files, [size] to a part.
|
||||
http.Response partOf(Map<String, Object?> args, int total, int size) {
|
||||
final offset = args['offset']! as int;
|
||||
return ok(
|
||||
protocol.ReleasePart(
|
||||
publicId: protocol.UuidValue.withValidation(publicId),
|
||||
version: 2,
|
||||
total: total,
|
||||
items: items(total).skip(offset).take(size).toList(),
|
||||
files: ['a' * 64],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
test('a release comes in parts, each from where the last ended and of '
|
||||
'the version the first named, saying how far it has come', () async {
|
||||
final fetched = <(int, int)>[];
|
||||
final release = await gateway(
|
||||
(method, args) async => partOf(args, 1200, 500),
|
||||
).release(publicId, onFetched: (done, total) => fetched.add((done, total)));
|
||||
|
||||
expect(release.version, 2);
|
||||
expect(release.items, hasLength(1200));
|
||||
expect(release.items['cards/1199.json'], {'id': 'card-1199'});
|
||||
expect(release.files, ['a' * 64]);
|
||||
expect([for (final (_, args) in asked) args['offset']], [0, 500, 1000]);
|
||||
expect([for (final (_, args) in asked) args['version']], [null, 2, 2]);
|
||||
expect(fetched, [(500, 1200), (1000, 1200), (1200, 1200)]);
|
||||
});
|
||||
|
||||
test('a part whose connection drops is asked for again, alone', () async {
|
||||
var drops = 1;
|
||||
final release = await gateway((method, args) async {
|
||||
if (args['offset'] == 500 && drops-- > 0) {
|
||||
throw const SocketException('Connection reset by peer');
|
||||
}
|
||||
return partOf(args, 900, 500);
|
||||
}).release(publicId);
|
||||
|
||||
expect(release.items, hasLength(900));
|
||||
expect([for (final (_, args) in asked) args['offset']], [0, 500, 500]);
|
||||
});
|
||||
|
||||
test('an instance from before parts gives the release whole', () async {
|
||||
final release = await gateway(
|
||||
(method, args) async => switch (method) {
|
||||
'releasePart' => http.Response('Method not found in endpoint', 400),
|
||||
_ => ok(
|
||||
protocol.ReleaseContent(
|
||||
publicId: protocol.UuidValue.withValidation(publicId),
|
||||
version: 1,
|
||||
items: items(3),
|
||||
files: const [],
|
||||
),
|
||||
),
|
||||
},
|
||||
).release(publicId);
|
||||
|
||||
expect(release.version, 1);
|
||||
expect(release.items, hasLength(3));
|
||||
expect(
|
||||
[for (final (method, _) in asked) method],
|
||||
[
|
||||
'releasePart',
|
||||
'release',
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('says whether the instance could not be reached or answered with an '
|
||||
'error', () async {
|
||||
await expectLater(
|
||||
gateway(
|
||||
(method, args) async => throw const SocketException('No route'),
|
||||
).release(publicId),
|
||||
throwsA(isA<InstanceUnreachable>()),
|
||||
);
|
||||
await expectLater(
|
||||
gateway(
|
||||
(method, args) async => http.Response('Internal server error', 500),
|
||||
).release(publicId),
|
||||
throwsA(isA<InstanceFailed>()),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -21,6 +21,9 @@ class FakeExploreGateway({
|
|||
/// When set, every request fails with it.
|
||||
Exception? fail;
|
||||
|
||||
/// When set, giving out a release fails with it, as from the instance.
|
||||
Exception? failRelease;
|
||||
|
||||
@override
|
||||
Future<List<ExploreStudyset>> list(
|
||||
ExploreFilter filter, {
|
||||
|
|
@ -63,17 +66,24 @@ class FakeExploreGateway({
|
|||
}
|
||||
|
||||
@override
|
||||
Future<PublishedRelease> release(String publicId, {int? version}) async {
|
||||
if (fail case final error?) {
|
||||
Future<PublishedRelease> release(
|
||||
String publicId, {
|
||||
int? version,
|
||||
void Function(int fetched, int total)? onFetched,
|
||||
}) async {
|
||||
if (failRelease ?? fail case final error?) {
|
||||
throw error;
|
||||
}
|
||||
final of = [
|
||||
for (final release in releases)
|
||||
if (release.publicId == publicId) release,
|
||||
]..sort((a, b) => b.version.compareTo(a.version));
|
||||
return version == null
|
||||
final release = version == null
|
||||
? of.first
|
||||
: of.firstWhere((release) => release.version == version);
|
||||
// In one part.
|
||||
onFetched?.call(release.items.length, release.items.length);
|
||||
return release;
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
|
|||
Loading…
Reference in a new issue