perf(tv): cut semantics work during card navigation

This commit is contained in:
edde746
2026-07-25 08:13:09 +02:00
parent 2b3853a882
commit fb27621c75
6 changed files with 319 additions and 76 deletions
+44 -1
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/focus/card_focus_scope.dart';
import 'package:plezy/focus/focusable_wrapper.dart';
import 'package:plezy/focus/input_mode_tracker.dart';
@@ -25,7 +26,7 @@ void main() {
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.pump();
expect(chromeIn(Transform), findsOneWidget);
expect(chromeIn(AnimatedBuilder), findsOneWidget);
expect(chromeIn(AnimatedContainer), findsOneWidget);
});
@@ -78,4 +79,46 @@ void main() {
expect(longPressed, 1);
expect(selected, 1);
});
testWidgets('focus scale animation keeps child semantics geometry stable', (tester) async {
final semantics = tester.ensureSemantics();
final node = FocusNode(debugLabel: 'card');
addTearDown(node.dispose);
await tester.pumpWidget(
InputModeTracker(
child: MaterialApp(
home: Scaffold(
body: Center(
child: FocusableWrapper(
focusNode: node,
focusScale: 1.2,
delegateFocusBorder: true,
child: CardFocusBorder(
child: Semantics(label: 'card content', child: SizedBox(width: 100, height: 100)),
),
),
),
),
),
),
);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
node.unfocus();
await tester.pumpAndSettle();
node.requestFocus();
await tester.pump();
final semanticsOwner = tester.binding.rootPipelineOwner.semanticsOwner!;
var semanticsUpdates = 0;
void countSemanticsUpdate() => semanticsUpdates++;
semanticsOwner.addListener(countSemanticsUpdate);
await tester.pump(const Duration(milliseconds: 16));
semanticsUpdates = 0;
await tester.pump(const Duration(milliseconds: 16));
expect(semanticsUpdates, 0);
semanticsOwner.removeListener(countSemanticsUpdate);
semantics.dispose();
});
}
@@ -316,6 +316,50 @@ void main() {
semantics.dispose();
});
testWidgets('TV cards collapse pointer-only detail semantics without a screen reader', (tester) async {
final semantics = tester.ensureSemantics();
TvDetectionService.debugSetAppleTVOverride(true);
final item = testMediaItem(
id: 'tv_semantic_movie',
kind: MediaKind.movie,
title: 'TV Semantic Movie',
summary: 'TV decorative summary',
);
await tester.pumpWidget(
_TestApp(
child: SizedBox(width: 200, height: 330, child: MediaCard(item: item, forceGridMode: true, isOffline: true)),
),
);
final card = tester.getSemantics(find.bySemanticsLabel(mediaCardSemanticLabel(item))).getSemanticsData();
expect(card.flagsCollection.isButton, isTrue);
expect(card.hasAction(ui.SemanticsAction.tap), isTrue);
expect(find.bySemanticsLabel('TV Semantic Movie'), findsNothing);
expect(find.bySemanticsLabel(RegExp('TV decorative summary')), findsNothing);
semantics.dispose();
});
testWidgets('TV cards preserve detail semantics for accessible navigation', (tester) async {
final semantics = tester.ensureSemantics();
TvDetectionService.debugSetAppleTVOverride(true);
final item = testMediaItem(id: 'tv_accessible_movie', kind: MediaKind.movie, title: 'Accessible TV Movie');
await tester.pumpWidget(
_TestApp(
child: MediaQuery(
data: const MediaQueryData(accessibleNavigation: true),
child: SizedBox(width: 200, height: 330, child: MediaCard(item: item, forceGridMode: true, isOffline: true)),
),
),
);
final detail = tester.getSemantics(find.bySemanticsLabel('Accessible TV Movie')).getSemanticsData();
expect(detail.flagsCollection.isButton, isTrue);
expect(detail.hasAction(ui.SemanticsAction.tap), isTrue);
semantics.dispose();
});
testWidgets('custom card actions keep detail-link semantics disabled in grid and list modes', (tester) async {
final semantics = tester.ensureSemantics();
final item = testMediaItem(
+64
View File
@@ -11,6 +11,7 @@ import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/providers/multi_server_provider.dart';
import 'package:plezy/services/data_aggregation_service.dart';
import 'package:plezy/services/device_performance.dart';
import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/theme/mono_theme.dart';
@@ -396,6 +397,69 @@ void main() {
semantics.dispose();
});
testWidgets('low-end snapshot optimization preserves vertical scroll animation', (tester) async {
DevicePerformance.debugReset(autoReduced: true);
addTearDown(DevicePerformance.debugReset);
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(1280, 720);
addTearDown(tester.view.resetDevicePixelRatio);
addTearDown(tester.view.resetPhysicalSize);
final serverManager = MultiServerManager();
final hubs = List.generate(6, (hubIndex) {
final item = testMediaItem(
id: 'movie_$hubIndex',
backend: MediaBackend.plex,
kind: MediaKind.movie,
title: 'Movie $hubIndex',
);
return MediaHub(id: 'hub_$hubIndex', title: 'Hub $hubIndex', type: 'movie', items: [item], size: 1);
});
await tester.pumpWidget(
ChangeNotifierProvider<MultiServerProvider>(
create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)),
child: InputModeTracker(
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(
body: SizedBox(
width: 1280,
height: 720,
child: TvBrowseRail(
focusMemory: focusMemory,
hubs: hubs,
autofocus: true,
iconForHub: (_, _) => Icons.movie_rounded,
),
),
),
),
),
),
);
await tester.pump();
tester.state<TvBrowseRailState>(find.byType(TvBrowseRail)).requestFocus();
await tester.pump();
final position = _verticalRailPosition(tester);
final initialOffset = position.pixels;
tester.widget<Semantics>(find.byKey(const ValueKey('tv_browse_rail_semantic_proxy'))).properties.onScrollDown!();
await tester.pump();
await tester.pump(const Duration(milliseconds: 80));
await tester.pump(const Duration(milliseconds: 80));
final animatedOffset = position.pixels;
expect(animatedOffset, greaterThan(initialOffset));
expect(tester.hasRunningAnimations, isTrue);
final snapshots = tester.widgetList<SnapshotWidget>(find.byType(SnapshotWidget));
expect(snapshots, isNotEmpty);
expect(snapshots.every((widget) => widget.controller.allowSnapshotting), isTrue);
await tester.pumpAndSettle();
expect(position.pixels, greaterThan(animatedOffset));
expect(snapshots.every((widget) => widget.controller.allowSnapshotting), isFalse);
});
testWidgets('active hub header uses theme foreground in light mode', (tester) async {
final serverManager = MultiServerManager();
final theme = monoTheme(dark: false);