fix: eliminate cross-app consistency drift
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:logger/logger.dart';
|
||||
import 'package:plezy/exceptions/media_server_exceptions.dart';
|
||||
import 'package:plezy/i18n/strings.g.dart';
|
||||
import 'package:plezy/utils/app_logger.dart' as logging;
|
||||
import 'package:plezy/utils/error_message_utils.dart';
|
||||
|
||||
void main() {
|
||||
late Logger originalLogger;
|
||||
late _RecordingLogOutput logOutput;
|
||||
|
||||
setUpAll(() async => LocaleSettings.setLocale(AppLocale.es));
|
||||
tearDownAll(() => LocaleSettings.setLocaleSync(AppLocale.en));
|
||||
|
||||
setUp(() {
|
||||
originalLogger = logging.appLogger;
|
||||
logOutput = _RecordingLogOutput();
|
||||
logging.appLogger = Logger(printer: SimplePrinter(), output: logOutput);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
logging.appLogger = originalLogger;
|
||||
});
|
||||
|
||||
group('localizedLoadErrorMessage', () {
|
||||
test('distinguishes connection timeouts from connection failures', () {
|
||||
for (final type in [MediaServerHttpErrorType.connectionTimeout, MediaServerHttpErrorType.receiveTimeout]) {
|
||||
final message = localizedLoadErrorMessage(
|
||||
MediaServerHttpException(type: type, message: 'private timeout detail'),
|
||||
StackTrace.current,
|
||||
context: 'Biblioteca',
|
||||
);
|
||||
|
||||
expect(message, 'Tiempo de conexión agotado al cargar Biblioteca');
|
||||
expect(message, isNot(contains('private timeout detail')));
|
||||
}
|
||||
|
||||
final connectionMessage = localizedLoadErrorMessage(
|
||||
MediaServerHttpException(type: MediaServerHttpErrorType.connectionError, message: 'private connection detail'),
|
||||
StackTrace.current,
|
||||
context: 'Biblioteca',
|
||||
);
|
||||
|
||||
expect(connectionMessage, 'No se puede conectar al servidor multimedia');
|
||||
expect(connectionMessage, isNot(contains('private connection detail')));
|
||||
});
|
||||
|
||||
test('logs an unexpected failure once and returns only the localized fallback', () {
|
||||
const privateDetail = 'token=super-secret';
|
||||
|
||||
final message = localizedLoadErrorMessage(StateError(privateDetail), StackTrace.current, context: 'Biblioteca');
|
||||
|
||||
expect(message, 'No se pudo cargar Biblioteca. Inténtalo de nuevo.');
|
||||
expect(message, isNot(contains(privateDetail)));
|
||||
expect(logOutput.writeCount, 1);
|
||||
});
|
||||
|
||||
test('never exposes server status text, response data, or exception messages', () {
|
||||
const serverText = 'database failed for user@example.com';
|
||||
final error = MediaServerHttpException(
|
||||
type: MediaServerHttpErrorType.unknown,
|
||||
statusCode: 500,
|
||||
message: serverText,
|
||||
responseData: {'error': serverText},
|
||||
);
|
||||
|
||||
final message = localizedLoadErrorMessage(error, StackTrace.current, context: 'Biblioteca');
|
||||
|
||||
expect(message, 'No se pudo cargar Biblioteca. Inténtalo de nuevo.');
|
||||
expect(message, isNot(contains('500')));
|
||||
expect(message, isNot(contains(serverText)));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
class _RecordingLogOutput extends LogOutput {
|
||||
int writeCount = 0;
|
||||
|
||||
@override
|
||||
void output(OutputEvent event) {
|
||||
writeCount++;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,15 @@
|
||||
import 'package:cached_network_image_ce/cached_network_image.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/media/media_item.dart';
|
||||
import 'package:plezy/media/media_kind.dart';
|
||||
import 'package:plezy/media/media_server_client.dart';
|
||||
import 'package:plezy/services/device_performance.dart';
|
||||
import 'package:plezy/services/settings_service.dart';
|
||||
import 'package:plezy/utils/media_image_helper.dart';
|
||||
|
||||
import '../test_helpers/media_items.dart';
|
||||
|
||||
/// Only [thumbnailUrl] is exercised; everything else throws via noSuchMethod.
|
||||
class _SizedUrlFakeClient implements MediaServerClient {
|
||||
@override
|
||||
@@ -14,6 +20,8 @@ class _SizedUrlFakeClient implements MediaServerClient {
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
MediaItem _item(MediaKind kind) => testMediaItem(kind: kind);
|
||||
|
||||
void main() {
|
||||
group('MediaImageHelper.getOptimizedImageUrl', () {
|
||||
test('adds size hints to absolute Jellyfin artwork URLs', () {
|
||||
@@ -134,6 +142,78 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('MediaImageHelper image type budgets', () {
|
||||
tearDown(DevicePerformance.debugReset);
|
||||
|
||||
test('hero logos get a larger budget without changing ordinary logos', () {
|
||||
DevicePerformance.debugReset(autoReduced: false, override: VisualEffectsSetting.auto);
|
||||
|
||||
expect(
|
||||
MediaImageHelper.getMemCacheDimensions(displayWidth: 4000, displayHeight: 4000, imageType: ImageType.logo),
|
||||
(600, 300),
|
||||
);
|
||||
expect(
|
||||
MediaImageHelper.getMemCacheDimensions(displayWidth: 4000, displayHeight: 4000, imageType: ImageType.heroLogo),
|
||||
(1000, 500),
|
||||
);
|
||||
});
|
||||
|
||||
test('card artwork follows square, wide, and poster media shapes', () {
|
||||
for (final kind in [MediaKind.artist, MediaKind.album, MediaKind.track]) {
|
||||
expect(
|
||||
MediaImageHelper.cardImageType(_item(kind), EpisodePosterMode.episodeThumbnail),
|
||||
ImageType.square,
|
||||
reason: '${kind.id} artwork must keep its square music cache budget',
|
||||
);
|
||||
}
|
||||
|
||||
expect(
|
||||
MediaImageHelper.cardImageType(_item(MediaKind.episode), EpisodePosterMode.episodeThumbnail),
|
||||
ImageType.thumb,
|
||||
);
|
||||
expect(
|
||||
MediaImageHelper.cardImageType(_item(MediaKind.episode), EpisodePosterMode.seriesPoster),
|
||||
ImageType.poster,
|
||||
);
|
||||
expect(
|
||||
MediaImageHelper.cardImageType(_item(MediaKind.movie), EpisodePosterMode.episodeThumbnail),
|
||||
ImageType.poster,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('MediaImageHelper.serverArtworkProvider', () {
|
||||
test('reuses cache identity and disk key while bounding both decode axes', () {
|
||||
const url = 'https://example.invalid/library/poster.jpg';
|
||||
|
||||
final first = MediaImageHelper.serverArtworkProvider(imageUrl: url, memWidth: 640, memHeight: 360) as ResizeImage;
|
||||
final second =
|
||||
MediaImageHelper.serverArtworkProvider(imageUrl: url, memWidth: 640, memHeight: 360) as ResizeImage;
|
||||
final firstCached = first.imageProvider as CachedNetworkImageProvider;
|
||||
final secondCached = second.imageProvider as CachedNetworkImageProvider;
|
||||
|
||||
expect(firstCached, secondCached);
|
||||
expect(first.width, second.width);
|
||||
expect(first.width, 640);
|
||||
expect(first.height, second.height);
|
||||
expect(first.height, 360);
|
||||
expect(first.policy, second.policy);
|
||||
expect(first.policy, ResizeImagePolicy.fit);
|
||||
expect(first.allowUpscaling, second.allowUpscaling);
|
||||
expect(first.allowUpscaling, isFalse);
|
||||
expect(firstCached.url, secondCached.url);
|
||||
expect(firstCached.url, url);
|
||||
expect(firstCached.headers, secondCached.headers);
|
||||
expect(firstCached.headers, const {'User-Agent': 'Plezy'});
|
||||
expect(firstCached.cacheKey, secondCached.cacheKey);
|
||||
expect(firstCached.cacheKey, isNotNull);
|
||||
expect(firstCached.maxWidth, secondCached.maxWidth);
|
||||
expect(firstCached.maxWidth, isNull);
|
||||
expect(firstCached.maxHeight, secondCached.maxHeight);
|
||||
expect(firstCached.maxHeight, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('MediaImageHelper.boundedDecode', () {
|
||||
test('bounds both axes with fit policy (no distortion, no upscale)', () {
|
||||
const base = NetworkImage('https://example/img');
|
||||
|
||||
Reference in New Issue
Block a user