Worked review 3: a cross-platform app, dart:io/web, and release config
prompt ที่ให้ไปคือ: “หน้าจอ settings ที่แสดงระดับแบตเตอรี่ผ่าน platform channel ทำงานได้ทั้งบน web และ native (native อ่านไฟล์ได้ web ทำไม่ได้) มีปุ่ม sync, API URL ที่ตั้งค่าผ่าน environment ได้ และพร้อม release แล้ว — มี error reporting, กฎ lint, ปุ่มที่เข้าถึงได้, test ที่ไม่ค้าง CI” นี่คือ PR แบบที่แตะทุกรอยต่อระหว่าง “รันบนมือถือผมในโหมด debug ได้” กับ “ship จริง”: platform channel, การแยก dart:io/web, error reporting, build config, lint และ accessibility ทุกรอยต่อเหล่านี้มีความผิดพลาดแบบ AI เป็นของตัวเองในดราฟต์นี้ ให้อ่านตามที่ review checklist แนะนำ: ทำงานได้จริงบนทุกแพลตฟอร์มที่อ้างไหมก่อน แล้วค่อย error handling แล้วค่อย release/lint config แล้วค่อย test มี bug จริง 9 ตัว ทุก claim ด้านล่างรันจริงใน probe ของคอร์สนี้ (Flutter 3.47.5) — flutter analyze จริง, flutter test จริง, flutter build web จริง และอ่าน source ของ SDK ที่ติดตั้งจริงตรง ๆ
โค้ดที่ AI สร้างให้
หัวข้อที่มีชื่อว่า “โค้ดที่ AI สร้างให้”import 'package:flutter/material.dart';import 'package:flutter/services.dart';
const batteryChannel = MethodChannel('com.example.crossapp/battery');
class BatteryDisplay extends StatefulWidget { const BatteryDisplay({super.key}); @override State<BatteryDisplay> createState() => _BatteryDisplayState();}
class _BatteryDisplayState extends State<BatteryDisplay> { int? _level;
@override void initState() { super.initState(); _load(); }
Future<void> _load() async { final level = await batteryChannel.invokeMethod<int>('getBatteryLevel'); setState(() => _level = level); }
@override Widget build(BuildContext context) { return Text(_level == null ? 'loading...' : 'Battery: $_level%'); }}import 'package:flutter/foundation.dart' show kIsWeb;
export 'platform_io_impl.dart' if (kIsWeb) 'platform_web_impl.dart';String platformLabel() => 'io';String platformLabel() => 'web';import 'package:flutter/material.dart';
class BrokenBuild extends StatelessWidget { const BrokenBuild({super.key}); @override Widget build(BuildContext context) { throw StateError('boom from build()'); }}main.dart ของ AI เอง ตามที่อธิบายไว้ใน PR (ไม่ใช่ fence จริงที่เก็บได้ — พฤติกรรมจริงของ runZonedGuarded พิสูจน์ไว้ด้านล่างกับ zoned_error_demo.dart แทน): “ห่อ runApp() ด้วย runZonedGuarded เพื่อให้ทุก error ถูกรายงาน — แล้วก็เพิ่ม print() ไม่กี่จุดไว้ debug ยังไงมันก็ถูกตัดออกตอน release อยู่แล้ว”
// the app is built/run with --dart-define=API_URL=...const apiUrl = String.fromEnvironment( 'API_BASE_URL', defaultValue: 'https://api.example.com',);import 'package:flutter/material.dart';
class OldStyleGuard extends StatelessWidget { const OldStyleGuard({super.key, required this.child}); final Widget child; @override Widget build(BuildContext context) { return WillPopScope( onWillPop: () async => false, child: child, ); }}import 'package:flutter/material.dart';
class InfiniteSpinner extends StatefulWidget { const InfiniteSpinner({super.key}); @override State<InfiniteSpinner> createState() => _InfiniteSpinnerState();}
class _InfiniteSpinnerState extends State<InfiniteSpinner> with SingleTickerProviderStateMixin { late final AnimationController _controller = AnimationController( vsync: this, duration: const Duration(milliseconds: 500), )..repeat(); // never settles on its own
@override void dispose() { _controller.dispose(); super.dispose(); }
@override Widget build(BuildContext context) { return RotationTransition(turns: _controller, child: const Icon(Icons.refresh)); }}import 'package:flutter/material.dart';
class UnlabeledSyncButton extends StatelessWidget { const UnlabeledSyncButton({super.key}); @override Widget build(BuildContext context) { return Scaffold( body: IconButton(icon: const Icon(Icons.sync), onPressed: () {}), ); }}analysis_options.yaml ของ PR นี้ พร้อมคอมเมนต์ที่ระบุข้อสมมติที่ AI ไม่เคยเช็กจริง:
include: package:flutter_lints/flutter.yaml
# prefer_const_constructors ships with flutter_lints, so every widget in# this app is already covered.เดินรีวิวทีละบั๊ก
หัวข้อที่มีชื่อว่า “เดินรีวิวทีละบั๊ก”1. เรียก MethodChannel โดยไม่มี PlatformException/MissingPluginException handling
หัวข้อที่มีชื่อว่า “1. เรียก MethodChannel โดยไม่มี PlatformException/MissingPluginException handling”_load() await batteryChannel.invokeMethod โดยไม่มี try/catch เลย กลไกเป๊ะ ๆ (และรูปแบบ channel/method เดียวกันนี้) พิสูจน์ไว้ครบในบท Platform channels; ทำซ้ำที่นี่สำหรับชื่อ channel ของแอปนี้เอง:
import 'package:flutter/services.dart';import 'package:flutter_test/flutter_test.dart';import 'package:my_app/cross_app/battery_widget.dart';
void main() { test('with no platform handler registered, invokeMethod throws the real MissingPluginException text', () async { TestWidgetsFlutterBinding.ensureInitialized(); await expectLater( batteryChannel.invokeMethod<int>('getBatteryLevel'), throwsA(isA<MissingPluginException>().having( (e) => e.toString(), 'message', 'MissingPluginException(No implementation found for method getBatteryLevel on channel com.example.crossapp/battery)', )), ); });}✓ with no platform handler registered, invokeMethod throws the real MissingPluginException text
00:00 +1: All tests passed!🐛 หา bug ในโค้ดนี้
Future<void> _load() async {final level = await batteryChannel.invokeMethod<int>('getBatteryLevel');setState(() => _level = level);}เฉลย
มีสาเหตุจริงและพบบ่อย 2 อย่างที่ทำให้ throw ตรงนี้: PlatformException จากฝั่ง native ที่รายงานความล้มเหลวจริง (ไม่มีสิทธิ์, hardware ใช้ไม่ได้) และ MissingPluginException — พิสูจน์แล้วด้านบน — เมื่อไม่มีอะไรฟังอยู่บน channel นั้นเลย (plugin ยังไม่ได้ลงทะเบียน, แพลตฟอร์มไม่มี implementation หรืออยู่ใน test) เรียกจาก initState โดยไม่มี await ทั้งสองแบบกลายเป็น Future ที่ reject แบบไม่มีใครจัดการ ไม่มี error state ให้ผู้ใช้เห็นเลย ทางแก้คือห่อการเรียกด้วย try { ... } on MissingPluginException { ... } on PlatformException catch (e) { ... } แล้วแสดงอะไรบางอย่างแทนที่จะค้างที่ “loading…” เงียบ ๆ
2. kIsWeb เป็นเงื่อนไข conditional-import — syntax ผ่าน แต่ไม่เคยสลับ branch จริง
หัวข้อที่มีชื่อว่า “2. kIsWeb เป็นเงื่อนไข conditional-import — syntax ผ่าน แต่ไม่เคยสลับ branch จริง”platform_broken.dart export implementation ที่ปลอดภัยสำหรับ web แบบมีเงื่อนไขโดยใช้ if (kIsWeb) อันนี้ analyze ผ่าน ไม่มี error เลย — syntax ของ conditional import ใน Dart ก็แค่ import/export '<default>' if (<dotted.identifier>) '<alt>'; และ dotted identifier ตัวไหนก็ parse ผ่านหมด ตัวเงื่อนไขเองถูกเช็กกับ compile-time environment declaration (กลไกเดียวกับ -D define และการเช็ก dart.library.* ที่มีในตัว) ซึ่งไม่เกี่ยวอะไรกับ kIsWeb ที่เป็นค่าคงที่ runtime จาก package:flutter/foundation.dart เลย พิสูจน์ด้วยการ build จริงสำหรับ web แล้วตรวจดูว่า branch ไหนถูก compile เข้าไป:
$ flutter build web...✓ Built build/web
$ grep -o "SENTINEL_IO_IMPL_USED\|SENTINEL_WEB_IMPL_USED" build/web/main.dart.js | sort | uniq -c 1 SENTINEL_IO_IMPL_USEDBuild ให้ web แล้ว แต่ output ที่ compile ออกมามีแค่ sentinel จาก implementation ฝั่ง io เท่านั้น — if (kIsWeb) ไม่เคยเลือก platform_web_impl.dart ตัวสำรองเลยสักครั้ง ไม่ว่า target ไหน เพราะ kIsWeb ไม่ใช่ environment key ที่ถูกประกาศไว้ (เวอร์ชันที่ใช้ if (dart.library.html) ต่างหาก — เงื่อนไขจริงที่ compiler รู้จัก — เลือก branch ถูกต้องบน build เดียวกันเป๊ะ ดูโค้ดที่แก้แล้ว)
3. runZonedGuarded เป็น error handler ตัวเดียว — พลาด error ของ framework ไปทั้งหมด
หัวข้อที่มีชื่อว่า “3. runZonedGuarded เป็น error handler ตัวเดียว — พลาด error ของ framework ไปทั้งหมด”main.dart ของ AI ห่อ runApp() ด้วย runZonedGuarded แล้วถือว่าจบเรื่อง error reporting พิสูจน์ตรง ๆ กับ widget ที่ throw จาก build():
import 'dart:async';
import 'package:flutter/material.dart';import 'package:flutter_test/flutter_test.dart';import 'package:my_app/cross_app/zoned_error_demo.dart';
void main() { testWidgets('runZonedGuarded around runApp does not see a build()-phase error', (tester) async { Object? caughtByZone; FlutterErrorDetails? caughtByFlutterError;
final originalOnError = FlutterError.onError; FlutterError.onError = (details) => caughtByFlutterError = details; addTearDown(() => FlutterError.onError = originalOnError);
await runZonedGuarded(() async { await tester.pumpWidget(const MaterialApp(home: BrokenBuild())); }, (error, stack) { caughtByZone = error; });
expect(caughtByFlutterError, isNotNull, reason: 'FlutterError.onError genuinely saw the build() error'); expect(caughtByZone, isNull, reason: 'the framework catches build()/layout()/paint() errors ' 'internally and routes them to FlutterError.onError BEFORE ' 'they could ever reach an ambient Zone -- runZonedGuarded ' 'never saw this error at all, even though it wraps runApp()'); });}✓ runZonedGuarded around runApp does not see a build()-phase error
00:00 +1: All tests passed!ตาม Production concerns เรื่องนี้ล้าสมัยซ้อนกันสองชั้น: release notes 3.3 ของ Flutter เองบอกตรง ๆ ว่า PlatformDispatcher.onError แทนที่ความจำเป็นต้องมี custom Zone ไปเลย (“custom Zones were detrimental to a number of optimizations in Dart’s core libraries, which slowed down application start-up time”) และ error ของ build/layout/paint ใน framework ไม่เคยเป็นหน้าที่ของ Zone ที่จะจับตั้งแต่แรกอยู่แล้ว — FlutterError.onError เป็น hook เดียวที่เห็นมัน พิสูจน์แล้วด้านบนและในบทนั้น
4. เรียก print() โดยสมมติว่า “ถูกตัดออกตอน release”
หัวข้อที่มีชื่อว่า “4. เรียก print() โดยสมมติว่า “ถูกตัดออกตอน release””แก้ไขความเข้าใจผิดนี้ไปแล้วใน Production concerns โดยอ่าน source ของ SDK ที่ติดตั้งจริงตรง ๆ (~/flutter/packages/flutter/lib/src/foundation/print.dart): ทั้ง print และ debugPrint ไม่ถูกลบออกใน release build เลย doc comment ของ debugPrint เองบอกตรง ๆ ว่า: “The debugPrint function logs to console even in release mode.” ทุกการเรียก print() ที่เหลือไว้ “เพื่อ debug” ใน PR นี้จะ ship ไปที่ log ของอุปกรณ์ผู้ใช้จริงใน release build ตามที่เขียนไว้เป๊ะ ๆ — เหตุผลจริงที่ควรใช้ debugPrint แทน print เปล่า ๆ คือเรื่อง throttling (หลีกเลี่ยงข้อมูล log หายบน Android ตอนมี output เยอะ) ไม่ใช่เรื่องถูกตัดออกตอน release
5. --dart-define=API_URL=... อ่านด้วย key ผิด
หัวข้อที่มีชื่อว่า “5. --dart-define=API_URL=... อ่านด้วย key ผิด”แอปมีเอกสารบอกว่าอ่าน API URL จาก --dart-define=API_URL=... แต่ env_config.dart อ่าน String.fromEnvironment('API_BASE_URL', ...) — คนละ key กันเลย:
import 'package:flutter_test/flutter_test.dart';import 'package:my_app/cross_app/env_config.dart';
void main() { test('apiUrl falls back to defaultValue -- the key it reads does not match --dart-define=API_URL', () { expect(apiUrl, 'https://api.example.com'); });}✓ apiUrl falls back to defaultValue -- the key it reads does not match --dart-define=API_URL
00:00 +1: All tests passed!รันด้วยมือ (harness ของคอร์สนี้ไม่มีกลไกส่ง --dart-define แบบกำหนดเองต่อบทได้ ข้อจำกัดเดียวกับ proof --dart-define จริงในบท Build & release) ด้วยค่าที่เอกสารของแอปเองบอกให้ส่ง:
$ flutter test --dart-define=API_URL=https://real.example.com test/env_config_test.dartยังคงได้ค่า default https://api.example.com เหมือนเดิม — String.fromEnvironment เป็นค่าคงที่ระดับ compile-time ที่อ่านด้วย key string ที่ตรงเป๊ะ; --dart-define สำหรับ key ที่ไม่มีอะไรอ่านไม่มีผลอะไรเลย และการอ่าน key ที่ไม่มีอะไรกำหนดก็ fallback ไปเงียบ ๆ ไม่มี error ทั้งสองแบบ
6. analysis_options.yaml สมมติว่า prefer_const_constructors มากับ flutter_lints
หัวข้อที่มีชื่อว่า “6. analysis_options.yaml สมมติว่า prefer_const_constructors มากับ flutter_lints”ไม่จริง แก้ไขความเข้าใจผิดนี้ไปแล้วในบท Rebuilds & performance ยืนยันโดยอ่าน lib/flutter.yaml ของ flutter_lints 6.0.0 เองตรง ๆ: มันเพิ่มแค่ prefer_const_constructors_in_immutables ที่แคบกว่า (ซึ่งจะทำงานก็ต่อเมื่อ class มี annotation @immutable ชัดเจนเท่านั้น ไม่ใช่กรณีทั่วไปของ StatelessWidget/StatefulWidget ที่แอปนี้ใช้จริง) package:lints/recommended.yaml ซึ่งเป็นฐานของ flutter_lints เองก็ไม่มีกฎทั่วไปนี้เหมือนกัน ยืนยันด้วยวิธีเดียวกัน very_good_analysis มีกฎทั่วไปนี้มาให้ ตามบทที่อ้างถึง; flutter_lints เพียงอย่างเดียวไม่มี และคอมเมนต์ใน analysis_options.yaml ของ PR นี้บอกตรงข้ามกับสิ่งที่เปิดใช้งานจริง
7. pumpAndSettle บน animation ที่ไม่มีวันจบ
หัวข้อที่มีชื่อว่า “7. pumpAndSettle บน animation ที่ไม่มีวันจบ”AnimationController ของ InfiniteSpinner เรียก ..repeat() — มันไม่เคยหยุด schedule เฟรมด้วยตัวเองเลย test ที่เรียก pumpAndSettle() (ที่รอ “ไม่มีเฟรมเหลือให้ schedule แล้ว”) บนหน้าจอที่มีมันอยู่:
import 'package:flutter/material.dart';import 'package:flutter_test/flutter_test.dart';import 'package:my_app/cross_app/infinite_spinner.dart';
void main() { testWidgets('pumpAndSettle on a repeating animation throws the real timeout error', (tester) async { await tester.pumpWidget(const MaterialApp(home: InfiniteSpinner())); try { await tester.pumpAndSettle(); fail('expected pumpAndSettle to time out'); } catch (e) { print(e); // FlutterError: pumpAndSettle timed out } });}FlutterError: pumpAndSettle timed out
00:01 +1: All tests passed!FlutterError: pumpAndSettle timed out ตัวจริง ประมาณ 1 วินาที (timeout เริ่มต้น) — ทุก test ใน PR นี้ที่แตะหน้าจอที่มี spinner อยู่แล้วเรียก pumpAndSettle ที่ไหนก็ตามจะค้างเต็ม timeout นั้นแล้วก็ fail ด้วยเหตุผลที่ไม่เกี่ยวอะไรกับสิ่งที่ test นั้นกำลังเช็กอยู่เลย
8. PopScope/WillPopScope ปนกันในแอปเดียว
หัวข้อที่มีชื่อว่า “8. PopScope/WillPopScope ปนกันในแอปเดียว”pop_mix.dart ใช้ WillPopScope ในขณะที่อีกหน้าจอหนึ่งใน PR เดียวกัน (ไม่ได้แสดงที่นี่ — พิสูจน์และอ้างอิงไว้ครบด้านล่างแล้ว) ใช้ PopScope เช็กกับ SDK นี้:
info • 'WillPopScope' is deprecated and shouldn't be used. Use PopScope instead. The Android predictive back feature will not work with WillPopScope. This feature was deprecated after v3.12.0-1.0.pre. Try replacing the use of the deprecated member with the replacement • deprecated_member_useannotation deprecation จริงของ WillPopScope, member จริงทั้งสองตัวของ PopScope (canPop, onPopInvokedWithResult) และ widget test เต็มรูปแบบที่พิสูจน์ว่า PopScope บล็อกการ pop และยังเรียก callback ของมันอยู่ พิสูจน์ไว้ในบท Navigator & routes ช่องว่างเรื่อง predictive-back ที่ระบุตรง ๆ ในข้อความ deprecation ไม่ใช่แค่เรื่องความสวยงาม: ตามเอกสารเดียวกันที่อ้างไว้ที่นั่น “The Android predictive back feature will not work with WillPopScope.” แอปที่ migrate ไปครึ่งเดียวจะมีบางหน้าจอที่รองรับได้และบางหน้าจอที่รองรับไม่ได้เลยในเชิงโครงสร้าง
9. ลืม Semantics บนปุ่มไอคอนล้วน ๆ
หัวข้อที่มีชื่อว่า “9. ลืม Semantics บนปุ่มไอคอนล้วน ๆ”UnlabeledSyncButton เป็น IconButton ที่ไม่มี tooltip และไม่มี Semantics ห่ออยู่:
import 'package:flutter/material.dart';import 'package:flutter_test/flutter_test.dart';import 'package:my_app/cross_app/sync_button.dart';
void main() { testWidgets('the unlabeled sync IconButton fails the labeled-tap-target guideline', (tester) async { final handle = tester.ensureSemantics(); await tester.pumpWidget(const MaterialApp(home: UnlabeledSyncButton())); await expectLater(tester, doesNotMeetGuideline(labeledTapTargetGuideline)); handle.dispose(); });}✓ the unlabeled sync IconButton fails the labeled-tap-target guideline
00:00 +1: All tests passed!ข้อความ failure จริงเป๊ะสำหรับปุ่มรูปแบบนี้ (SemanticsNode ที่ระบุ bounding rect และ flag ของตัวเอง จบด้วย “expected tappable node to have semantic label, but none was found”) มีบันทึกไว้ครบในบท Production concerns ซึ่งครอบคลุม tester.ensureSemantics() ด้วย (ต้องเรียกก่อน check meetsGuideline/doesNotMeetGuideline ใด ๆ) และ AccessibilityGuideline ในตัวอื่น ๆ
โค้ดที่แก้แล้ว
หัวข้อที่มีชื่อว่า “โค้ดที่แก้แล้ว”import 'package:flutter/material.dart';import 'package:flutter/services.dart';
const batteryChannel = MethodChannel('com.example.crossapp/battery');
class BatteryDisplay extends StatefulWidget { const BatteryDisplay({super.key}); @override State<BatteryDisplay> createState() => _BatteryDisplayState();}
class _BatteryDisplayState extends State<BatteryDisplay> { int? _level; String? _error;
@override void initState() { super.initState(); _load(); }
Future<void> _load() async { try { final level = await batteryChannel.invokeMethod<int>('getBatteryLevel'); if (!mounted) return; setState(() => _level = level); } on MissingPluginException { if (!mounted) return; setState(() => _error = 'Battery info is not available on this platform'); } on PlatformException catch (e) { if (!mounted) return; setState(() => _error = 'Battery info unavailable: ${e.message}'); } }
@override Widget build(BuildContext context) { if (_error != null) return Text(_error!); return Text(_level == null ? 'loading...' : 'Battery: $_level%'); }}export 'platform_io_impl.dart' if (dart.library.html) 'platform_web_impl.dart';String platformLabel() => 'io';String platformLabel() => 'web';$ flutter build web✓ Built build/web
$ grep -o "SENTINEL_IO_IMPL_USED\|SENTINEL_WEB_IMPL_USED" build/web/main.dart.js | sort | uniq -c 1 SENTINEL_WEB_IMPL_USEDbuild เดียวกันเป๊ะ แค่เปลี่ยนเงื่อนไขเป็น dart.library.html แทน kIsWeb: output สำหรับ web target ตอนนี้มี sentinel ของ branch web จริง ๆ แล้ว
import 'package:flutter/foundation.dart';
void installErrorReporting(void Function(Object error, StackTrace? stack) report) { FlutterError.onError = (details) { FlutterError.presentError(details); report(details.exception, details.stack); }; PlatformDispatcher.instance.onError = (error, stack) { report(error, stack); return true; };}const apiUrl = String.fromEnvironment( 'API_URL', defaultValue: 'https://api.example.com',);import 'package:flutter/material.dart';
class InfiniteSpinner extends StatefulWidget { const InfiniteSpinner({super.key}); @override State<InfiniteSpinner> createState() => _InfiniteSpinnerState();}
class _InfiniteSpinnerState extends State<InfiniteSpinner> with SingleTickerProviderStateMixin { late final AnimationController _controller = AnimationController( vsync: this, duration: const Duration(milliseconds: 500), )..repeat();
@override void dispose() { _controller.dispose(); super.dispose(); }
@override Widget build(BuildContext context) { return RotationTransition(turns: _controller, child: const Icon(Icons.refresh)); }}import 'package:flutter/material.dart';
class LabeledSyncButton extends StatelessWidget { const LabeledSyncButton({super.key}); @override Widget build(BuildContext context) { return Scaffold( body: Semantics( label: 'Sync now', child: IconButton( icon: const Icon(Icons.sync), onPressed: () {}, tooltip: 'Sync now', ), ), ); }}include: package:flutter_lints/flutter.yaml
linter: rules: prefer_const_constructors: trueพิสูจน์ 4 อย่างที่โจทย์ขอมา — conditional import (พิสูจน์แล้วด้านบนผ่าน flutter build web จริง), การผ่าน meetsGuideline, คู่ FlutterError.onError/PlatformDispatcher.instance.onError ที่แยกกันจริง (พิสูจน์ไว้ครบในบท Production concerns เอามาใช้ซ้ำตรงนี้แทนที่จะรันซ้ำแบบไม่จำเป็น) และ pumpAndSettle ที่เปลี่ยนเป็น pump แบบมีขอบเขต:
import 'package:flutter/material.dart';import 'package:flutter_test/flutter_test.dart';import 'package:my_app/cross_app_fixed/sync_button.dart';
void main() { testWidgets('the labeled sync IconButton meets the labeled-tap-target guideline', (tester) async { final handle = tester.ensureSemantics(); await tester.pumpWidget(const MaterialApp(home: LabeledSyncButton())); await expectLater(tester, meetsGuideline(labeledTapTargetGuideline)); handle.dispose(); });}import 'package:flutter/material.dart';import 'package:flutter_test/flutter_test.dart';import 'package:my_app/cross_app_fixed/infinite_spinner.dart';
void main() { testWidgets('a fixed number of tester.pump(duration) calls checks a repeating animation without pumpAndSettle', (tester) async { await tester.pumpWidget(const MaterialApp(home: InfiniteSpinner())); for (var i = 0; i < 5; i++) { await tester.pump(const Duration(milliseconds: 500)); } expect(tester.takeException(), isNull); expect(find.byIcon(Icons.refresh), findsOneWidget); });}import 'package:flutter/material.dart';import 'package:flutter_test/flutter_test.dart';import 'package:my_app/cross_app_fixed/battery_widget.dart';
void main() { testWidgets('BatteryDisplay with no handler shows an error state instead of crashing', (tester) async { await tester.pumpWidget(const MaterialApp(home: BatteryDisplay())); // A MissingPluginException from a channel with no handler needs a real // async gap (not just fake-clock frame pumps) to actually resolve. await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 10))); await tester.pump();
expect(tester.takeException(), isNull); expect(find.text('Battery info is not available on this platform'), findsOneWidget); });}import 'package:flutter_test/flutter_test.dart';import 'package:my_app/cross_app_fixed/env_config.dart';
void main() { test('apiUrl falls back to defaultValue when no --dart-define is passed', () { expect(apiUrl, 'https://api.example.com'); });}✓ the labeled sync IconButton meets the labeled-tap-target guideline✓ a fixed number of tester.pump(duration) calls checks a repeating animation without pumpAndSettle✓ BatteryDisplay with no handler shows an error state instead of crashing✓ apiUrl falls back to defaultValue when no --dart-define is passed
00:02 +4: All tests passed!สิ่งที่เปลี่ยนและเหตุผล
หัวข้อที่มีชื่อว่า “สิ่งที่เปลี่ยนและเหตุผล”| # | Bug | Fix |
|---|---|---|
| 1 | เรียก MethodChannel โดยไม่มี exception handling | on MissingPluginException/on PlatformException — พิสูจน์แล้ว: ไม่งั้นได้ข้อความ MissingPluginException จริง |
| 2 | kIsWeb เป็นเงื่อนไข conditional-import | dart.library.html — พิสูจน์แล้ว: output ของ flutter build web สลับ branch จริง |
| 3 | runZonedGuarded เป็น error handler ตัวเดียว | FlutterError.onError + PlatformDispatcher.instance.onError ไม่มี Zone — พิสูจน์แล้ว: build error ข้าม zone ไปเลย |
| 4 | print() “ถูกตัดออกตอน release” | ลบทิ้ง/guard ด้วย kDebugMode หรือใช้ debugPrint/log() จาก dart:developer — ตาม source ของ SDK ไม่มีอะไรถูกตัดตอน release |
| 5 | --dart-define=API_URL อ่านเป็น API_BASE_URL | key ให้ตรงกัน — พิสูจน์แล้ว: ใช้ default เสมอถ้า key ผิด |
| 6 | analysis_options.yaml สมมติว่า prefer_const_constructors มาให้เอง | เพิ่มกฎนี้เข้าไปตรง ๆ — ตาม source ของ flutter_lints มันไม่ได้มาให้เองโดยค่าเริ่มต้น |
| 7 | pumpAndSettle บน animation แบบ ..repeat() | เรียก tester.pump(duration) แบบมีขอบเขต — พิสูจน์แล้ว: ไม่งั้นได้ pumpAndSettle timed out จริง |
| 8 | WillPopScope/PopScope ปนกัน | ใช้ PopScope ทุกที่ — พิสูจน์แล้วว่า deprecated และ predictive back ใช้กับ widget เก่าไม่ได้เลยในเชิงโครงสร้าง |
| 9 | ปุ่มไอคอนล้วน ๆ ไม่มี Semantics/tooltip | Semantics(label: ...) + tooltip — พิสูจน์แล้ว: ผ่าน meetsGuideline จริง |
สิ่งที่ reviewer เขียนใน PR
หัวข้อที่มีชื่อว่า “สิ่งที่ reviewer เขียนใน PR”เรื่องนี้แตะรอยต่อจริงเยอะมาก และเกือบทุกอันต้องแก้ — มี 9 เรื่อง:
- ไม่มี error handling บนการเรียก battery channel
MissingPluginException/PlatformExceptionthrow ไม่มีใครจัดการจากinitStateทั้งคู่ ห่อมันซะif (kIsWeb)ใน conditional import ไม่ทำอะไรเลย ยืนยันด้วย web build จริง — sentinel ของ branch io ยังโผล่แม้ใน web bundle conditional import เช็กแค่dart.library.*/environment declaration ไม่ใช่ runtime constantrunZonedGuardedเดี่ยว ๆ พลาด error ของ build/layout/paint ไปทั้งหมด และยังเป็นคำแนะนำที่ล้าสมัยตั้งแต่ Flutter 3.3 ด้วย ใช้FlutterError.onError+PlatformDispatcher.instance.onErrorไม่ต้องมีZoneเองprint()ไม่ถูกตัดออกตอน release — ยืนยันจาก source ของ SDK อะไรก็ตามที่ sensitive ใน call พวกนั้นจะ ship ไปที่ log ของผู้ใช้จริง--dart-define=API_URLกับString.fromEnvironment('API_BASE_URL')ไม่ตรงกัน ค่าที่ override ไม่เคยมีผลเลยแบบเงียบ ๆ- คอมเมนต์ใน
analysis_options.yamlผิดflutter_lintsไม่มีprefer_const_constructorsมาให้ (มีแค่_in_immutablesที่แคบกว่า) — เพิ่มเข้าไปตรง ๆ ถ้าต้องการ- test ที่ใช้
pumpAndSettleบนหน้า spinner จะค้างเต็มหนึ่งวินาทีแล้วก็ fail ด้วยเหตุผลที่ไม่เกี่ยวกับสิ่งที่มันกำลังทดสอบเลย ใช้pump(duration)แบบมีขอบเขตกับอะไรก็ตามที่มี animation แบบวนซ้ำWillPopScopeที่นี่PopScopeที่อื่นWillPopScopedeprecated แล้วและรองรับ predictive back ไม่ได้เลยในเชิงโครงสร้าง migrate ตัวนี้ด้วย- ปุ่ม sync ไม่มี label ที่เข้าถึงได้
meetsGuideline(labeledTapTargetGuideline)fail อยู่ตอนนี้เลย — เพิ่มSemantics/tooltipทุกอย่างเป็นการแก้เล็ก ๆ เชิงกลไกทั้งนั้น — ยินดี pair กันแก้ข้อไหนก็ได้
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”มี 2 เรื่องที่วนซ้ำตลอดรีวิวนี้ อย่างแรก: API ที่ดูสมเหตุสมผลแต่ไม่ได้ทำในสิ่งที่ดูเหมือนจะทำ — if (kIsWeb) ข้างใน conditional import parse และ analyze ผ่านสะอาด runZonedGuarded จับ error บางส่วน ได้จริง --dart-define เป็นกลไกจริงจริง ๆ — แต่ละตัวคือ Dart/Flutter surface จริง แค่ไม่ได้ต่อเข้ากับงานเฉพาะที่ AI เอาไปใช้ อย่างที่สอง: claim ที่เช็กได้ง่ายแต่ไม่มีใครเช็ก — “print ถูกตัดออกตอน release”, “flutter_lints มี prefer_const_constructors มาให้” ทั้งคู่แค่ grep ครั้งเดียวเข้าไปใน source ที่ติดตั้งจริงก็หักล้างได้แล้ว และทั้งคู่เป็น claim ที่แก้ไขความเข้าใจผิดไปแล้วที่อื่นในคอร์สนี้ด้วยเหตุผลเดียวกันเป๊ะ ตลอดทั้ง 9 bug ไม่มีตัวไหนแก้ยากเลยเมื่อเจอแล้ว — รีวิวทั้งหมดนี้อยู่ที่การ เจอ มัน ไม่ใช่การแก้
Trade-offs
หัวข้อที่มีชื่อว่า “Trade-offs”| ตัวเลือก | ข้อดี | ต้นทุน |
|---|---|---|
conditional import (if (dart.library.X)) แทน branch แบบ runtime kIsWeb ในไฟล์เดียว | สลับ implementation ตาม compile target ได้จริง — พิสูจน์แล้วด้านบน | คำศัพท์ของเงื่อนไขแคบ (dart.library.*, environment declaration) — ใช้ logic runtime แบบอิสระไม่ได้ |
FlutterError.onError + PlatformDispatcher.instance.onError ไม่มี Zone | ตรงกับคำแนะนำปัจจุบันของ Flutter เอง; หลีกเลี่ยงต้นทุน startup ของ custom Zone | ต้องต่อ callback 2 ตัวแทนที่จะห่อตัวเดียว — เผลอเพิ่มแค่ตัวเดียวแล้วคิดว่าครอบคลุมแล้วได้ง่าย |
--dart-define สำหรับ config ตาม environment | ค่าคงที่ระดับ compile-time ไม่ต้อง parse ตอน runtime ไม่มี secret commit ลง source | ไม่ทำอะไรเลยแบบเงียบ ๆ ถ้า key ไม่ตรงกันทั้งสองฝั่ง — ไม่มี error มีแค่ default |
Semantics/meetsGuideline ใน test | จับช่องโหว่ด้าน accessibility จริงและเจาะจงได้แบบกลไก ใน CI ไม่ต้องใช้อุปกรณ์จริง | ครอบคลุมแค่สิ่งที่ built-in guideline เช็ก ไม่ใช่ตัวแทนการทดสอบ screen reader จริง |
Common Misconceptions
หัวข้อที่มีชื่อว่า “Common Misconceptions”- “
kIsWebใช้เป็นเงื่อนไข conditional-import ได้เพราะมันเป็นtrue/falseตอน compile time” พิสูจน์แล้วว่าผิดด้านบนด้วยflutter build webจริงบวกการตรวจ output ที่ compile: conditional import เข้าใจแค่การเช็กdart.library.*และ environment key ที่ประกาศไว้ ไม่ใช่ค่าคงที่ Dart แบบอิสระ ไม่ว่าจะดู compile-time แค่ไหนก็ตาม - “
print()/debugPrint()ถูกลบออกอัตโนมัติใน release build” พิสูจน์แล้วว่าผิดตรงจาก source ของ SDK ที่ติดตั้งจริง อ้างไว้ด้านบนจาก Production concerns: ทั้งคู่ไม่ถูกตัดออกเลย doc comment ของdebugPrintเองบอกว่ามัน log “even in release mode” - “
runZonedGuardedรอบrunApp()จับ error ได้ทุกตัว เลยไม่จำเป็นต้องมีFlutterError.onError” พิสูจน์แล้วว่าผิดด้านบนด้วย test จริง: error ระยะbuild()ไม่มีทางไปถึง handler ของ zone เลย เพราะ framework ดักและส่งไปที่FlutterError.onErrorก่อนเสมอ - “
flutter_lintsเป็น preset ที่เข้มงวดและมีกฎ performance ที่ชัดเจนอย่างprefer_const_constructorsมาให้แล้ว” พิสูจน์แล้วว่าผิดโดยอ่านlib/flutter.yamlของมันเอง: มันเพิ่ม list สั้น ๆ ที่แคบโดยตั้งใจทับpackage:lints/recommended.yamlและprefer_const_constructors(กฎทั่วไป ไม่ใช่ตัวแปร_in_immutables) ไม่ได้อยู่ในนั้น
flowchart TD
A["BatteryDisplay.initState -> _load()"] -->|"1: no try/catch"| A
B["export ... if (kIsWeb) ..."] -->|"2: never switches branch"| B
C["runApp() wrapped in runZonedGuarded"] -->|"3: misses build/layout/paint errors"| C
D["print(...) for debugging"] -->|"4: ships to release logs"| D
E["String.fromEnvironment('API_BASE_URL')"] -->|"5: --dart-define=API_URL never applies"| E
F["analysis_options.yaml"] -->|"6: prefer_const_constructors not actually on"| F
G["test: pumpAndSettle on InfiniteSpinner"] -->|"7: real timeout"| G
H["WillPopScope here, PopScope elsewhere"] -->|"8: predictive back broken here"| H
I["IconButton(icon: sync), no label"] -->|"9: fails meetsGuideline"| I