@@ -7,6 +7,7 @@ import 'package:flutter/material.dart';
77import 'package:flutter/services.dart' ;
88import 'package:flutter_riverpod/flutter_riverpod.dart' ;
99import 'package:flutter_test/flutter_test.dart' ;
10+ import 'package:webview_flutter/webview_flutter.dart' ;
1011
1112import 'package:pyble/app/providers.dart' ;
1213import 'package:pyble/blocks/blocks.dart' ;
@@ -281,6 +282,12 @@ Future<void> _pumpUntil(
281282 );
282283}
283284
285+ Future <void > _tapVisible (WidgetTester tester, Finder finder) async {
286+ await tester.ensureVisible (finder);
287+ await tester.pumpAndSettle ();
288+ await tester.tap (finder);
289+ }
290+
284291Widget _testApp (ProviderContainer container, {required bool showBlocks}) {
285292 return UncontrolledProviderScope (
286293 container: container,
@@ -313,6 +320,11 @@ void registerBlocklyWebViewIntegrationTests() {
313320
314321 controller.receiveBridgeMessage (_seedSnapshot ());
315322
323+ // Android creates the real platform view lazily after the native Flutter
324+ // surface is attached. Reproduce that production lifecycle instead of
325+ // making a WebView the integration runner's first rendered frame.
326+ await tester.pumpWidget (_testApp (container, showBlocks: false ));
327+ await tester.pumpAndSettle ();
316328 await tester.pumpWidget (_testApp (container, showBlocks: true ));
317329
318330 await _pumpUntil (
@@ -364,6 +376,82 @@ void registerBlocklyWebViewIntegrationTests() {
364376 );
365377 }
366378
379+ // Reload the same platform view, not merely its Flutter wrapper. The
380+ // retained graph must restore through a fresh page generation, and an
381+ // exact duplicate readiness event must remain host-control traffic.
382+ final WebViewWidget webView = tester.widget <WebViewWidget >(
383+ find.byType (WebViewWidget ),
384+ );
385+ final WebViewController liveWebViewController =
386+ WebViewController .fromPlatform (webView.platform.params.controller);
387+ final int beforeReloadRevision = container
388+ .read (blocksDocumentProvider)
389+ .program!
390+ .revision;
391+ await liveWebViewController.reload ();
392+ await _pumpUntil (
393+ tester,
394+ () {
395+ final BlocksDocument state = container.read (blocksDocumentProvider);
396+ return state.status == BlocksStatus .ready &&
397+ state.error == null &&
398+ state.program != null &&
399+ state.program! .revision > beforeReloadRevision &&
400+ state.program! .source == _expectedGpioSource &&
401+ controller.hasActiveReadyHost;
402+ },
403+ reason:
404+ 'the same WebView did not restore through a fresh page generation' ,
405+ );
406+ final int afterReloadRevision = container
407+ .read (blocksDocumentProvider)
408+ .program!
409+ .revision;
410+ final String supersededSnapshot = jsonEncode (< String , Object ? > {
411+ 'version' : 1 ,
412+ 'type' : 'snapshot' ,
413+ 'hostEpoch' : 1 ,
414+ 'revision' : afterReloadRevision + 100 ,
415+ 'source' : 'print("superseded page")\n ' ,
416+ 'workspace' : < String , Object ? > {
417+ 'blocks' : < String , Object ? > {
418+ 'languageVersion' : 0 ,
419+ 'blocks' : < Object ? > [],
420+ },
421+ },
422+ });
423+ final String supersededError = jsonEncode (< String , Object ? > {
424+ 'version' : 1 ,
425+ 'type' : 'error' ,
426+ 'hostEpoch' : 1 ,
427+ 'message' : 'superseded page failed' ,
428+ });
429+ await liveWebViewController.runJavaScript (
430+ 'window.PybleBlocks.postMessage(${jsonEncode (supersededSnapshot )});'
431+ 'window.PybleBlocks.postMessage(${jsonEncode (supersededError )});' ,
432+ );
433+ await tester.pump (const Duration (milliseconds: 300 ));
434+ final BlocksDocument afterSupersededTraffic = container.read (
435+ blocksDocumentProvider,
436+ );
437+ expect (afterSupersededTraffic.status, BlocksStatus .ready);
438+ expect (afterSupersededTraffic.error, isNull);
439+ expect (afterSupersededTraffic.workspaceError, isNull);
440+ expect (afterSupersededTraffic.program? .revision, afterReloadRevision);
441+ expect (afterSupersededTraffic.program? .source, _expectedGpioSource);
442+
443+ await liveWebViewController.runJavaScript (
444+ "window.PybleBlocks.postMessage("
445+ "'{\" version\" :1,\" type\" :\" hostReady\" }');" ,
446+ );
447+ await tester.pump (const Duration (milliseconds: 300 ));
448+ final BlocksDocument afterDuplicateReady = container.read (
449+ blocksDocumentProvider,
450+ );
451+ expect (afterDuplicateReady.status, BlocksStatus .ready);
452+ expect (afterDuplicateReady.error, isNull);
453+ expect (afterDuplicateReady.program? .revision, afterReloadRevision);
454+
367455 await tester.tap (find.byKey (kBlocksPreviewButtonKey));
368456 await tester.pumpAndSettle ();
369457 final Finder previewDialog = find.byType (AlertDialog );
@@ -549,13 +637,26 @@ void registerBlocklyWebViewIntegrationTests() {
549637 () => find.byKey (kBlocksExamplesCatalogKey).evaluate ().isNotEmpty,
550638 reason: 'the bundled beginner example chooser did not open' ,
551639 );
640+ expect (
641+ find.textContaining ("print('Hello, PyBLE!')" ),
642+ findsNothing,
643+ reason: 'opening the chooser must not generate through the WebView' ,
644+ );
645+ await _tapVisible (tester, find.byKey (kBlocksExamplePreviewButtonKey));
552646 await _pumpUntil (
553647 tester,
554648 () =>
555649 find.textContaining ("print('Hello, PyBLE!')" ).evaluate ().isNotEmpty,
556650 reason:
557651 'the real scratch Blockly workspace did not generate the Hello preview' ,
558652 );
653+ await tester.tap (
654+ find.descendant (
655+ of: find.byType (AlertDialog ),
656+ matching: find.text ('Close' ),
657+ ),
658+ );
659+ await tester.pumpAndSettle ();
559660 final BlocksDocument afterHelloPreview = container.read (
560661 blocksDocumentProvider,
561662 );
@@ -576,6 +677,13 @@ void registerBlocklyWebViewIntegrationTests() {
576677 find.widgetWithText (TextField , 'NeoPixel data GPIO' ),
577678 '48' ,
578679 );
680+ await tester.pumpAndSettle ();
681+ expect (
682+ find.textContaining ('from neopixel import NeoPixel' ),
683+ findsNothing,
684+ reason: 'GPIO editing must not generate through the WebView' ,
685+ );
686+ await _tapVisible (tester, find.byKey (kBlocksExamplePreviewButtonKey));
579687 await _pumpUntil (
580688 tester,
581689 () =>
@@ -588,6 +696,13 @@ void registerBlocklyWebViewIntegrationTests() {
588696 'the real scratch Blockly workspace did not generate the standard '
589697 'NeoPixel API from the user-selected GPIO' ,
590698 );
699+ await tester.tap (
700+ find.descendant (
701+ of: find.byType (AlertDialog ),
702+ matching: find.text ('Close' ),
703+ ),
704+ );
705+ await tester.pumpAndSettle ();
591706 final BlocksDocument afterNeoPixelPreview = container.read (
592707 blocksDocumentProvider,
593708 );
@@ -633,16 +748,31 @@ pixels.write()
633748 );
634749 await tester.pumpAndSettle ();
635750 await tester.enterText (find.widgetWithText (TextField , 'LED GPIO' ), '17' );
751+ await tester.pumpAndSettle ();
752+ expect (
753+ find.textContaining ('Pin(17, Pin.OUT' ),
754+ findsNothing,
755+ reason: 'GPIO editing must remain local until an explicit action' ,
756+ );
757+ await _tapVisible (tester, find.byKey (kBlocksExamplePreviewButtonKey));
636758 await _pumpUntil (
637759 tester,
638760 () => find.textContaining ('Pin(17, Pin.OUT' ).evaluate ().isNotEmpty,
639761 reason:
640762 'the real scratch Blockly workspace did not materialize the selected LED GPIO' ,
641763 );
764+ await tester.tap (
765+ find.descendant (
766+ of: find.byType (AlertDialog ),
767+ matching: find.text ('Close' ),
768+ ),
769+ );
770+ await tester.pumpAndSettle ();
642771 // A real Android IME can still cover the example action after enterText,
643772 // even when ensureVisible has scrolled its RenderBox into the viewport.
644- // Close it before asserting hit-testability so the integration gate tests
645- // the action rather than emulator keyboard-animation timing.
773+ // Close it after the explicit preview before asserting hit-testability,
774+ // so the integration gate tests the action rather than emulator
775+ // keyboard-animation timing.
646776 FocusManager .instance.primaryFocus? .unfocus ();
647777 await SystemChannels .textInput.invokeMethod <void >('TextInput.hide' );
648778 await tester.pumpAndSettle ();
0 commit comments