Skip to content

Commit 4479ff6

Browse files
committed
[green] finish recovery consent fallback and prepare app 0.3.0 build 9
Signed-off-by: Viwat Vchirawongkwin <viwat.v@chula.ac.th>
1 parent 29455b6 commit 4479ff6

12 files changed

Lines changed: 212 additions & 47 deletions
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
// SPDX-License-Identifier: MIT
2+
// Part of PyBLE (https://pyble.dev) — see /LICENSE.
3+
4+
/// Two launches of an isolated qualification application exercise actual
5+
/// platform storage. Run seed, terminate the process, then run verify with the
6+
/// same application ID/data container. This uses a recording board seam and
7+
/// does not qualify physical BLE or a store-distributed binary.
8+
library;
9+
10+
import 'dart:io';
11+
import 'package:flutter_riverpod/flutter_riverpod.dart';
12+
import 'package:flutter_test/flutter_test.dart';
13+
import 'package:integration_test/integration_test.dart';
14+
import 'package:path_provider/path_provider.dart';
15+
import 'package:pyble/app/providers.dart';
16+
import 'package:pyble/data/draft_repository.dart';
17+
import 'package:pyble/data/drift_draft_repository.dart';
18+
import 'package:pyble/editor/board_target_guard.dart';
19+
import 'package:pyble/editor/draft_recovery.dart';
20+
import 'package:pyble/editor/editor_document.dart';
21+
import 'package:pyble/editor/save_controller.dart';
22+
import 'package:pyble/pble/pble.dart';
23+
import '../test/support/recording_connection.dart';
24+
25+
const _source = '# recovery qualification — ทดสอบ\nprint(“exact text”)\n';
26+
const _phase = String.fromEnvironment(
27+
'PYBLE_RECOVERY_TEST_PHASE',
28+
defaultValue: 'seed',
29+
);
30+
31+
void main() {
32+
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
33+
testWidgets('platform durable recovery ($_phase)', (tester) async {
34+
final directory = await getApplicationSupportDirectory();
35+
final file = File('${directory.path}/qualification-current-draft.sqlite');
36+
final repository = await DriftDraftRepository.open(file);
37+
try {
38+
final existing = await repository.load();
39+
if (_phase == 'seed') {
40+
await repository.write(
41+
DraftSnapshot(
42+
name: 'qualification.py',
43+
content: _source,
44+
originPath: '/previous-board/qualification.py',
45+
generation: existing.generation + 1,
46+
checkpointTime: DateTime.now().toUtc(),
47+
),
48+
expectedGeneration: existing.generation,
49+
);
50+
expect((await repository.load()).snapshot!.content, _source);
51+
} else {
52+
expect(_phase, 'verify');
53+
expect(
54+
existing.snapshot,
55+
isNotNull,
56+
reason: 'The seed must survive a separate app process launch.',
57+
);
58+
final connection = RecordingConnection(initial: ConnState.ready);
59+
final container = ProviderContainer(
60+
overrides: [
61+
draftRepositoryProvider.overrideWithValue(repository),
62+
connectionProvider.overrideWithValue(connection),
63+
boardTargetConfirmationProvider.overrideWithValue(
64+
(_) async => false,
65+
),
66+
],
67+
);
68+
try {
69+
final recovery = container.read(draftRecoveryProvider.notifier);
70+
await recovery.start();
71+
expect(
72+
container.read(draftRecoveryProvider).status,
73+
DraftRecoveryStatus.decision,
74+
);
75+
expect(recovery.restore(), isTrue);
76+
expect(container.read(editorDocumentProvider).content, _source);
77+
expect(container.read(editorDocumentProvider).boardPath, isNull);
78+
expect(container.read(editorDocumentProvider).dirty, isTrue);
79+
expect(connection.putFileCalls, isEmpty);
80+
expect(connection.runFileCalls, isEmpty);
81+
await expectLater(
82+
container.read(saveControllerProvider).save(),
83+
throwsA(isA<BoardActionCancelled>()),
84+
);
85+
expect(connection.putFileCalls, isEmpty);
86+
} finally {
87+
container.dispose();
88+
}
89+
}
90+
} finally {
91+
await repository.close();
92+
}
93+
});
94+
}

