mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
refactor: test organisation and service test (#27991)
* refactor: test organisation # Conflicts: # mobile/test/unit/utils/editor_test.dart * regroup hash_service_test --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||
|
||||
import '../../utils.dart';
|
||||
|
||||
class LocalAlbumFactory {
|
||||
const LocalAlbumFactory();
|
||||
|
||||
static LocalAlbum create({
|
||||
String? id,
|
||||
String? name,
|
||||
DateTime? updatedAt,
|
||||
BackupSelection? backupSelection,
|
||||
bool? isIosSharedAlbum,
|
||||
String? linkedRemoteAlbumId,
|
||||
int? assetCount,
|
||||
}) {
|
||||
id = TestUtils.uuid(id);
|
||||
return LocalAlbum(
|
||||
id: id,
|
||||
name: name ?? 'local_album_$id',
|
||||
updatedAt: TestUtils.date(updatedAt),
|
||||
backupSelection: backupSelection ?? BackupSelection.none,
|
||||
isIosSharedAlbum: isIosSharedAlbum ?? false,
|
||||
linkedRemoteAlbumId: linkedRemoteAlbumId,
|
||||
assetCount: assetCount ?? 10,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
|
||||
import '../../utils.dart';
|
||||
|
||||
class LocalAssetFactory {
|
||||
const LocalAssetFactory();
|
||||
|
||||
static LocalAsset create({String? id, String? name}) {
|
||||
id = TestUtils.uuid(id);
|
||||
|
||||
return LocalAsset(
|
||||
id: id,
|
||||
name: name ?? 'local_$id.jpg',
|
||||
type: AssetType.image,
|
||||
createdAt: TestUtils.yesterday(),
|
||||
updatedAt: TestUtils.now(),
|
||||
playbackStyle: AssetPlaybackStyle.image,
|
||||
isEdited: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:mocktail/mocktail.dart' as mocktail;
|
||||
|
||||
import '../domain/service.mock.dart';
|
||||
import '../infrastructure/repository.mock.dart';
|
||||
|
||||
class UnitMocks {
|
||||
final localAlbum = MockLocalAlbumRepository();
|
||||
final localAsset = MockDriftLocalAssetRepository();
|
||||
final trashedAsset = MockTrashedLocalAssetRepository();
|
||||
|
||||
final nativeApi = MockNativeSyncApi();
|
||||
|
||||
UnitMocks() {
|
||||
mocktail.registerFallbackValue(LocalAlbum(id: '', name: '', updatedAt: DateTime.now()));
|
||||
mocktail.registerFallbackValue(
|
||||
LocalAsset(
|
||||
id: '',
|
||||
name: '',
|
||||
type: AssetType.image,
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
playbackStyle: AssetPlaybackStyle.image,
|
||||
isEdited: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void reset() {
|
||||
mocktail.reset(localAlbum);
|
||||
mocktail.reset(localAsset);
|
||||
mocktail.reset(trashedAsset);
|
||||
mocktail.reset(nativeApi);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||
import 'package:immich_mobile/domain/services/hash.service.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import '../factories/local_album_factory.dart';
|
||||
import '../factories/local_asset_factory.dart';
|
||||
import '../mocks.dart';
|
||||
|
||||
void main() {
|
||||
late HashService sut;
|
||||
final mocks = UnitMocks();
|
||||
|
||||
setUp(() {
|
||||
sut = HashService(
|
||||
localAlbumRepository: mocks.localAlbum,
|
||||
localAssetRepository: mocks.localAsset,
|
||||
nativeSyncApi: mocks.nativeApi,
|
||||
trashedLocalAssetRepository: mocks.trashedAsset,
|
||||
);
|
||||
|
||||
when(() => mocks.localAsset.reconcileHashesFromCloudId()).thenAnswer((_) async => {});
|
||||
when(() => mocks.localAsset.updateHashes(any())).thenAnswer((_) async => {});
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
mocks.reset();
|
||||
});
|
||||
|
||||
group('HashService', () {
|
||||
group('hashAssets', () {
|
||||
test('skips albums with no assets to hash', () async {
|
||||
final album = LocalAlbumFactory.create(assetCount: 0);
|
||||
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]);
|
||||
when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => []);
|
||||
|
||||
await sut.hashAssets();
|
||||
|
||||
verifyNever(() => mocks.nativeApi.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')));
|
||||
});
|
||||
|
||||
test('skips empty batches', () async {
|
||||
final album = LocalAlbumFactory.create();
|
||||
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]);
|
||||
when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => []);
|
||||
|
||||
await sut.hashAssets();
|
||||
|
||||
verifyNever(() => mocks.nativeApi.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')));
|
||||
});
|
||||
|
||||
test('processes assets when available', () async {
|
||||
final album = LocalAlbumFactory.create();
|
||||
final asset = LocalAssetFactory.create();
|
||||
final result = HashResult(assetId: asset.id, hash: 'test-hash');
|
||||
|
||||
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]);
|
||||
when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
|
||||
when(() => mocks.nativeApi.hashAssets([asset.id], allowNetworkAccess: false)).thenAnswer((_) async => [result]);
|
||||
|
||||
await sut.hashAssets();
|
||||
|
||||
verify(() => mocks.nativeApi.hashAssets([asset.id], allowNetworkAccess: false)).called(1);
|
||||
final captured =
|
||||
verify(() => mocks.localAsset.updateHashes(captureAny())).captured.first as Map<String, String>;
|
||||
expect(captured.length, 1);
|
||||
expect(captured[asset.id], result.hash);
|
||||
});
|
||||
|
||||
test('handles failed hashes', () async {
|
||||
final album = LocalAlbumFactory.create();
|
||||
final asset = LocalAssetFactory.create();
|
||||
|
||||
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]);
|
||||
when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
|
||||
when(
|
||||
() => mocks.nativeApi.hashAssets([asset.id], allowNetworkAccess: false),
|
||||
).thenAnswer((_) async => [HashResult(assetId: asset.id, error: 'Failed to hash')]);
|
||||
|
||||
await sut.hashAssets();
|
||||
|
||||
final captured =
|
||||
verify(() => mocks.localAsset.updateHashes(captureAny())).captured.first as Map<String, String>;
|
||||
expect(captured.length, 0);
|
||||
});
|
||||
|
||||
test('handles null hash results', () async {
|
||||
final album = LocalAlbumFactory.create();
|
||||
final asset = LocalAssetFactory.create();
|
||||
|
||||
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]);
|
||||
when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
|
||||
when(
|
||||
() => mocks.nativeApi.hashAssets([asset.id], allowNetworkAccess: false),
|
||||
).thenAnswer((_) async => [HashResult(assetId: asset.id, hash: null)]);
|
||||
|
||||
await sut.hashAssets();
|
||||
|
||||
final captured =
|
||||
verify(() => mocks.localAsset.updateHashes(captureAny())).captured.first as Map<String, String>;
|
||||
expect(captured.length, 0);
|
||||
});
|
||||
|
||||
test('batches by size limit', () async {
|
||||
const batchSize = 2;
|
||||
final sut = HashService(
|
||||
localAlbumRepository: mocks.localAlbum,
|
||||
localAssetRepository: mocks.localAsset,
|
||||
nativeSyncApi: mocks.nativeApi,
|
||||
batchSize: batchSize,
|
||||
trashedLocalAssetRepository: mocks.trashedAsset,
|
||||
);
|
||||
|
||||
final album = LocalAlbumFactory.create();
|
||||
final asset1 = LocalAssetFactory.create();
|
||||
final asset2 = LocalAssetFactory.create();
|
||||
final asset3 = LocalAssetFactory.create();
|
||||
|
||||
final capturedCalls = <List<String>>[];
|
||||
|
||||
when(() => mocks.localAsset.updateHashes(any())).thenAnswer((_) async => {});
|
||||
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]);
|
||||
when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => [asset1, asset2, asset3]);
|
||||
when(() => mocks.nativeApi.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess'))).thenAnswer((
|
||||
invocation,
|
||||
) async {
|
||||
final assetIds = invocation.positionalArguments[0] as List<String>;
|
||||
capturedCalls.add(List<String>.from(assetIds));
|
||||
return assetIds.map((id) => HashResult(assetId: id, hash: '$id-hash')).toList();
|
||||
});
|
||||
|
||||
await sut.hashAssets();
|
||||
|
||||
expect(capturedCalls.length, 2, reason: 'Should make exactly 2 calls to hashAssets');
|
||||
expect(capturedCalls[0], [asset1.id, asset2.id], reason: 'First call should batch the first two assets');
|
||||
expect(capturedCalls[1], [asset3.id], reason: 'Second call should have the remaining asset');
|
||||
|
||||
verify(() => mocks.localAsset.updateHashes(any())).called(2);
|
||||
});
|
||||
|
||||
test('handles mixed success and failure in batch', () async {
|
||||
final album = LocalAlbumFactory.create();
|
||||
final asset1 = LocalAssetFactory.create();
|
||||
final asset2 = LocalAssetFactory.create();
|
||||
|
||||
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]);
|
||||
when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => [asset1, asset2]);
|
||||
when(() => mocks.nativeApi.hashAssets([asset1.id, asset2.id], allowNetworkAccess: false)).thenAnswer(
|
||||
(_) async => [
|
||||
HashResult(assetId: asset1.id, hash: 'asset1-hash'),
|
||||
HashResult(assetId: asset2.id, error: 'Failed to hash asset2'),
|
||||
],
|
||||
);
|
||||
|
||||
await sut.hashAssets();
|
||||
|
||||
final captured =
|
||||
verify(() => mocks.localAsset.updateHashes(captureAny())).captured.first as Map<String, String>;
|
||||
expect(captured.length, 1);
|
||||
expect(captured[asset1.id], 'asset1-hash');
|
||||
});
|
||||
|
||||
test('uses allowNetworkAccess based on album backup selection', () async {
|
||||
final selectedAlbum = LocalAlbumFactory.create(backupSelection: BackupSelection.selected);
|
||||
final nonSelectedAlbum = LocalAlbumFactory.create(id: 'album2', backupSelection: BackupSelection.excluded);
|
||||
final asset1 = LocalAssetFactory.create();
|
||||
final asset2 = LocalAssetFactory.create();
|
||||
|
||||
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [selectedAlbum, nonSelectedAlbum]);
|
||||
when(() => mocks.localAlbum.getAssetsToHash(selectedAlbum.id)).thenAnswer((_) async => [asset1]);
|
||||
when(() => mocks.localAlbum.getAssetsToHash(nonSelectedAlbum.id)).thenAnswer((_) async => [asset2]);
|
||||
when(() => mocks.nativeApi.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess'))).thenAnswer((
|
||||
invocation,
|
||||
) async {
|
||||
final assetIds = invocation.positionalArguments[0] as List<String>;
|
||||
return assetIds.map((id) => HashResult(assetId: id, hash: '$id-hash')).toList();
|
||||
});
|
||||
|
||||
await sut.hashAssets();
|
||||
|
||||
verify(() => mocks.nativeApi.hashAssets([asset1.id], allowNetworkAccess: true)).called(1);
|
||||
verify(() => mocks.nativeApi.hashAssets([asset2.id], allowNetworkAccess: false)).called(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
|
||||
import 'package:immich_mobile/utils/editor.utils.dart';
|
||||
import 'package:openapi/api.dart' show MirrorAxis, MirrorParameters, RotateParameters;
|
||||
|
||||
List<AssetEdit> normalizedToEdits(NormalizedTransform transform) {
|
||||
List<AssetEdit> edits = [];
|
||||
|
||||
if (transform.mirrorHorizontal) {
|
||||
edits.add(MirrorEdit(MirrorParameters(axis: MirrorAxis.horizontal)));
|
||||
}
|
||||
|
||||
if (transform.mirrorVertical) {
|
||||
edits.add(MirrorEdit(MirrorParameters(axis: MirrorAxis.vertical)));
|
||||
}
|
||||
|
||||
if (transform.rotation != 0) {
|
||||
edits.add(RotateEdit(RotateParameters(angle: transform.rotation)));
|
||||
}
|
||||
|
||||
return edits;
|
||||
}
|
||||
|
||||
bool compareEditAffines(List<AssetEdit> editsA, List<AssetEdit> editsB) {
|
||||
final normA = buildAffineFromEdits(editsA);
|
||||
final normB = buildAffineFromEdits(editsB);
|
||||
|
||||
return ((normA.a - normB.a).abs() < 0.0001 &&
|
||||
(normA.b - normB.b).abs() < 0.0001 &&
|
||||
(normA.c - normB.c).abs() < 0.0001 &&
|
||||
(normA.d - normB.d).abs() < 0.0001);
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('normalizeEdits', () {
|
||||
test('should handle no edits', () {
|
||||
final edits = <AssetEdit>[];
|
||||
|
||||
final result = normalizeTransformEdits(edits);
|
||||
final normalizedEdits = normalizedToEdits(result);
|
||||
|
||||
expect(compareEditAffines(normalizedEdits, edits), true);
|
||||
});
|
||||
|
||||
test('should handle a single 90° rotation', () {
|
||||
final edits = <AssetEdit>[
|
||||
RotateEdit(RotateParameters(angle: 90)),
|
||||
];
|
||||
|
||||
final result = normalizeTransformEdits(edits);
|
||||
final normalizedEdits = normalizedToEdits(result);
|
||||
|
||||
expect(compareEditAffines(normalizedEdits, edits), true);
|
||||
});
|
||||
|
||||
test('should handle a single 180° rotation', () {
|
||||
final edits = <AssetEdit>[
|
||||
RotateEdit(RotateParameters(angle: 180)),
|
||||
];
|
||||
|
||||
final result = normalizeTransformEdits(edits);
|
||||
final normalizedEdits = normalizedToEdits(result);
|
||||
|
||||
expect(compareEditAffines(normalizedEdits, edits), true);
|
||||
});
|
||||
|
||||
test('should handle a single 270° rotation', () {
|
||||
final edits = <AssetEdit>[
|
||||
RotateEdit(RotateParameters(angle: 270)),
|
||||
];
|
||||
|
||||
final result = normalizeTransformEdits(edits);
|
||||
final normalizedEdits = normalizedToEdits(result);
|
||||
|
||||
expect(compareEditAffines(normalizedEdits, edits), true);
|
||||
});
|
||||
|
||||
test('should handle a single horizontal mirror', () {
|
||||
final edits = <AssetEdit>[
|
||||
MirrorEdit(MirrorParameters(axis: MirrorAxis.horizontal)),
|
||||
];
|
||||
|
||||
final result = normalizeTransformEdits(edits);
|
||||
final normalizedEdits = normalizedToEdits(result);
|
||||
|
||||
expect(compareEditAffines(normalizedEdits, edits), true);
|
||||
});
|
||||
|
||||
test('should handle a single vertical mirror', () {
|
||||
final edits = <AssetEdit>[
|
||||
MirrorEdit(MirrorParameters(axis: MirrorAxis.vertical)),
|
||||
];
|
||||
|
||||
final result = normalizeTransformEdits(edits);
|
||||
final normalizedEdits = normalizedToEdits(result);
|
||||
|
||||
expect(compareEditAffines(normalizedEdits, edits), true);
|
||||
});
|
||||
|
||||
test('should handle 90° rotation + horizontal mirror', () {
|
||||
final edits = <AssetEdit>[
|
||||
RotateEdit(RotateParameters(angle: 90)),
|
||||
MirrorEdit(MirrorParameters(axis: MirrorAxis.horizontal)),
|
||||
];
|
||||
|
||||
final result = normalizeTransformEdits(edits);
|
||||
final normalizedEdits = normalizedToEdits(result);
|
||||
|
||||
expect(compareEditAffines(normalizedEdits, edits), true);
|
||||
});
|
||||
|
||||
test('should handle 90° rotation + vertical mirror', () {
|
||||
final edits = <AssetEdit>[
|
||||
RotateEdit(RotateParameters(angle: 90)),
|
||||
MirrorEdit(MirrorParameters(axis: MirrorAxis.vertical)),
|
||||
];
|
||||
|
||||
final result = normalizeTransformEdits(edits);
|
||||
final normalizedEdits = normalizedToEdits(result);
|
||||
|
||||
expect(compareEditAffines(normalizedEdits, edits), true);
|
||||
});
|
||||
|
||||
test('should handle 90° rotation + both mirrors', () {
|
||||
final edits = <AssetEdit>[
|
||||
RotateEdit(RotateParameters(angle: 90)),
|
||||
MirrorEdit(MirrorParameters(axis: MirrorAxis.horizontal)),
|
||||
MirrorEdit(MirrorParameters(axis: MirrorAxis.vertical)),
|
||||
];
|
||||
|
||||
final result = normalizeTransformEdits(edits);
|
||||
final normalizedEdits = normalizedToEdits(result);
|
||||
|
||||
expect(compareEditAffines(normalizedEdits, edits), true);
|
||||
});
|
||||
|
||||
test('should handle 180° rotation + horizontal mirror', () {
|
||||
final edits = <AssetEdit>[
|
||||
RotateEdit(RotateParameters(angle: 180)),
|
||||
MirrorEdit(MirrorParameters(axis: MirrorAxis.horizontal)),
|
||||
];
|
||||
|
||||
final result = normalizeTransformEdits(edits);
|
||||
final normalizedEdits = normalizedToEdits(result);
|
||||
|
||||
expect(compareEditAffines(normalizedEdits, edits), true);
|
||||
});
|
||||
|
||||
test('should handle 180° rotation + vertical mirror', () {
|
||||
final edits = <AssetEdit>[
|
||||
RotateEdit(RotateParameters(angle: 180)),
|
||||
MirrorEdit(MirrorParameters(axis: MirrorAxis.vertical)),
|
||||
];
|
||||
|
||||
final result = normalizeTransformEdits(edits);
|
||||
final normalizedEdits = normalizedToEdits(result);
|
||||
|
||||
expect(compareEditAffines(normalizedEdits, edits), true);
|
||||
});
|
||||
|
||||
test('should handle 180° rotation + both mirrors', () {
|
||||
final edits = <AssetEdit>[
|
||||
RotateEdit(RotateParameters(angle: 180)),
|
||||
MirrorEdit(MirrorParameters(axis: MirrorAxis.horizontal)),
|
||||
MirrorEdit(MirrorParameters(axis: MirrorAxis.vertical)),
|
||||
];
|
||||
|
||||
final result = normalizeTransformEdits(edits);
|
||||
final normalizedEdits = normalizedToEdits(result);
|
||||
|
||||
expect(compareEditAffines(normalizedEdits, edits), true);
|
||||
});
|
||||
|
||||
test('should handle 270° rotation + horizontal mirror', () {
|
||||
final edits = <AssetEdit>[
|
||||
RotateEdit(RotateParameters(angle: 270)),
|
||||
MirrorEdit(MirrorParameters(axis: MirrorAxis.horizontal)),
|
||||
];
|
||||
|
||||
final result = normalizeTransformEdits(edits);
|
||||
final normalizedEdits = normalizedToEdits(result);
|
||||
|
||||
expect(compareEditAffines(normalizedEdits, edits), true);
|
||||
});
|
||||
|
||||
test('should handle 270° rotation + vertical mirror', () {
|
||||
final edits = <AssetEdit>[
|
||||
RotateEdit(RotateParameters(angle: 270)),
|
||||
MirrorEdit(MirrorParameters(axis: MirrorAxis.vertical)),
|
||||
];
|
||||
|
||||
final result = normalizeTransformEdits(edits);
|
||||
final normalizedEdits = normalizedToEdits(result);
|
||||
|
||||
expect(compareEditAffines(normalizedEdits, edits), true);
|
||||
});
|
||||
|
||||
test('should handle 270° rotation + both mirrors', () {
|
||||
final edits = <AssetEdit>[
|
||||
RotateEdit(RotateParameters(angle: 270)),
|
||||
MirrorEdit(MirrorParameters(axis: MirrorAxis.horizontal)),
|
||||
MirrorEdit(MirrorParameters(axis: MirrorAxis.vertical)),
|
||||
];
|
||||
|
||||
final result = normalizeTransformEdits(edits);
|
||||
final normalizedEdits = normalizedToEdits(result);
|
||||
|
||||
expect(compareEditAffines(normalizedEdits, edits), true);
|
||||
});
|
||||
|
||||
test('should handle horizontal mirror + 90° rotation', () {
|
||||
final edits = <AssetEdit>[
|
||||
MirrorEdit(MirrorParameters(axis: MirrorAxis.horizontal)),
|
||||
RotateEdit(RotateParameters(angle: 90)),
|
||||
];
|
||||
|
||||
final result = normalizeTransformEdits(edits);
|
||||
final normalizedEdits = normalizedToEdits(result);
|
||||
|
||||
expect(compareEditAffines(normalizedEdits, edits), true);
|
||||
});
|
||||
|
||||
test('should handle horizontal mirror + 180° rotation', () {
|
||||
final edits = <AssetEdit>[
|
||||
MirrorEdit(MirrorParameters(axis: MirrorAxis.horizontal)),
|
||||
RotateEdit(RotateParameters(angle: 180)),
|
||||
];
|
||||
|
||||
final result = normalizeTransformEdits(edits);
|
||||
final normalizedEdits = normalizedToEdits(result);
|
||||
|
||||
expect(compareEditAffines(normalizedEdits, edits), true);
|
||||
});
|
||||
|
||||
test('should handle horizontal mirror + 270° rotation', () {
|
||||
final edits = <AssetEdit>[
|
||||
MirrorEdit(MirrorParameters(axis: MirrorAxis.horizontal)),
|
||||
RotateEdit(RotateParameters(angle: 270)),
|
||||
];
|
||||
|
||||
final result = normalizeTransformEdits(edits);
|
||||
final normalizedEdits = normalizedToEdits(result);
|
||||
|
||||
expect(compareEditAffines(normalizedEdits, edits), true);
|
||||
});
|
||||
|
||||
test('should handle vertical mirror + 90° rotation', () {
|
||||
final edits = <AssetEdit>[
|
||||
MirrorEdit(MirrorParameters(axis: MirrorAxis.vertical)),
|
||||
RotateEdit(RotateParameters(angle: 90)),
|
||||
];
|
||||
|
||||
final result = normalizeTransformEdits(edits);
|
||||
final normalizedEdits = normalizedToEdits(result);
|
||||
|
||||
expect(compareEditAffines(normalizedEdits, edits), true);
|
||||
});
|
||||
|
||||
test('should handle vertical mirror + 180° rotation', () {
|
||||
final edits = <AssetEdit>[
|
||||
MirrorEdit(MirrorParameters(axis: MirrorAxis.vertical)),
|
||||
RotateEdit(RotateParameters(angle: 180)),
|
||||
];
|
||||
|
||||
final result = normalizeTransformEdits(edits);
|
||||
final normalizedEdits = normalizedToEdits(result);
|
||||
|
||||
expect(compareEditAffines(normalizedEdits, edits), true);
|
||||
});
|
||||
|
||||
test('should handle vertical mirror + 270° rotation', () {
|
||||
final edits = <AssetEdit>[
|
||||
MirrorEdit(MirrorParameters(axis: MirrorAxis.vertical)),
|
||||
RotateEdit(RotateParameters(angle: 270)),
|
||||
];
|
||||
|
||||
final result = normalizeTransformEdits(edits);
|
||||
final normalizedEdits = normalizedToEdits(result);
|
||||
|
||||
expect(compareEditAffines(normalizedEdits, edits), true);
|
||||
});
|
||||
|
||||
test('should handle both mirrors + 90° rotation', () {
|
||||
final edits = <AssetEdit>[
|
||||
MirrorEdit(MirrorParameters(axis: MirrorAxis.horizontal)),
|
||||
MirrorEdit(MirrorParameters(axis: MirrorAxis.vertical)),
|
||||
RotateEdit(RotateParameters(angle: 90)),
|
||||
];
|
||||
|
||||
final result = normalizeTransformEdits(edits);
|
||||
final normalizedEdits = normalizedToEdits(result);
|
||||
|
||||
expect(compareEditAffines(normalizedEdits, edits), true);
|
||||
});
|
||||
|
||||
test('should handle both mirrors + 180° rotation', () {
|
||||
final edits = <AssetEdit>[
|
||||
MirrorEdit(MirrorParameters(axis: MirrorAxis.horizontal)),
|
||||
MirrorEdit(MirrorParameters(axis: MirrorAxis.vertical)),
|
||||
RotateEdit(RotateParameters(angle: 180)),
|
||||
];
|
||||
|
||||
final result = normalizeTransformEdits(edits);
|
||||
final normalizedEdits = normalizedToEdits(result);
|
||||
|
||||
expect(compareEditAffines(normalizedEdits, edits), true);
|
||||
});
|
||||
|
||||
test('should handle both mirrors + 270° rotation', () {
|
||||
final edits = <AssetEdit>[
|
||||
MirrorEdit(MirrorParameters(axis: MirrorAxis.horizontal)),
|
||||
MirrorEdit(MirrorParameters(axis: MirrorAxis.vertical)),
|
||||
RotateEdit(RotateParameters(angle: 270)),
|
||||
];
|
||||
|
||||
final result = normalizeTransformEdits(edits);
|
||||
final normalizedEdits = normalizedToEdits(result);
|
||||
|
||||
expect(compareEditAffines(normalizedEdits, edits), true);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/utils/option.dart';
|
||||
|
||||
void main() {
|
||||
group('Option', () {
|
||||
group('constructors', () {
|
||||
test('Option.some creates a Some instance', () {
|
||||
const option = Option.some(42);
|
||||
expect(option, isA<Some<int>>());
|
||||
expect((option as Some).value, 42);
|
||||
});
|
||||
|
||||
test('Option.none creates a None instance', () {
|
||||
const option = Option<int>.none();
|
||||
expect(option, isA<None<int>>());
|
||||
});
|
||||
|
||||
test('Option.fromNullable returns Some for non-null value', () {
|
||||
final option = Option.fromNullable('hello');
|
||||
expect(option, isA<Some<String>>());
|
||||
expect((option as Some).value, 'hello');
|
||||
});
|
||||
|
||||
test('Option.fromNullable returns None for null value', () {
|
||||
final option = Option.fromNullable(null);
|
||||
expect(option, isA<None>());
|
||||
});
|
||||
});
|
||||
|
||||
group('isSome / isNone', () {
|
||||
test('Some.isSome is true', () {
|
||||
expect(const Option.some(1).isSome, isTrue);
|
||||
});
|
||||
|
||||
test('Some.isNone is false', () {
|
||||
expect(const Option.some(1).isNone, isFalse);
|
||||
});
|
||||
|
||||
test('None.isSome is false', () {
|
||||
expect(const Option.none().isSome, isFalse);
|
||||
});
|
||||
|
||||
test('None.isNone is true', () {
|
||||
expect(const Option.none().isNone, isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('unwrapOrNull', () {
|
||||
test('returns value for Some', () {
|
||||
expect(const Option.some('hi').unwrapOrNull, 'hi');
|
||||
});
|
||||
|
||||
test('returns null for None', () {
|
||||
expect(const Option.none().unwrapOrNull, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('fold', () {
|
||||
test('calls onSome with value for Some', () {
|
||||
final result = const Option.some('world').fold((v) => 'some: $v', () => 'none');
|
||||
expect(result, 'some: world');
|
||||
});
|
||||
|
||||
test('calls onNone for None', () {
|
||||
final result = const Option.none().fold((v) => 'some: $v', () => 'none');
|
||||
expect(result, 'none');
|
||||
});
|
||||
});
|
||||
|
||||
group('equality', () {
|
||||
test('Some equals Some with same value', () {
|
||||
expect(const Option.some(1) == const Option.some(1), isTrue);
|
||||
});
|
||||
|
||||
test('Some does not equal Some with different value', () {
|
||||
expect(const Option.some(1) == const Option.some(2), isFalse);
|
||||
});
|
||||
|
||||
test('None equals None of same type', () {
|
||||
expect(const Option<int>.none() == const Option<int>.none(), isTrue);
|
||||
});
|
||||
|
||||
test('None does not equal None of different type', () {
|
||||
expect(const Option<int>.none() == (const Option<String>.none() as Object), isFalse);
|
||||
});
|
||||
|
||||
test('Some does not equal None', () {
|
||||
expect(const Option.some(0) == const Option.none(), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('hashCode', () {
|
||||
test('Some hashCode equals value hashCode', () {
|
||||
expect(const Option.some('abc').hashCode, 'abc'.hashCode);
|
||||
});
|
||||
|
||||
test('None hashCode is 0', () {
|
||||
expect(const Option.none().hashCode, 0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group('ObjectOptionExtension', () {
|
||||
test('non-null value.toOption() returns Some', () {
|
||||
final option = 'hello'.toOption();
|
||||
expect(option, isA<Some<String>>());
|
||||
expect((option as Some).value, 'hello');
|
||||
});
|
||||
|
||||
test('null value.toOption() returns None', () {
|
||||
const String? value = null;
|
||||
final option = value.toOption();
|
||||
expect(option, isA<None<String>>());
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/utils/semver.dart';
|
||||
|
||||
void main() {
|
||||
group('SemVer', () {
|
||||
test('Parses valid semantic version strings correctly', () {
|
||||
final version = SemVer.fromString('1.2.3');
|
||||
expect(version.major, 1);
|
||||
expect(version.minor, 2);
|
||||
expect(version.patch, 3);
|
||||
});
|
||||
|
||||
test('Throws FormatException for invalid version strings', () {
|
||||
expect(() => SemVer.fromString('1.2'), throwsFormatException);
|
||||
expect(() => SemVer.fromString('a.b.c'), throwsFormatException);
|
||||
expect(() => SemVer.fromString('1.2.3.4'), throwsFormatException);
|
||||
});
|
||||
|
||||
test('Compares equal versons correctly', () {
|
||||
final v1 = SemVer.fromString('1.2.3');
|
||||
final v2 = SemVer.fromString('1.2.3');
|
||||
expect(v1 == v2, isTrue);
|
||||
expect(v1 > v2, isFalse);
|
||||
expect(v1 < v2, isFalse);
|
||||
});
|
||||
|
||||
test('Compares major version correctly', () {
|
||||
final v1 = SemVer.fromString('2.0.0');
|
||||
final v2 = SemVer.fromString('1.9.9');
|
||||
expect(v1 == v2, isFalse);
|
||||
expect(v1 > v2, isTrue);
|
||||
expect(v1 < v2, isFalse);
|
||||
});
|
||||
|
||||
test('Compares minor version correctly', () {
|
||||
final v1 = SemVer.fromString('1.3.0');
|
||||
final v2 = SemVer.fromString('1.2.9');
|
||||
expect(v1 == v2, isFalse);
|
||||
expect(v1 > v2, isTrue);
|
||||
expect(v1 < v2, isFalse);
|
||||
});
|
||||
|
||||
test('Compares patch version correctly', () {
|
||||
final v1 = SemVer.fromString('1.2.4');
|
||||
final v2 = SemVer.fromString('1.2.3');
|
||||
expect(v1 == v2, isFalse);
|
||||
expect(v1 > v2, isTrue);
|
||||
expect(v1 < v2, isFalse);
|
||||
});
|
||||
|
||||
test('Gives correct major difference type', () {
|
||||
final v1 = SemVer.fromString('2.0.0');
|
||||
final v2 = SemVer.fromString('1.9.9');
|
||||
expect(v1.differenceType(v2), SemVerType.major);
|
||||
});
|
||||
|
||||
test('Gives correct minor difference type', () {
|
||||
final v1 = SemVer.fromString('1.3.0');
|
||||
final v2 = SemVer.fromString('1.2.9');
|
||||
expect(v1.differenceType(v2), SemVerType.minor);
|
||||
});
|
||||
|
||||
test('Gives correct patch difference type', () {
|
||||
final v1 = SemVer.fromString('1.2.4');
|
||||
final v2 = SemVer.fromString('1.2.3');
|
||||
expect(v1.differenceType(v2), SemVerType.patch);
|
||||
});
|
||||
|
||||
test('Gives null difference type for equal versions', () {
|
||||
final v1 = SemVer.fromString('1.2.3');
|
||||
final v2 = SemVer.fromString('1.2.3');
|
||||
expect(v1.differenceType(v2), isNull);
|
||||
});
|
||||
|
||||
test('toString returns correct format', () {
|
||||
final version = SemVer.fromString('1.2.3');
|
||||
expect(version.toString(), '1.2.3');
|
||||
});
|
||||
|
||||
test('Parses versions with leading v correctly', () {
|
||||
final version1 = SemVer.fromString('v1.2.3');
|
||||
expect(version1.major, 1);
|
||||
expect(version1.minor, 2);
|
||||
expect(version1.patch, 3);
|
||||
|
||||
final version2 = SemVer.fromString('V1.2.3');
|
||||
expect(version2.major, 1);
|
||||
expect(version2.minor, 2);
|
||||
expect(version2.patch, 3);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/utils/timezone.dart';
|
||||
import 'package:timezone/data/latest.dart' as tz;
|
||||
|
||||
void main() {
|
||||
setUpAll(() {
|
||||
tz.initializeTimeZones();
|
||||
});
|
||||
|
||||
group('applyTimezoneOffset', () {
|
||||
group('with named timezone locations', () {
|
||||
test('should convert UTC to Asia/Hong_Kong (+08:00)', () {
|
||||
final utcTime = DateTime.utc(2024, 6, 15, 12, 0, 0);
|
||||
|
||||
final (adjustedTime, offset) = applyTimezoneOffset(
|
||||
dateTime: utcTime,
|
||||
timeZone: 'Asia/Hong_Kong',
|
||||
);
|
||||
|
||||
expect(adjustedTime.hour, 20); // 12:00 UTC + 8 hours = 20:00
|
||||
expect(offset, const Duration(hours: 8));
|
||||
});
|
||||
|
||||
test('should convert UTC to America/New_York (handles DST)', () {
|
||||
// Summer time (EDT = UTC-4)
|
||||
final summerUtc = DateTime.utc(2024, 6, 15, 12, 0, 0);
|
||||
final (summerTime, summerOffset) = applyTimezoneOffset(
|
||||
dateTime: summerUtc,
|
||||
timeZone: 'America/New_York',
|
||||
);
|
||||
|
||||
expect(summerTime.hour, 8); // 12:00 UTC - 4 hours = 08:00
|
||||
expect(summerOffset, const Duration(hours: -4));
|
||||
|
||||
// Winter time (EST = UTC-5)
|
||||
final winterUtc = DateTime.utc(2024, 1, 15, 12, 0, 0);
|
||||
final (winterTime, winterOffset) = applyTimezoneOffset(
|
||||
dateTime: winterUtc,
|
||||
timeZone: 'America/New_York',
|
||||
);
|
||||
|
||||
expect(winterTime.hour, 7); // 12:00 UTC - 5 hours = 07:00
|
||||
expect(winterOffset, const Duration(hours: -5));
|
||||
});
|
||||
|
||||
test('should convert UTC to Europe/London', () {
|
||||
// Winter (GMT = UTC+0)
|
||||
final winterUtc = DateTime.utc(2024, 1, 15, 12, 0, 0);
|
||||
final (winterTime, winterOffset) = applyTimezoneOffset(
|
||||
dateTime: winterUtc,
|
||||
timeZone: 'Europe/London',
|
||||
);
|
||||
|
||||
expect(winterTime.hour, 12);
|
||||
expect(winterOffset, Duration.zero);
|
||||
|
||||
// Summer (BST = UTC+1)
|
||||
final summerUtc = DateTime.utc(2024, 6, 15, 12, 0, 0);
|
||||
final (summerTime, summerOffset) = applyTimezoneOffset(
|
||||
dateTime: summerUtc,
|
||||
timeZone: 'Europe/London',
|
||||
);
|
||||
|
||||
expect(summerTime.hour, 13);
|
||||
expect(summerOffset, const Duration(hours: 1));
|
||||
});
|
||||
|
||||
test('should handle timezone with 30-minute offset (Asia/Kolkata)', () {
|
||||
final utcTime = DateTime.utc(2024, 6, 15, 12, 0, 0);
|
||||
|
||||
final (adjustedTime, offset) = applyTimezoneOffset(
|
||||
dateTime: utcTime,
|
||||
timeZone: 'Asia/Kolkata',
|
||||
);
|
||||
|
||||
expect(adjustedTime.hour, 17);
|
||||
expect(adjustedTime.minute, 30); // 12:00 UTC + 5:30 = 17:30
|
||||
expect(offset, const Duration(hours: 5, minutes: 30));
|
||||
});
|
||||
|
||||
test('should handle timezone with 45-minute offset (Asia/Kathmandu)', () {
|
||||
final utcTime = DateTime.utc(2024, 6, 15, 12, 0, 0);
|
||||
|
||||
final (adjustedTime, offset) = applyTimezoneOffset(
|
||||
dateTime: utcTime,
|
||||
timeZone: 'Asia/Kathmandu',
|
||||
);
|
||||
|
||||
expect(adjustedTime.hour, 17);
|
||||
expect(adjustedTime.minute, 45); // 12:00 UTC + 5:45 = 17:45
|
||||
expect(offset, const Duration(hours: 5, minutes: 45));
|
||||
});
|
||||
});
|
||||
|
||||
group('with UTC offset format', () {
|
||||
test('should handle UTC+08:00 format', () {
|
||||
final utcTime = DateTime.utc(2024, 6, 15, 12, 0, 0);
|
||||
|
||||
final (adjustedTime, offset) = applyTimezoneOffset(
|
||||
dateTime: utcTime,
|
||||
timeZone: 'UTC+08:00',
|
||||
);
|
||||
|
||||
expect(adjustedTime.hour, 20);
|
||||
expect(offset, const Duration(hours: 8));
|
||||
});
|
||||
|
||||
test('should handle UTC-05:00 format', () {
|
||||
final utcTime = DateTime.utc(2024, 6, 15, 12, 0, 0);
|
||||
|
||||
final (adjustedTime, offset) = applyTimezoneOffset(
|
||||
dateTime: utcTime,
|
||||
timeZone: 'UTC-05:00',
|
||||
);
|
||||
|
||||
expect(adjustedTime.hour, 7);
|
||||
expect(offset, const Duration(hours: -5));
|
||||
});
|
||||
|
||||
test('should handle UTC+8 format (without minutes)', () {
|
||||
final utcTime = DateTime.utc(2024, 6, 15, 12, 0, 0);
|
||||
|
||||
final (adjustedTime, offset) = applyTimezoneOffset(
|
||||
dateTime: utcTime,
|
||||
timeZone: 'UTC+8',
|
||||
);
|
||||
|
||||
expect(adjustedTime.hour, 20);
|
||||
expect(offset, const Duration(hours: 8));
|
||||
});
|
||||
|
||||
test('should handle UTC-5 format (without minutes)', () {
|
||||
final utcTime = DateTime.utc(2024, 6, 15, 12, 0, 0);
|
||||
|
||||
final (adjustedTime, offset) = applyTimezoneOffset(
|
||||
dateTime: utcTime,
|
||||
timeZone: 'UTC-5',
|
||||
);
|
||||
|
||||
expect(adjustedTime.hour, 7);
|
||||
expect(offset, const Duration(hours: -5));
|
||||
});
|
||||
|
||||
test('should handle plain UTC format', () {
|
||||
final utcTime = DateTime.utc(2024, 6, 15, 12, 0, 0);
|
||||
|
||||
final (adjustedTime, offset) = applyTimezoneOffset(
|
||||
dateTime: utcTime,
|
||||
timeZone: 'UTC',
|
||||
);
|
||||
|
||||
expect(adjustedTime.hour, 12);
|
||||
expect(offset, Duration.zero);
|
||||
});
|
||||
|
||||
test('should handle lowercase utc format', () {
|
||||
final utcTime = DateTime.utc(2024, 6, 15, 12, 0, 0);
|
||||
|
||||
final (adjustedTime, offset) = applyTimezoneOffset(
|
||||
dateTime: utcTime,
|
||||
timeZone: 'utc+08:00',
|
||||
);
|
||||
|
||||
expect(adjustedTime.hour, 20);
|
||||
expect(offset, const Duration(hours: 8));
|
||||
});
|
||||
|
||||
test('should handle UTC+05:30 format (with minutes)', () {
|
||||
final utcTime = DateTime.utc(2024, 6, 15, 12, 0, 0);
|
||||
|
||||
final (adjustedTime, offset) = applyTimezoneOffset(
|
||||
dateTime: utcTime,
|
||||
timeZone: 'UTC+05:30',
|
||||
);
|
||||
|
||||
expect(adjustedTime.hour, 17);
|
||||
expect(adjustedTime.minute, 30);
|
||||
expect(offset, const Duration(hours: 5, minutes: 30));
|
||||
});
|
||||
});
|
||||
|
||||
group('with null or invalid timezone', () {
|
||||
test('should return UTC time when timezone is null', () {
|
||||
final localTime = DateTime(2024, 6, 15, 12, 0, 0);
|
||||
|
||||
final (adjustedTime, offset) = applyTimezoneOffset(
|
||||
dateTime: localTime,
|
||||
timeZone: null,
|
||||
);
|
||||
|
||||
expect(adjustedTime.isUtc, true);
|
||||
expect(offset, adjustedTime.timeZoneOffset);
|
||||
});
|
||||
|
||||
test('should return UTC time when timezone is invalid', () {
|
||||
final utcTime = DateTime.utc(2024, 6, 15, 12, 0, 0);
|
||||
|
||||
final (adjustedTime, offset) = applyTimezoneOffset(
|
||||
dateTime: utcTime,
|
||||
timeZone: 'Invalid/Timezone',
|
||||
);
|
||||
|
||||
expect(adjustedTime.isUtc, true);
|
||||
expect(adjustedTime.hour, 12);
|
||||
expect(offset, adjustedTime.timeZoneOffset);
|
||||
});
|
||||
|
||||
test('should return UTC time when UTC offset format is malformed', () {
|
||||
final utcTime = DateTime.utc(2024, 6, 15, 12, 0, 0);
|
||||
|
||||
final (adjustedTime, offset) = applyTimezoneOffset(
|
||||
dateTime: utcTime,
|
||||
timeZone: 'UTC++08',
|
||||
);
|
||||
|
||||
expect(adjustedTime.isUtc, true);
|
||||
expect(adjustedTime.hour, 12);
|
||||
});
|
||||
});
|
||||
|
||||
group('edge cases', () {
|
||||
test('should handle date crossing midnight forward', () {
|
||||
final utcTime = DateTime.utc(2024, 6, 15, 20, 0, 0);
|
||||
|
||||
final (adjustedTime, offset) = applyTimezoneOffset(
|
||||
dateTime: utcTime,
|
||||
timeZone: 'Asia/Tokyo', // UTC+9
|
||||
);
|
||||
|
||||
expect(adjustedTime.day, 16); // Crosses to next day
|
||||
expect(adjustedTime.hour, 5); // 20:00 UTC + 9 = 05:00 next day
|
||||
expect(offset, const Duration(hours: 9));
|
||||
});
|
||||
|
||||
test('should handle date crossing midnight backward', () {
|
||||
final utcTime = DateTime.utc(2024, 6, 15, 3, 0, 0);
|
||||
|
||||
final (adjustedTime, offset) = applyTimezoneOffset(
|
||||
dateTime: utcTime,
|
||||
timeZone: 'America/Los_Angeles', // UTC-7 in summer
|
||||
);
|
||||
|
||||
expect(adjustedTime.day, 14); // Crosses to previous day
|
||||
expect(adjustedTime.hour, 20); // 03:00 UTC - 7 = 20:00 previous day
|
||||
expect(offset, const Duration(hours: -7));
|
||||
});
|
||||
|
||||
test('should handle year boundary crossing', () {
|
||||
final utcTime = DateTime.utc(2024, 1, 1, 2, 0, 0);
|
||||
|
||||
final (adjustedTime, offset) = applyTimezoneOffset(
|
||||
dateTime: utcTime,
|
||||
timeZone: 'America/New_York', // UTC-5 in winter
|
||||
);
|
||||
|
||||
expect(adjustedTime.year, 2023);
|
||||
expect(adjustedTime.month, 12);
|
||||
expect(adjustedTime.day, 31);
|
||||
expect(adjustedTime.hour, 21); // 02:00 UTC - 5 = 21:00 Dec 31
|
||||
});
|
||||
|
||||
test('should convert local time to UTC before applying timezone', () {
|
||||
// Create a local time (not UTC)
|
||||
final localTime = DateTime(2024, 6, 15, 12, 0, 0);
|
||||
|
||||
final (adjustedTime, _) = applyTimezoneOffset(
|
||||
dateTime: localTime,
|
||||
timeZone: 'Asia/Hong_Kong',
|
||||
);
|
||||
|
||||
// The function converts to UTC first, then applies timezone
|
||||
// So local 12:00 -> UTC (depends on local timezone) -> HK time
|
||||
// We can verify it's working by checking it's a TZDateTime
|
||||
expect(adjustedTime, isNotNull);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user