fix(pdf): the divider beside a PDF follows the pointer, and keeps its place

Dragging it moved the sidebar by each event's step from the width last
built, so whatever arrived between two frames was lost: the sidebar
trailed the pointer, which soon left the 10 dp strip and dropped the
drag. Each move also built every editor of the sidebar anew, its blocks
being a new list on every build. Now the width follows from where the
drag started, the blocks are kept while they stay the same, the handle
is 16 dp with a grip under the pointer, and the resize cursor holds
over the pages while dragging.

The sidebar's share of the room is kept as a device setting once a drag
ends, so every PDF opens with it, in any window.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-12 22:58:20 +02:00
commit 72cbec52c2
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
7 changed files with 228 additions and 36 deletions

View file

@ -83,7 +83,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **Manual dependency injection.** One `Dependencies` object (`lib/src/core/dependencies/`) is the composition root, built at startup (`Dependencies.create()`) and exposed through the `DependenciesScope` InheritedWidget. It hands features repositories only. No service locator, no DI framework, no globals (the scaffold's global `client` moved into it). Why: every dependency is a typed, greppable field, and nothing is resolved by magic. - **Manual dependency injection.** One `Dependencies` object (`lib/src/core/dependencies/`) is the composition root, built at startup (`Dependencies.create()`) and exposed through the `DependenciesScope` InheritedWidget. It hands features repositories only. No service locator, no DI framework, no globals (the scaffold's global `client` moved into it). Why: every dependency is a typed, greppable field, and nothing is resolved by magic.
- **One graph per context.** The dependency graph belongs to one instance and one signed-in person; when either changes, a `KeyedSubtree` disposes the whole subtree and rebuilds it. Why: nothing from one person's session (blocs, streams, caches) can leak into another's. - **One graph per context.** The dependency graph belongs to one instance and one signed-in person; when either changes, a `KeyedSubtree` disposes the whole subtree and rebuilds it. Why: nothing from one person's session (blocs, streams, caches) can leak into another's.
- **App-wide vs. per person.** `Dependencies` holds what outlives a person (the client, auth). Everything of the signed-in person (their local database, its sync, their repositories) is `PersonDependencies`, opened by `PersonScope` inside `SessionGate`'s keyed subtree (`Dependencies.openPerson`) and disposed with it; widgets reach it through `PersonScope.of(context)`. - **App-wide vs. per person.** `Dependencies` holds what outlives a person (the client, auth). Everything of the signed-in person (their local database, its sync, their repositories) is `PersonDependencies`, opened by `PersonScope` inside `SessionGate`'s keyed subtree (`Dependencies.openPerson`) and disposed with it; widgets reach it through `PersonScope.of(context)`.
- **The device's own settings** (appearance, language, citation style; `core/device_settings/`) belong to the device, not to a person: kept in `<app support>/device_settings.json`, read in `Dependencies.create()` so the first frame already has them, and provided by `DeviceSettingsScope` above `SessionGate`'s keyed subtree, which both its plain app and `PersonApp` read. What should follow the person to their other devices goes into their synced `PersonSettings` instead. - **The device's own settings** (appearance, language, citation style, the PDF sidebar's share of the room as last dragged; `core/device_settings/`) belong to the device, not to a person: kept in `<app support>/device_settings.json`, read in `Dependencies.create()` so the first frame already has them, and provided by `DeviceSettingsScope` above `SessionGate`'s keyed subtree, which both its plain app and `PersonApp` read. What should follow the person to their other devices goes into their synced `PersonSettings` instead.
- **Every person has an app of their own** (`PersonApp`, `lib/src/app/`), which `SessionGate` builds inside the keyed subtree: a `MaterialApp.router` with its own `GoRouter`, and `PersonScope` around the router's navigator (`MaterialApp.builder`). So every screen and dialog is inside the person's scope and reads their repositories from `PersonScope.of(context)` wherever it is built, and a change of person disposes the router with every screen on it and starts again at home. Until someone is signed in, `SessionGate` shows a plain `MaterialApp`: the spinner, or the failure with a way to try again. - **Every person has an app of their own** (`PersonApp`, `lib/src/app/`), which `SessionGate` builds inside the keyed subtree: a `MaterialApp.router` with its own `GoRouter`, and `PersonScope` around the router's navigator (`MaterialApp.builder`). So every screen and dialog is inside the person's scope and reads their repositories from `PersonScope.of(context)` wherever it is built, and a change of person disposes the router with every screen on it and starts again at home. Until someone is signed in, `SessionGate` shows a plain `MaterialApp`: the spinner, or the failure with a way to try again.
- **Platform plugins sit behind small interfaces in `Dependencies`**, with fakes in tests: picking files (`FilePicker`, `file_selector`), saving files the app made, such as exports (`FileSaver`: `file_selector`'s dialog on the desktop, `file_picker`'s on Android and iOS, where `file_selector` has none), playing sounds (`SoundPlayer`, `audioplayers`), playing YouTube videos (`YoutubeVideos`, `youtube_player_iframe` where a web view exists, else the browser), reading PDFs (`PdfEngine`, pdfrx on PDFium, which `flutter test` can't load: widget tests use `FakePdfEngine`, whose PDF bytes say their pages and chapters), reading Anki packages on another isolate (`AnkiPackageReader`; a widget test's fake clock can't wait for one), zipping an export with its files on another isolate (`ExportArchiver`, for the same reason), and the Pomodoro timer's side of the system (`PomodoroPlatform`, `FakePomodoroPlatform` in tests). - **Platform plugins sit behind small interfaces in `Dependencies`**, with fakes in tests: picking files (`FilePicker`, `file_selector`), saving files the app made, such as exports (`FileSaver`: `file_selector`'s dialog on the desktop, `file_picker`'s on Android and iOS, where `file_selector` has none), playing sounds (`SoundPlayer`, `audioplayers`), playing YouTube videos (`YoutubeVideos`, `youtube_player_iframe` where a web view exists, else the browser), reading PDFs (`PdfEngine`, pdfrx on PDFium, which `flutter test` can't load: widget tests use `FakePdfEngine`, whose PDF bytes say their pages and chapters), reading Anki packages on another isolate (`AnkiPackageReader`; a widget test's fake clock can't wait for one), zipping an export with its files on another isolate (`ExportArchiver`, for the same reason), and the Pomodoro timer's side of the system (`PomodoroPlatform`, `FakePomodoroPlatform` in tests).
- **Material's `Card` widget clashes with the domain's `Card`**: files that need both import `material_ui` with `hide Card`. - **Material's `Card` widget clashes with the domain's `Card`**: files that need both import `material_ui` with `hide Card`.

View file

@ -39,6 +39,9 @@ class DeviceSettingsBloc(
state.withSearchEngine(event.name, customUrl: event.customUrl), state.withSearchEngine(event.name, customUrl: event.customUrl),
), ),
); );
on<DeviceSettingsEventPdfSidebarDragged>(
(event, emit) => _apply(emit, state.withPdfSidebarShare(event.share)),
);
on<DeviceSettingsEventBrowserViewChosen>( on<DeviceSettingsEventBrowserViewChosen>(
(event, emit) => _apply( (event, emit) => _apply(
emit, emit,
@ -130,3 +133,10 @@ final class const DeviceSettingsEventBrowserViewChosen(
@override @override
List<Object?> get props => [studysetId, detailed]; List<Object?> get props => [studysetId, detailed];
} }
/// The person dragged the editor beside a PDF to take [share] of the room.
final class const DeviceSettingsEventPdfSidebarDragged(final double share)
extends DeviceSettingsEvent {
@override
List<Object?> get props => [share];
}

View file

@ -63,6 +63,10 @@ class FileDeviceSettingsStore(final File _file) implements DeviceSettingsStore {
final String url => url, final String url => url,
_ => null, _ => null,
}, },
pdfSidebarShare: switch (json['pdfSidebarShare']) {
final num share when share > 0 && share < 1 => share.toDouble(),
_ => null,
},
); );
} on FormatException catch (error) { } on FormatException catch (error) {
log('The device settings are unreadable', name: 'Settings', error: error); log('The device settings are unreadable', name: 'Settings', error: error);
@ -85,6 +89,7 @@ class FileDeviceSettingsStore(final File _file) implements DeviceSettingsStore {
'assistantTarget': settings.assistantTarget.name, 'assistantTarget': settings.assistantTarget.name,
'searchEngine': ?settings.searchEngine, 'searchEngine': ?settings.searchEngine,
'customSearchUrl': ?settings.customSearchUrl, 'customSearchUrl': ?settings.customSearchUrl,
'pdfSidebarShare': ?settings.pdfSidebarShare,
}), }),
flush: true, flush: true,
); );

