frosty/test/utils_test.dart
Tommy Chow 16f2722a5b perf: async startup cache sweep, memoize chat name color
the orphaned-cache-file sweep ran a synchronous recursive listSync on
the UI isolate at startup against a cache that can hold 10k files; use
the async listing instead. memoize adjustChatNameColor keyed by (color,
background, target contrast) since chat repeats the same handful of
palette colors thousands of times and low-contrast ones pay a 16-step
search per call.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 14:45:38 -04:00

378 lines
12 KiB
Dart

import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:frosty/utils.dart';
void main() {
group('getReadableName', () {
test('returns display name when it equals username', () {
expect(getReadableName('testuser', 'testuser'), 'testuser');
});
test('returns display name when it contains English letters (even if case differs)', () {
// Display name contains English letters, so it's readable as-is
// The function doesn't add username because English letters make it readable
expect(getReadableName('TestUser', 'testuser'), 'TestUser');
});
test('returns display name only for numeric-only names', () {
expect(getReadableName('12345', 'somename'), '12345');
});
test('returns display name only when it contains English letters', () {
expect(getReadableName('TestName123', 'testname123'), 'TestName123');
expect(getReadableName('User_Name', 'user_name'), 'User_Name');
});
test('adds username in parentheses for non-English display names', () {
// Japanese characters
expect(getReadableName('テスト', 'testuser'), 'テスト (testuser)');
// Korean characters
expect(getReadableName('테스트', 'testuser'), '테스트 (testuser)');
// Chinese characters
expect(getReadableName('测试', 'testuser'), '测试 (testuser)');
// Russian characters
expect(getReadableName('Тест', 'testuser'), 'Тест (testuser)');
});
test('adds username for mixed non-English with numbers', () {
// Non-English + numbers (no English letters)
expect(getReadableName('テスト123', 'testuser'), 'テスト123 (testuser)');
});
test('returns display name for mixed English and non-English', () {
// Has English letters, so no parentheses
expect(getReadableName('TestテストUser', 'testuser'), 'TestテストUser');
});
});
group('adjustChatNameColor', () {
// Helper to create a testable widget tree with specific theme
Widget buildTestWidget({
required Color scaffoldBackground,
required Widget child,
Brightness brightness = Brightness.dark,
}) {
return MaterialApp(
theme: ThemeData(
brightness: brightness,
scaffoldBackgroundColor: scaffoldBackground,
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.blue,
brightness: brightness,
surface: scaffoldBackground,
),
),
home: Scaffold(body: child),
);
}
testWidgets('brightens dark colors on dark backgrounds', (tester) async {
late Color adjustedColor;
const darkBackground = Color(0xFF121212);
const darkNameColor = Color(0xFF000000); // Pure black
await tester.pumpWidget(
buildTestWidget(
scaffoldBackground: darkBackground,
child: Builder(
builder: (context) {
adjustedColor = adjustChatNameColor(context, darkNameColor);
return const SizedBox();
},
),
),
);
// The adjusted color should be lighter than the original
final originalLuminance = darkNameColor.computeLuminance();
final adjustedLuminance = adjustedColor.computeLuminance();
expect(adjustedLuminance, greaterThan(originalLuminance));
});
testWidgets('darkens light colors on light backgrounds', (tester) async {
late Color adjustedColor;
const lightBackground = Color(0xFFFAFAFA);
const lightNameColor = Color(0xFFFFFFFF); // Pure white
await tester.pumpWidget(
buildTestWidget(
scaffoldBackground: lightBackground,
brightness: Brightness.light,
child: Builder(
builder: (context) {
adjustedColor = adjustChatNameColor(context, lightNameColor);
return const SizedBox();
},
),
),
);
// The adjusted color should be darker than the original
final originalLuminance = lightNameColor.computeLuminance();
final adjustedLuminance = adjustedColor.computeLuminance();
expect(adjustedLuminance, lessThan(originalLuminance));
});
testWidgets('preserves colors that already meet contrast ratio',
(tester) async {
late Color adjustedColor;
const darkBackground = Color(0xFF121212);
const goodContrastColor = Color(0xFFFFFFFF); // White on dark = good contrast
await tester.pumpWidget(
buildTestWidget(
scaffoldBackground: darkBackground,
child: Builder(
builder: (context) {
adjustedColor = adjustChatNameColor(context, goodContrastColor);
return const SizedBox();
},
),
),
);
// Color should be unchanged or very close to original
expect(adjustedColor, goodContrastColor);
});
testWidgets('uses custom target contrast ratio', (tester) async {
late Color adjusted45;
late Color adjusted30;
const darkBackground = Color(0xFF121212);
const greyColor = Color(0xFF555555);
await tester.pumpWidget(
buildTestWidget(
scaffoldBackground: darkBackground,
child: Builder(
builder: (context) {
adjusted45 = adjustChatNameColor(
context,
greyColor,
);
adjusted30 = adjustChatNameColor(
context,
greyColor,
targetContrast: 3.0,
);
return const SizedBox();
},
),
),
);
// Higher contrast requirement should result in a lighter color
final lum45 = adjusted45.computeLuminance();
final lum30 = adjusted30.computeLuminance();
expect(lum45, greaterThanOrEqualTo(lum30));
});
testWidgets('uses custom background color parameter', (tester) async {
late Color adjustedColor;
const customBackground = Color(0xFF00FF00); // Green background
await tester.pumpWidget(
buildTestWidget(
scaffoldBackground: Colors.black,
child: Builder(
builder: (context) {
// Pass custom background, ignoring theme
adjustedColor = adjustChatNameColor(
context,
const Color(0xFF00FF00), // Same as background
background: customBackground,
);
return const SizedBox();
},
),
),
);
// Color should be adjusted for contrast against green, not black
// Green on green needs adjustment
expect(adjustedColor, isNot(const Color(0xFF00FF00)));
});
testWidgets('memoized results are stable and keyed by background',
(tester) async {
late Color first;
late Color second;
late Color onLightBackground;
const darkBackground = Color(0xFF121212);
const lowContrastColor = Color(0xFF1F1F1F); // Near-black on dark bg
await tester.pumpWidget(
buildTestWidget(
scaffoldBackground: darkBackground,
child: Builder(
builder: (context) {
// Repeated calls with identical inputs must return the same
// adjusted color (second call is served from the memo cache).
first = adjustChatNameColor(context, lowContrastColor);
second = adjustChatNameColor(context, lowContrastColor);
// A different background must not be served the dark-background
// cache entry: near-black already passes on white, so it should
// come back unchanged.
onLightBackground = adjustChatNameColor(
context,
lowContrastColor,
background: const Color(0xFFFFFFFF),
);
return const SizedBox();
},
),
),
);
expect(second, first);
expect(
first.computeLuminance(),
greaterThan(lowContrastColor.computeLuminance()),
);
expect(onLightBackground, lowContrastColor);
});
testWidgets('handles transparent scaffold background', (tester) async {
late Color adjustedColor;
const darkColor = Color(0xFF000000);
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(
brightness: Brightness.dark,
scaffoldBackgroundColor: Colors.transparent,
colorScheme: const ColorScheme.dark(
surface: Color(0xFF1E1E1E),
),
),
home: Scaffold(
body: Builder(
builder: (context) {
adjustedColor = adjustChatNameColor(context, darkColor);
return const SizedBox();
},
),
),
),
);
// Should use colorScheme.surface as fallback
// Dark color on dark surface should be adjusted
expect(adjustedColor.computeLuminance(), greaterThan(0.0));
});
testWidgets('maintains hue while adjusting lightness', (tester) async {
late Color adjustedColor;
const darkBackground = Color(0xFF121212);
const blueColor = Color(0xFF0000AA); // Dark blue
await tester.pumpWidget(
buildTestWidget(
scaffoldBackground: darkBackground,
child: Builder(
builder: (context) {
adjustedColor = adjustChatNameColor(context, blueColor);
return const SizedBox();
},
),
),
);
// Should still be recognizably blue (hue preserved)
final originalHsl = HSLColor.fromColor(blueColor);
final adjustedHsl = HSLColor.fromColor(adjustedColor);
// Hue should be the same or very close
expect(adjustedHsl.hue, closeTo(originalHsl.hue, 1.0));
});
});
group('Color contrast calculation (indirect tests)', () {
// These tests verify the contrast behavior indirectly through adjustChatNameColor
testWidgets('common Twitch colors work on dark background', (tester) async {
final twitchColors = [
const Color(0xFFFF0000), // Red
const Color(0xFF0000FF), // Blue
const Color(0xFF00FF00), // Green
const Color(0xFFB22222), // Firebrick
const Color(0xFFFF69B4), // Hot pink
const Color(0xFFDAA520), // Golden rod
];
for (final color in twitchColors) {
late Color adjusted;
await tester.pumpWidget(
MaterialApp(
theme: ThemeData.dark().copyWith(
scaffoldBackgroundColor: const Color(0xFF121212),
),
home: Scaffold(
body: Builder(
builder: (context) {
adjusted = adjustChatNameColor(context, color);
return const SizedBox();
},
),
),
),
);
// All adjusted colors should have reasonable luminance
expect(
adjusted.computeLuminance(),
greaterThan(0.05),
reason: 'Color $color should be readable',
);
}
});
testWidgets('adjusted color meets WCAG AA 4.5:1 contrast ratio',
(tester) async {
late Color adjustedColor;
const darkBackground = Color(0xFF121212);
const darkBlue = Color(0xFF0000AA);
await tester.pumpWidget(
MaterialApp(
theme: ThemeData.dark().copyWith(
scaffoldBackgroundColor: darkBackground,
),
home: Scaffold(
body: Builder(
builder: (context) {
adjustedColor = adjustChatNameColor(context, darkBlue);
return const SizedBox();
},
),
),
),
);
// Compute WCAG contrast ratio manually
double linearize(double c) => c <= 0.03928
? c / 12.92
: math.pow((c + 0.055) / 1.055, 2.4).toDouble();
double luminance(Color c) {
return 0.2126 * linearize(c.r) +
0.7152 * linearize(c.g) +
0.0722 * linearize(c.b);
}
final l1 = luminance(adjustedColor);
final l2 = luminance(darkBackground);
final hi = l1 > l2 ? l1 : l2;
final lo = l1 > l2 ? l2 : l1;
final ratio = (hi + 0.05) / (lo + 0.05);
expect(ratio, greaterThanOrEqualTo(4.5),
reason: 'Adjusted color should meet WCAG AA 4.5:1 contrast ratio');
});
});
}