‎app/lib/app/app_info.dart‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,6 @@ library;
1717
const String kAppName = 'PyBLE';
1818

1919
/// The client version sent in HELLO (ASCII semver). Kept in step with the
20-
/// `version:` field in `app/pubspec.yaml` (currently `0.2.0+5`); the build-
21-
/// metadata suffix (`+5`) is dropped so the wire value is a clean semver.
22-
const String kAppVersion = '0.2.0';
20+
/// `version:` field in `app/pubspec.yaml` (currently `0.3.0+9`); the build-
21+
/// metadata suffix (`+9`) is dropped so the wire value is a clean semver.
22+
const String kAppVersion = '0.3.0';

‎app/lib/app/pages/editor_page.dart‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
// Part of PyBLE (https://pyble.dev) — see /LICENSE.
33

44
/// A-20 — the Editor surface host. Delegates to the live [EditorView] in
5-
/// `lib/editor` (owner: app-editor-console-engineer), which edits the volatile
6-
/// in-memory current document (ADR-0010). This page is a thin app-layer host: it
5+
/// `lib/editor` (owner: app-editor-console-engineer), which edits the shared
6+
/// current document with bounded local recovery (ADR-0048). This page is a thin app-layer host: it
77
/// bears no display copy (all strings live in the feature view, sourced from
88
/// `AppLocalizations`) and binds to no transport type (CON-8, D2).
99
library;

‎app/lib/app/shell.dart‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
2020
import 'package:pyble/console/console.dart';
2121
import 'package:pyble/editor/editor.dart';
2222
import 'package:pyble/editor/board_target_guard.dart';
23-
import 'package:pyble/editor/draft_recovery.dart';
2423
import 'package:pyble/editor/recovery_gate.dart';
2524
import 'package:pyble/localization/localization.dart';
2625
import 'package:pyble/pble/pble.dart';

‎app/lib/blocks/blocks_document.dart‎

Lines changed: 14 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
1717

1818
import 'package:pyble/editor/editor.dart';
1919
import 'package:pyble/editor/board_target_guard.dart';
20-
import 'package:pyble/editor/draft_recovery.dart';
2120

