Files
plezy/test/widgets/fitting_title_text_test.dart
T
edde746 c79ff68a7b perf(tv): shape spotlight text once per swap instead of twice
Every D-pad step on the TV home ends with a spotlight swap, and on a
low-end box that frame ran over budget: 12 % of UI-thread CPU during
navigation was paragraph layout, most of it shaping strings a second time
to measure them before the render tree shaped them for real.

- FittingTitleText no longer lays the title out to learn whether it fits:
  Text wraps and ellipsizes at maxLines, so the box can only overflow
  vertically, and one cached one-glyph line height settles that. Only a
  box shorter than maxLines lines still searches, now bracketed by the
  proportional shrink of the base layout and stopped at a 0.25 px
  tolerance instead of a fixed 12 bisections.
- FittedMetadataLine and inlineRatingBadgeWidth measure through a small
  bounded cache keyed by text, style, scaler and direction, so separators,
  type labels, certifications, runtimes and years measure once per
  session.
- MediaHub.isContinueWatchingHub/usesContinueWatchingAction memoize their
  regex tokenisation per key; every card build and step re-asked them.

Cold navigation on the Android 14 box (30 steps over three hubs): build
312-316 ms -> 284-285 ms, layout 530-540 ms -> 515 ms, UI frame p90
7.5-7.9 ms -> 6.8-7.2 ms.
2026-09-02 22:38:11 +02:00

71 lines
2.5 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/widgets/fitting_title_text.dart';
const _style = TextStyle(fontSize: 40, letterSpacing: 0, wordSpacing: 0);
Future<double> _fittedSize(WidgetTester tester, String text, {required double width, required double height}) async {
await tester.pumpWidget(
MaterialApp(
home: Center(
child: SizedBox(
width: width,
height: height,
child: FittingTitleText(text, style: _style),
),
),
),
);
return tester.widget<Text>(find.text(text)).style!.fontSize!;
}
/// Whether [text] at [fontSize], wrapped to two lines like the widget does,
/// fits [height] — the widget's own definition of fitting.
bool _fits(String text, double fontSize, {required double width, required double height}) {
final painter = TextPainter(
text: TextSpan(
text: text,
style: _style.copyWith(fontSize: fontSize),
),
maxLines: 2,
ellipsis: '\u2026',
textDirection: TextDirection.ltr,
)..layout(maxWidth: width);
final fits = painter.height <= height + 0.1;
painter.dispose();
return fits;
}
void main() {
testWidgets('keeps the base size when two lines fit the box', (tester) async {
expect(await _fittedSize(tester, 'ABCDE', width: 400, height: 100), 40);
// Width never shrinks the title: a too-long line is ellipsized instead.
expect(await _fittedSize(tester, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', width: 50, height: 100), 40);
});
testWidgets('a box shorter than two lines gets the largest size that still fits', (tester) async {
for (final (text, width, height) in [
('ABCDE FGHIJ', 50.0, 8.0), // one line beats two: 11 chars on one line at ~4.5 > two lines at 4
('ABCDE FGHIJ', 50.0, 20.0), // two lines at ~10, bounded by the line width
('The Lord of the Rings: The Return of the King', 300.0, 60.0),
]) {
final size = await _fittedSize(tester, text, width: width, height: height);
expect(size, lessThan(40), reason: '$text in ${width}x$height');
expect(
_fits(text, size, width: width, height: height),
isTrue,
reason: '$text at $size must fit',
);
expect(
_fits(text, size + 0.5, width: width, height: height),
isFalse,
reason: '$text at $size is not maximal',
);
}
});
testWidgets('never goes below the minimum size', (tester) async {
expect(await _fittedSize(tester, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', width: 5, height: 0.5), 1);
});
}