appsok/test/console_page_test.dart

319 lines
9.1 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:appsok/src/features/console/console_page.dart';
import 'package:appsok/src/models/adb_device.dart';
import 'package:appsok/src/services/adb_service.dart';
import 'package:appsok/src/theme/app_theme.dart';
class _FakeProcess implements Process {
final _stdout = StreamController<List<int>>();
final _stderr = StreamController<List<int>>();
final _exitCode = Completer<int>();
bool killCalled = false;
void emitStdout(String text) => _stdout.add(utf8.encode(text));
void emitStderr(String text) => _stderr.add(utf8.encode(text));
@override
Stream<List<int>> get stdout => _stdout.stream;
@override
Stream<List<int>> get stderr => _stderr.stream;
@override
Future<int> get exitCode => _exitCode.future;
@override
bool kill([ProcessSignal signal = ProcessSignal.sigterm]) {
killCalled = true;
if (!_exitCode.isCompleted) _exitCode.complete(-15);
_stdout.close();
_stderr.close();
return true;
}
@override
int get pid => 4242;
@override
IOSink get stdin => throw UnimplementedError();
}
void main() {
testWidgets('colors sample logcat rows by parsed level', (tester) async {
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: const Scaffold(body: ConsolePage()),
),
);
const infoLine =
'06-08 10:41:22.121 1832 2110 I ActivityTaskManager: START u0 com.toki.app/.MainActivity';
const debugLine =
'06-08 10:41:22.339 5521 5521 D AppSokInstall: install session committed';
const warnLine =
'06-08 10:41:22.921 5521 5540 W NetworkMonitor: vpn route not available, using usb install cache';
const errorLine =
'06-08 10:41:23.411 5521 5521 E Sample: placeholder error row for filter styling';
expect(_textColor(tester, infoLine), const Color(0xFFBDEFA8));
expect(_textColor(tester, debugLine), const Color(0xFF8DE1D7));
expect(_textColor(tester, warnLine), const Color(0xFFFFD28A));
expect(_textColor(tester, errorLine), const Color(0xFFFFA3A3));
});
testWidgets('appends live stream lines and stops session on dispose', (
tester,
) async {
final fakeProcess = _FakeProcess();
final session = AdbLogcatSession(fakeProcess);
const device = AdbDevice(
serial: 'R5CT90A1B2C',
state: 'device',
model: 'SM_S918N',
product: 'dm3q',
);
var startCalls = 0;
Future<AdbLogcatSession> fakeStarter({String? serial}) async {
startCalls++;
expect(serial, device.serial);
return session;
}
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: Scaffold(
body: ConsolePage(device: device, logcatStarter: fakeStarter),
),
),
);
// Initial pump to run initState/startSession
await tester.pump();
expect(startCalls, 1);
// Emit live log lines
fakeProcess.emitStdout('I/test: hello live line\n');
await tester.pump(Duration.zero);
expect(find.text('I/test: hello live line'), findsOneWidget);
// Check dispose kills process
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: const Scaffold(body: SizedBox.shrink()),
),
);
expect(fakeProcess.killCalled, isTrue);
});
testWidgets('discards stale session when device changes during async start', (
tester,
) async {
final completerA = Completer<AdbLogcatSession>();
final completerB = Completer<AdbLogcatSession>();
final fakeProcessA = _FakeProcess();
final sessionA = AdbLogcatSession(fakeProcessA);
final fakeProcessB = _FakeProcess();
final sessionB = AdbLogcatSession(fakeProcessB);
const deviceA = AdbDevice(
serial: 'device-A',
state: 'device',
model: 'Pixel_8',
product: 'shiba',
);
const deviceB = AdbDevice(
serial: 'device-B',
state: 'device',
model: 'Galaxy_S23',
product: 'dm3q',
);
var startCalls = 0;
Future<AdbLogcatSession> fakeStarter({String? serial}) async {
startCalls++;
if (serial == 'device-A') {
return completerA.future;
} else if (serial == 'device-B') {
return completerB.future;
}
throw Exception('unexpected serial');
}
// 1. Render ConsolePage with deviceA, starter completerA is pending
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: Scaffold(
body: ConsolePage(device: deviceA, logcatStarter: fakeStarter),
),
),
);
// Initial pump to trigger initState
await tester.pump();
expect(startCalls, 1);
// 2. Update widget to deviceB, starter completerB is pending
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: Scaffold(
body: ConsolePage(device: deviceB, logcatStarter: fakeStarter),
),
),
);
// didUpdateWidget triggers start for deviceB
await tester.pump();
expect(startCalls, 2);
// 3. Complete starter B first
completerB.complete(sessionB);
await tester.pump(); // let completerB resolve
await tester.pump(Duration.zero); // let stream listen trigger
// Emit line from B
fakeProcessB.emitStdout('I/test: hello from B\n');
await tester.pump(Duration.zero);
expect(find.text('I/test: hello from B'), findsOneWidget);
// 4. Now complete starter A (which is stale)
completerA.complete(sessionA);
await tester.pump(); // let completerA resolve
await tester.pump(Duration.zero);
// A session must be stopped immediately
expect(fakeProcessA.killCalled, isTrue);
});
testWidgets('auto-scrolls to the newest log while running', (tester) async {
final fakeProcess = _FakeProcess();
final session = AdbLogcatSession(fakeProcess);
const device = AdbDevice(
serial: 'R5CT90A1B2C',
state: 'device',
model: 'SM_S918N',
product: 'dm3q',
);
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: Scaffold(
body: ConsolePage(
device: device,
logcatStarter: ({serial}) async => session,
),
),
),
);
await tester.pump();
// Emit enough lines to make the list scrollable (e.g., 50 lines)
for (var i = 0; i < 50; i++) {
fakeProcess.emitStdout('I/test: line $i\n');
}
await tester.pump(Duration.zero);
await tester.pumpAndSettle();
// Verify it auto-scrolled to the bottom
final listView = tester.widget<ListView>(find.byType(ListView));
final controller = listView.controller;
expect(controller, isNotNull);
expect(
controller!.position.pixels,
equals(controller.position.maxScrollExtent),
);
});
testWidgets('keeps user scroll position while paused', (tester) async {
final fakeProcess = _FakeProcess();
final session = AdbLogcatSession(fakeProcess);
const device = AdbDevice(
serial: 'R5CT90A1B2C',
state: 'device',
model: 'SM_S918N',
product: 'dm3q',
);
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: Scaffold(
body: ConsolePage(
device: device,
logcatStarter: ({serial}) async => session,
),
),
),
);
await tester.pump();
// 1. Emit enough lines to scroll
for (var i = 0; i < 50; i++) {
fakeProcess.emitStdout('I/test: line $i\n');
}
await tester.pump(Duration.zero);
await tester.pumpAndSettle();
final listView = tester.widget<ListView>(find.byType(ListView));
final controller = listView.controller!;
final maxScrollExtentBeforePause = controller.position.maxScrollExtent;
expect(controller.position.pixels, equals(maxScrollExtentBeforePause));
// 2. Press pause button
final pauseButtonFinder = find.byTooltip('일시정지');
expect(pauseButtonFinder, findsOneWidget);
await tester.tap(pauseButtonFinder);
await tester.pumpAndSettle();
// Verify tooltip and icon changed
expect(find.byTooltip('재개'), findsOneWidget);
// 3. User scrolls up slightly to some offset (e.g., 100.0)
controller.jumpTo(100.0);
await tester.pumpAndSettle();
expect(controller.position.pixels, equals(100.0));
// 4. Emit more lines while paused
for (var i = 50; i < 60; i++) {
fakeProcess.emitStdout('I/test: line $i\n');
}
await tester.pump(Duration.zero);
await tester.pumpAndSettle();
// Verify offset remains at 100.0 (does not scroll to bottom)
expect(controller.position.pixels, equals(100.0));
// 5. Tap resume (tooltip '재개')
final resumeButtonFinder = find.byTooltip('재개');
await tester.tap(resumeButtonFinder);
await tester.pumpAndSettle();
// Verify tooltip is '일시정지' again and it scrolled to bottom
expect(find.byTooltip('일시정지'), findsOneWidget);
expect(
controller.position.pixels,
equals(controller.position.maxScrollExtent),
);
});
}
Color? _textColor(WidgetTester tester, String text) {
return tester.widget<Text>(find.text(text)).style?.color;
}