2221
import 'blocks_companion.dart';
2322
import 'blocks_examples.dart';
@@ -1075,21 +1074,20 @@ class BlocksDocumentController extends Notifier<BlocksDocument> {
10751074
!await confirmEditorReplacement(ref, expectedEpoch: editorEpoch)) {
10761075
throw const BoardActionCancelled();
10771076
}
1078-
final adopted = await ref
1079-
.read(draftRecoveryProvider.notifier)
1080-
.replaceDocument(
1081-
EditorDocument(
1082-
name: generatedName,
1083-
content: program.source,
1084-
dirty: true,
1085-
),
1086-
expectedEpoch: editorEpoch,
1087-
isStillValid: () =>
1088-
capturedHostId == _activeHostId &&
1089-
state.program?.revision == program.revision &&
1090-
state.retainedWorkspaceRevision == program.revision &&
1091-
state.workspaceError == null,
1092-
);
1077+
final adopted = await replaceEditorDocument(
1078+
ref,
1079+
EditorDocument(
1080+
name: generatedName,
1081+
content: program.source,
1082+
dirty: true,
1083+
),
1084+
expectedEpoch: editorEpoch,
1085+
isStillValid: () =>
1086+
capturedHostId == _activeHostId &&
1087+
state.program?.revision == program.revision &&
1088+
state.retainedWorkspaceRevision == program.revision &&
1089+
state.workspaceError == null,
1090+
);
10931091
if (!adopted) throw const BoardActionCancelled();
10941092
} finally {
10951093
_endAction();

‎app/lib/data/data.dart‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,9 @@
66
/// On-device content only: no accounts, no telemetry, no cloud, no
77
/// managed-fleet/classroom tables (SEC, DAT-8). Binds to the seam, not the wire.
88
///
9-
/// S1 skeleton placeholder barrel. Real content lands at story A-24.
9+
/// The first implemented subset is one bounded current-text recovery slot
10+
/// (ADR-0048 / PYB-006.2). Full projects, history and saved boards remain A-24.
1011
library;
12+
13+
export 'draft_repository.dart';
14+
export 'draft_repository_factory.dart';

‎app/lib/editor/board_target_guard.dart‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ final boardTargetConfirmationProvider =
2828
final editorReplacementConfirmationProvider =
2929
Provider<Future<bool> Function()?>((ref) => null);
3030

31+
final editorVolatileReplacementConfirmationProvider =
32+
Provider<Future<bool> Function()?>((ref) => null);
33+
3134
class BoardActionCancelled implements Exception {
3235
const BoardActionCancelled();
3336
}
@@ -79,3 +82,39 @@ Future<bool> confirmEditorReplacement(
7982
if (confirm == null || !await confirm()) return false;
8083
return ref.read(editorDocumentEpochProvider) == expectedEpoch;
8184
}
85+
86+
/// Preserve the live document on failure; volatile replacement needs its own
87+
/// explicit decision bound to the same document and board session.
88+
Future<bool> replaceEditorDocument(
89+
Ref ref,
90+
EditorDocument replacement, {
91+
required int expectedEpoch,
92+
bool Function()? isStillValid,
93+
}) async {
94+
bool valid() =>
95+
ref.read(editorDocumentEpochProvider) == expectedEpoch &&
96+
(isStillValid?.call() ?? true);
97+
if (!valid()) return false;
98+
final controller = ref.read(draftRecoveryProvider.notifier);
99+
final replaced = await controller.replaceDocument(
100+
replacement,
101+
expectedEpoch: expectedEpoch,
102+
allowVolatile:
103+
ref.read(draftRecoveryProvider).status == DraftRecoveryStatus.volatile,
104+
isStillValid: isStillValid,
105+
);
106+
if (replaced ||
107+
!valid() ||
108+
ref.read(draftRecoveryProvider).status != DraftRecoveryStatus.failed) {
109+
return replaced;
110+
}
111+
final confirm = ref.read(editorVolatileReplacementConfirmationProvider);
112+
if (confirm == null || !await confirm() || !valid()) return false;
113+
controller.continueWithoutRecovery();
114+
return controller.replaceDocument(
115+
replacement,
116+
expectedEpoch: expectedEpoch,
117+
allowVolatile: true,
118+
isStillValid: isStillValid,
119+
);
120+
}

‎app/lib/editor/editor.dart‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
/// PyBLE `lib/editor/` — the working-loop editor + run-control (A-20 / A-16,
55
/// ADR-0010, TDD §11.6a/b, specs.md FR-EDIT §4.6 / FR-RUN §4.8).
66
///
7-
/// Exposes the volatile in-memory current document ([editorDocumentProvider]),
7+
/// Exposes the current document ([editorDocumentProvider]),
88
/// the derived run-action availability ([runAvailabilityProvider]) and imperative
99
/// run controller ([runControllerProvider]), and the live editable surface
1010
/// ([EditorView]). Every board action crosses the abstract `Connection` seam
@@ -13,10 +13,11 @@
1313
/// ADR-0012 puts the rich and plain editing implementations behind the exported
1414
/// EditorSurface seam. The rich default supplies Python highlighting and line
1515
/// numbers; session display settings resize code and gutter together. Deferred
16-
/// (recorded, not dropped): Drift persistence (A-24), multi-file tabs,
16+
/// (recorded, not dropped): full project persistence (A-24), multi-file tabs,
1717
/// app-owned find/replace, and the remaining external-keyboard shortcuts.
1818
library;
1919

20+
export 'draft_recovery.dart';
2021
export 'editor_display_settings.dart';
2122
export 'editor_document.dart';
2223
export 'editor_surface.dart';

‎app/lib/editor/program_actions.dart‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,13 @@ class ProgramBundlePathTooLong implements Exception {
3232
String toString() => 'ProgramBundlePathTooLong($path)';
3333
}
3434

35+
/// The text upload may have completed on its original board, but Run was
36+
/// refused after the facade changed attachment. Keep that partial result.
37+
class ProgramSourceSessionChanged implements Exception {
38+
const ProgramSourceSessionChanged({required this.sourcePath});
39+
final String sourcePath;
40+
}
41+
3542
/// The source upload completed, but its sidecar commit record did not.
3643
///
3744
/// PBLE/1 has no multi-file transaction. Callers must therefore report that
@@ -140,7 +147,7 @@ class ProgramActions {
140147
!identical(connectionSessionStampOf(connection), stamp)) {
141148
// The upload may have completed on the previous attachment. Do not send
142149
// the second verb to a replacement board.
143-
throw const NotConnectedException();
150+
throw ProgramSourceSessionChanged(sourcePath: path);
144151
}
145152
await connection.runFile(path);
146153
}