View file

@ -55,6 +55,10 @@ final class const DeviceSettings({
/// The address a custom search engine searches, with `%s` for the /// The address a custom search engine searches, with `%s` for the
/// question; null without. /// question; null without.
final String? customSearchUrl, final String? customSearchUrl,
/// How much of the room beside a PDF its editor takes, from 0 to 1, as
/// last dragged; null for the reader's default width.
final double? pdfSidebarShare,
}) with Equatable { }) with Equatable {
/// These settings with [appearance] instead. /// These settings with [appearance] instead.
DeviceSettings withAppearance(Appearance appearance) => DeviceSettings withAppearance(Appearance appearance) =>
@ -92,6 +96,11 @@ final class const DeviceSettings({
DeviceSettings withSearchEngine(String? name, {String? customUrl}) => DeviceSettings withSearchEngine(String? name, {String? customUrl}) =>
_copy(searchEngine: name, customSearchUrl: customUrl); _copy(searchEngine: name, customSearchUrl: customUrl);
/// These settings with the editor beside a PDF taking [share] of the
/// room.
DeviceSettings withPdfSidebarShare(double share) =>
_copy(pdfSidebarShare: share);
DeviceSettings _copy({ DeviceSettings _copy({
Appearance? appearance, Appearance? appearance,
Object? language = _keep, Object? language = _keep,
@ -102,6 +111,7 @@ final class const DeviceSettings({
AssistantTarget? assistantTarget, AssistantTarget? assistantTarget,
Object? searchEngine = _keep, Object? searchEngine = _keep,
Object? customSearchUrl = _keep, Object? customSearchUrl = _keep,
double? pdfSidebarShare,
}) => DeviceSettings( }) => DeviceSettings(
appearance: appearance ?? this.appearance, appearance: appearance ?? this.appearance,
language: identical(language, _keep) ? this.language : language as String?, language: identical(language, _keep) ? this.language : language as String?,
@ -116,6 +126,7 @@ final class const DeviceSettings({
customSearchUrl: identical(customSearchUrl, _keep) customSearchUrl: identical(customSearchUrl, _keep)
? this.customSearchUrl ? this.customSearchUrl
: customSearchUrl as String?, : customSearchUrl as String?,
pdfSidebarShare: pdfSidebarShare ?? this.pdfSidebarShare,
); );
@override @override
@ -129,6 +140,7 @@ final class const DeviceSettings({
assistantTarget, assistantTarget,
searchEngine, searchEngine,
customSearchUrl, customSearchUrl,
pdfSidebarShare,
]; ];
} }

View file

@ -1,11 +1,14 @@
import 'dart:async'; import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/gestures.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart'; import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies_scope.dart'; import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies_scope.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart'; import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
import 'package:halkyon_learn_flutter/src/core/device_settings/bloc/device_settings_bloc.dart';
import 'package:halkyon_learn_flutter/src/core/editor/core_blocks.dart'; import 'package:halkyon_learn_flutter/src/core/editor/core_blocks.dart';
import 'package:halkyon_learn_flutter/src/core/editor/editor_blocks.dart'; import 'package:halkyon_learn_flutter/src/core/editor/editor_blocks.dart';
import 'package:halkyon_learn_flutter/src/core/layout/window_size.dart'; import 'package:halkyon_learn_flutter/src/core/layout/window_size.dart';
@ -149,7 +152,20 @@ class _ReaderState() extends State<_Reader> {
var _started = false; var _started = false;
var _searching = false; var _searching = false;
var _fullscreen = false; var _fullscreen = false;
var _sidebarWidth = _sidebarDefault;
/// The editor's width as dragged here; null until dragged, when it is
/// the share of the room last dragged on this device, else the default.
double? _sidebarWidth;
/// While the divider is dragged: the editor's width and the pointer's
/// position when the drag started.
({double width, double x})? _drag;
/// The editor blocks of the panels, with screenshots of the pages: made
/// anew only when the app's change, so dragging the divider (a rebuild
/// each move) doesn't make every editor below rebuild its services.
EditorBlocks? _appBlocks;
EditorBlocks? _panelBlocks;
var _sheetOpen = false; var _sheetOpen = false;
var _lastPage = 0; var _lastPage = 0;
@ -157,6 +173,9 @@ class _ReaderState() extends State<_Reader> {
static const _sidebarMin = 300.0; static const _sidebarMin = 300.0;
static const _viewerMin = 360.0; static const _viewerMin = 360.0;
/// The rail beside the pages and its line.
static const _railWidth = 57.0;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@ -361,10 +380,15 @@ class _ReaderState() extends State<_Reader> {
], ],
); );
// The panels' editors take screenshots of the pages too. // The panels' editors take screenshots of the pages too.
final appBlocks = blocksOf(context);
if (!identical(appBlocks, _appBlocks)) {
_appBlocks = appBlocks;
_panelBlocks = appBlocks.plus(
screenshotBlocks(_capture, widget.state.source.id),
);
}
final panels = EditorBlocksScope( final panels = EditorBlocksScope(
blocks: blocksOf( blocks: _panelBlocks!,
context,
).plus(screenshotBlocks(_capture, widget.state.source.id)),
child: PdfReaderPanels( child: PdfReaderPanels(
source: widget.state.source, source: widget.state.source,
controller: _controller, controller: _controller,
@ -380,39 +404,62 @@ class _ReaderState() extends State<_Reader> {
body: SafeArea( body: SafeArea(
child: LayoutBuilder( child: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
// The sidebar as wide as dragged, leaving the pages room. // The sidebar as wide as dragged, or the share of the room
final width = _sidebarWidth.clamp( // last dragged on this device, leaving the pages room.
_sidebarMin, final room =
(constraints.maxWidth - _viewerMin - 57).clamp( constraints.maxWidth - _railWidth - _SidebarHandle.width;
_sidebarMin, final share = context.select<DeviceSettingsBloc, double?>(
double.infinity, (bloc) => bloc.state.pdfSidebarShare,
),
); );
return Row( double widthOf(double? dragged) =>
children: [ (dragged ?? (share == null ? _sidebarDefault : share * room))
_Rail( .clamp(
title: widget.state.source.content.title, _sidebarMin,
controller: _controller, math.max(_sidebarMin, room - _viewerMin),
onSearch: _toggleSearch, )
onFullscreen: () => setState(() => _fullscreen = true), .toDouble();
), final width = widthOf(_sidebarWidth);
const VerticalDivider(width: 1), return MouseRegion(
Expanded(child: viewer), // While dragging, the pointer keeps its look over the pages.
MouseRegion( cursor: _drag == null
cursor: SystemMouseCursors.resizeColumn, ? MouseCursor.defer
child: GestureDetector( : SystemMouseCursors.resizeColumn,
behavior: HitTestBehavior.opaque, child: Row(
onHorizontalDragUpdate: (details) => setState( children: [
() => _sidebarWidth = width - details.delta.dx, _Rail(
), title: widget.state.source.content.title,
child: const SizedBox( controller: _controller,
width: 10, onSearch: _toggleSearch,
child: VerticalDivider(width: 10), onFullscreen: () => setState(() => _fullscreen = true),
),
), ),
), const VerticalDivider(width: 1),
SizedBox(width: width, child: panels), Expanded(child: viewer),
], _SidebarHandle(
dragging: _drag != null,
// From where the drag started: every move counts.
onStart: (x) => setState(() {
_drag = (width: width, x: x);
}),
onMove: (x) => setState(() {
_sidebarWidth = _drag!.width - (x - _drag!.x);
}),
onEnd: () {
// A press let go of without dragging ends nothing.
if (_drag == null) {
return;
}
setState(() => _drag = null);
// As last moved, which may not have been built yet.
context.read<DeviceSettingsBloc>().add(
DeviceSettingsEventPdfSidebarDragged(
widthOf(_sidebarWidth) / room,
),
);
},
),
SizedBox(width: width, child: panels),
],
),
); );
}, },
), ),
@ -1111,3 +1158,70 @@ class const _ChapterSuggestion({
); );
} }
} }
/// The divider between the pages and the editor, dragged to share the room
/// between them: 16 dp to take hold of, its line darkening and a grip
/// showing under the pointer and while dragged.
class const _SidebarHandle({
required final bool dragging,
required final ValueChanged<double> onStart,
required final ValueChanged<double> onMove,
required final VoidCallback onEnd,
}) extends StatefulWidget {
/// How wide it is to take hold of.
static const width = 16.0;
@override
State<_SidebarHandle> createState() => _SidebarHandleState();
}
class _SidebarHandleState() extends State<_SidebarHandle> {
var _hovered = false;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final active = _hovered || widget.dragging;
return MouseRegion(
cursor: SystemMouseCursors.resizeColumn,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
// From where it was pressed, so the divider never trails the
// pointer by the distance a drag needs to be told from a tap.
dragStartBehavior: DragStartBehavior.down,
onHorizontalDragStart: (details) =>
widget.onStart(details.globalPosition.dx),
onHorizontalDragUpdate: (details) =>
widget.onMove(details.globalPosition.dx),
onHorizontalDragEnd: (_) => widget.onEnd(),
onHorizontalDragCancel: widget.onEnd,
child: SizedBox(
width: _SidebarHandle.width,
child: Stack(
alignment: Alignment.center,
children: [
VerticalDivider(
width: 1,
color: active ? colors.outline : null,
),
AnimatedOpacity(
opacity: active ? 1 : 0,
duration: const Duration(milliseconds: 120),
child: Container(
width: 6,
height: 40,
decoration: BoxDecoration(
color: colors.outline,
borderRadius: BorderRadius.circular(3),
),
),
),
],
),
),
),
);
}
}

View file

@ -100,6 +100,18 @@ void main() {
); );
}); });
test("keeps the PDF sidebar's share of the room, a share only", () async {
expect((await store.read()).pdfSidebarShare, isNull);
await store.write(const DeviceSettings().withPdfSidebarShare(0.4));
expect((await FileDeviceSettingsStore(file).read()).pdfSidebarShare, 0.4);
for (final share in ['1.5', '0', '"wide"']) {
await file.writeAsString('{"pdfSidebarShare": $share}');
expect((await store.read()).pdfSidebarShare, isNull, reason: share);
}
});
test('leaves no temporary file behind', () async { test('leaves no temporary file behind', () async {
await store.write(const DeviceSettings(appearance: Appearance.dark)); await store.write(const DeviceSettings(appearance: Appearance.dark));

View file

@ -2,10 +2,12 @@ import 'dart:convert';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart'; import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart';
import 'package:halkyon_learn_flutter/src/core/content/content_blocks.dart'; import 'package:halkyon_learn_flutter/src/core/content/content_blocks.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart'; import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/core/device_settings/model/device_settings.dart';
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_editor.dart'; import 'package:halkyon_learn_flutter/src/core/editor/rich_content_editor.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart'; import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
@ -27,6 +29,7 @@ import '../../../support/fake_pdf_source_gateway.dart';
import '../../../support/fake_studyset_gateway.dart'; import '../../../support/fake_studyset_gateway.dart';
import '../../../support/fake_tag_gateway.dart'; import '../../../support/fake_tag_gateway.dart';
import '../../../support/memory_asset_file_cache.dart'; import '../../../support/memory_asset_file_cache.dart';
import '../../../support/memory_device_settings_store.dart';
import '../../../support/pump_app.dart'; import '../../../support/pump_app.dart';
import '../../../support/test_dependencies.dart'; import '../../../support/test_dependencies.dart';
@ -86,6 +89,7 @@ void main() {
WidgetTester tester, WidgetTester tester,
String id, { String id, {
Size size = const Size(1400, 1000), Size size = const Size(1400, 1000),
MemoryDeviceSettingsStore? settings,
}) async { }) async {
tester.view.physicalSize = size; tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1; tester.view.devicePixelRatio = 1;
@ -101,6 +105,8 @@ void main() {
noteGateway: noteGateway, noteGateway: noteGateway,
tagGateway: tagGateway, tagGateway: tagGateway,
pdfEngine: engine, pdfEngine: engine,
deviceSettingsStore: settings,
deviceSettings: await settings?.read() ?? const DeviceSettings(),
), ),
location: RoutePaths.pdf(id), location: RoutePaths.pdf(id),
); );
@ -138,6 +144,39 @@ void main() {
expect(await furthest(), {id: 7}); expect(await furthest(), {id: 7});
}); });
testWidgets('the divider beside the pages follows the pointer, and the '
'share of the room it leaves is kept for every PDF', (tester) async {
final id = await stored();
final settings = MemoryDeviceSettingsStore();
await read(tester, id, settings: settings);
final handle = find.byWidgetPredicate(
(widget) =>
widget is MouseRegion &&
widget.cursor == SystemMouseCursors.resizeColumn,
);
// The window less the rail, 57 wide, and the handle, 16.
const room = 1400.0 - 57 - 16;
expect(tester.getTopLeft(handle).dx, 1400 - 16 - 360);
await tester.drag(
handle,
const Offset(-200, 0),
kind: PointerDeviceKind.mouse,
);
await tester.pumpAndSettle();
expect(tester.getTopLeft(handle).dx, 1400 - 16 - 560);
expect(settings.writes.last.pdfSidebarShare, closeTo(560 / room, 1e-9));
// Opened again in a narrower window, the sidebar takes the same share.
await tester.pumpWidget(const SizedBox());
await read(tester, id, size: const Size(1200, 1000), settings: settings);
const narrower = 1200.0 - 57 - 16;
expect(
tester.getTopLeft(handle).dx,
closeTo(1200 - 16 - 560 / room * narrower, 1e-6),
);
});
testWidgets('opens where reading left off', (tester) async { testWidgets('opens where reading left off', (tester) async {
final id = await stored(readTo: 12); final id = await stored(readTo: 12);
await read(tester, id); await read(tester, id);