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:
shenlong
2026-04-21 03:15:20 +05:30
committed by GitHub
parent f909648bce
commit d9011c0829
15 changed files with 309 additions and 219 deletions
@@ -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,
);
}
}
+36
View File
@@ -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);
});
});
});
}
+322
View File
@@ -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);
});
});
}
+116
View File
@@ -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>>());
});
});
}
+92
View File
@@ -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);
});
});
}
+278
View File
@@ -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);
});
});
});
}