‎app/lib/files/file_explorer_controller.dart‎

Lines changed: 22 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
2626
import 'package:pyble/app/providers.dart';
2727
import 'package:pyble/editor/editor.dart';
2828
import 'package:pyble/editor/board_target_guard.dart';
29-
import 'package:pyble/editor/draft_recovery.dart';
3029
import 'package:pyble/pble/pble.dart';
3130

3231
/// The neutral, localizable classification of a file-operation failure
@@ -513,25 +512,26 @@ class FileExplorerController extends Notifier<FileExplorerState> {
513512
}
514513
state = state.copyWith(clearProgress: true);
515514
final String content = utf8.decode(bytes, allowMalformed: true);
516-
if (!await confirmEditorReplacement(ref, expectedEpoch: documentEpoch))
515+
if (!await confirmEditorReplacement(ref, expectedEpoch: documentEpoch)) {
517516
return;
518-
final adopted = await ref
519-
.read(draftRecoveryProvider.notifier)
520-
.replaceDocument(
521-
EditorDocument(
522-
name: fileName,
523-
content: content,
524-
dirty: false,
525-
boardPath: path,
526-
),
527-
expectedEpoch: documentEpoch,
528-
isStillValid: () =>
529-
!_disposed &&
530-
epoch == _openEpoch &&
531-
_isCapturedSessionCurrent(connection: connection, stamp: stamp),
532-
);
533-
if (adopted)
517+
}
518+
final adopted = await replaceEditorDocument(
519+
ref,
520+
EditorDocument(
521+
name: fileName,
522+
content: content,
523+
dirty: false,
524+
boardPath: path,
525+
),
526+
expectedEpoch: documentEpoch,
527+
isStillValid: () =>
528+
!_disposed &&
529+
epoch == _openEpoch &&
530+
_isCapturedSessionCurrent(connection: connection, stamp: stamp),
531+
);
532+
if (adopted) {
534533
ref.read(selectedSurfaceProvider.notifier).state = AppSurface.editor;
534+
}
535535
} on PbleException catch (e) {
536536
if (epoch == _openEpoch) _fail(e, path);
537537
}
@@ -588,9 +588,12 @@ class FileExplorerController extends Notifier<FileExplorerState> {
588588
final Connection connection = _conn;
589589
final Object stamp = connectionSessionStampOf(connection);
590590
if (!await confirmBoardTarget(ref, path: path, run: false) ||
591+
_disposed ||
592+
_join(state.cwd, doc.name) != path ||
591593
ref.read(editorDocumentEpochProvider) != epoch ||
592-
!_isCapturedSessionCurrent(connection: connection, stamp: stamp))
594+
!_isCapturedSessionCurrent(connection: connection, stamp: stamp)) {
593595
return;
596+
}
594597
try {
595598
await connection.putFile(
596599
path,

0 commit comments

Comments
 (0)