feat: asset dimensions in asset table

This commit is contained in:
bwees
2025-11-21 22:50:41 -06:00
parent f0b069adb9
commit 81ed88fb99
28 changed files with 485 additions and 143 deletions

View File

@@ -1,10 +1,8 @@
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
import 'package:immich_mobile/infrastructure/utils/exif.converter.dart';
class AssetService {
final RemoteAssetRepository _remoteAssetRepository;
@@ -58,22 +56,11 @@ class AssetService {
}
Future<double> getAspectRatio(BaseAsset asset) async {
bool isFlipped;
double? width;
double? height;
if (asset.hasRemote) {
final exif = await getExif(asset);
isFlipped = ExifDtoConverter.isOrientationFlipped(exif?.orientation);
width = asset.width?.toDouble();
height = asset.height?.toDouble();
} else if (asset is LocalAsset) {
isFlipped = CurrentPlatform.isAndroid && (asset.orientation == 90 || asset.orientation == 270);
width = asset.width?.toDouble();
height = asset.height?.toDouble();
} else {
isFlipped = false;
}
width = asset.width?.toDouble();
height = asset.height?.toDouble();
if (width == null || height == null) {
if (asset.hasRemote) {
@@ -89,10 +76,8 @@ class AssetService {
}
}
final orientedWidth = isFlipped ? height : width;
final orientedHeight = isFlipped ? width : height;
if (orientedWidth != null && orientedHeight != null && orientedHeight > 0) {
return orientedWidth / orientedHeight;
if (width != null && height != null && height > 0) {
return width / height;
}
return 1.0;

View File

@@ -22,6 +22,7 @@ import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/utils/exif.converter.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart' as api show AssetVisibility, AlbumUserRole, UserMetadataKey;
import 'package:openapi/api.dart' hide AssetVisibility, AlbumUserRole, UserMetadataKey;
@@ -194,6 +195,8 @@ class SyncStreamRepository extends DriftDatabaseRepository {
livePhotoVideoId: Value(asset.livePhotoVideoId),
stackId: Value(asset.stackId),
libraryId: Value(asset.libraryId),
width: Value(asset.width),
height: Value(asset.height),
);
batch.insert(
@@ -245,10 +248,21 @@ class SyncStreamRepository extends DriftDatabaseRepository {
await _db.batch((batch) {
for (final exif in data) {
int? width;
int? height;
if (ExifDtoConverter.isOrientationFlipped(exif.orientation)) {
width = exif.exifImageHeight;
height = exif.exifImageWidth;
} else {
width = exif.exifImageWidth;
height = exif.exifImageHeight;
}
batch.update(
_db.remoteAssetEntity,
RemoteAssetEntityCompanion(width: Value(exif.exifImageWidth), height: Value(exif.exifImageHeight)),
where: (row) => row.id.equals(exif.assetId),
RemoteAssetEntityCompanion(width: Value(width), height: Value(height)),
where: (row) => row.id.equals(exif.assetId) & row.width.isNull() & row.height.isNull(),
);
}
});

View File

@@ -23,6 +23,7 @@ class AssetResponseDto {
required this.fileCreatedAt,
required this.fileModifiedAt,
required this.hasMetadata,
required this.height,
required this.id,
required this.isArchived,
required this.isFavorite,
@@ -45,6 +46,7 @@ class AssetResponseDto {
this.unassignedFaces = const [],
required this.updatedAt,
required this.visibility,
required this.width,
});
/// base64 encoded sha1 hash
@@ -77,6 +79,8 @@ class AssetResponseDto {
bool hasMetadata;
num? height;
String id;
bool isArchived;
@@ -141,6 +145,8 @@ class AssetResponseDto {
AssetVisibility visibility;
num? width;
@override
bool operator ==(Object other) => identical(this, other) || other is AssetResponseDto &&
other.checksum == checksum &&
@@ -153,6 +159,7 @@ class AssetResponseDto {
other.fileCreatedAt == fileCreatedAt &&
other.fileModifiedAt == fileModifiedAt &&
other.hasMetadata == hasMetadata &&
other.height == height &&
other.id == id &&
other.isArchived == isArchived &&
other.isFavorite == isFavorite &&
@@ -174,7 +181,8 @@ class AssetResponseDto {
other.type == type &&
_deepEquality.equals(other.unassignedFaces, unassignedFaces) &&
other.updatedAt == updatedAt &&
other.visibility == visibility;
other.visibility == visibility &&
other.width == width;
@override
int get hashCode =>
@@ -189,6 +197,7 @@ class AssetResponseDto {
(fileCreatedAt.hashCode) +
(fileModifiedAt.hashCode) +
(hasMetadata.hashCode) +
(height == null ? 0 : height!.hashCode) +
(id.hashCode) +
(isArchived.hashCode) +
(isFavorite.hashCode) +
@@ -210,10 +219,11 @@ class AssetResponseDto {
(type.hashCode) +
(unassignedFaces.hashCode) +
(updatedAt.hashCode) +
(visibility.hashCode);
(visibility.hashCode) +
(width == null ? 0 : width!.hashCode);
@override
String toString() => 'AssetResponseDto[checksum=$checksum, createdAt=$createdAt, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duplicateId=$duplicateId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, id=$id, isArchived=$isArchived, isFavorite=$isFavorite, isOffline=$isOffline, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalMimeType=$originalMimeType, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, stack=$stack, tags=$tags, thumbhash=$thumbhash, type=$type, unassignedFaces=$unassignedFaces, updatedAt=$updatedAt, visibility=$visibility]';
String toString() => 'AssetResponseDto[checksum=$checksum, createdAt=$createdAt, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duplicateId=$duplicateId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, height=$height, id=$id, isArchived=$isArchived, isFavorite=$isFavorite, isOffline=$isOffline, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalMimeType=$originalMimeType, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, stack=$stack, tags=$tags, thumbhash=$thumbhash, type=$type, unassignedFaces=$unassignedFaces, updatedAt=$updatedAt, visibility=$visibility, width=$width]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -235,6 +245,11 @@ class AssetResponseDto {
json[r'fileCreatedAt'] = this.fileCreatedAt.toUtc().toIso8601String();
json[r'fileModifiedAt'] = this.fileModifiedAt.toUtc().toIso8601String();
json[r'hasMetadata'] = this.hasMetadata;
if (this.height != null) {
json[r'height'] = this.height;
} else {
// json[r'height'] = null;
}
json[r'id'] = this.id;
json[r'isArchived'] = this.isArchived;
json[r'isFavorite'] = this.isFavorite;
@@ -285,6 +300,11 @@ class AssetResponseDto {
json[r'unassignedFaces'] = this.unassignedFaces;
json[r'updatedAt'] = this.updatedAt.toUtc().toIso8601String();
json[r'visibility'] = this.visibility;
if (this.width != null) {
json[r'width'] = this.width;
} else {
// json[r'width'] = null;
}
return json;
}
@@ -307,6 +327,9 @@ class AssetResponseDto {
fileCreatedAt: mapDateTime(json, r'fileCreatedAt', r'')!,
fileModifiedAt: mapDateTime(json, r'fileModifiedAt', r'')!,
hasMetadata: mapValueOfType<bool>(json, r'hasMetadata')!,
height: json[r'height'] == null
? null
: num.parse('${json[r'height']}'),
id: mapValueOfType<String>(json, r'id')!,
isArchived: mapValueOfType<bool>(json, r'isArchived')!,
isFavorite: mapValueOfType<bool>(json, r'isFavorite')!,
@@ -329,6 +352,9 @@ class AssetResponseDto {
unassignedFaces: AssetFaceWithoutPersonResponseDto.listFromJson(json[r'unassignedFaces']),
updatedAt: mapDateTime(json, r'updatedAt', r'')!,
visibility: AssetVisibility.fromJson(json[r'visibility'])!,
width: json[r'width'] == null
? null
: num.parse('${json[r'width']}'),
);
}
return null;
@@ -384,6 +410,7 @@ class AssetResponseDto {
'fileCreatedAt',
'fileModifiedAt',
'hasMetadata',
'height',
'id',
'isArchived',
'isFavorite',
@@ -397,6 +424,7 @@ class AssetResponseDto {
'type',
'updatedAt',
'visibility',
'width',
};
}

View File

@@ -18,6 +18,7 @@ class SyncAssetV1 {
required this.duration,
required this.fileCreatedAt,
required this.fileModifiedAt,
required this.height,
required this.id,
required this.isFavorite,
required this.libraryId,
@@ -29,6 +30,7 @@ class SyncAssetV1 {
required this.thumbhash,
required this.type,
required this.visibility,
required this.width,
});
String checksum;
@@ -41,6 +43,8 @@ class SyncAssetV1 {
DateTime? fileModifiedAt;
int? height;
String id;
bool isFavorite;
@@ -63,6 +67,8 @@ class SyncAssetV1 {
AssetVisibility visibility;
int? width;
@override
bool operator ==(Object other) => identical(this, other) || other is SyncAssetV1 &&
other.checksum == checksum &&
@@ -70,6 +76,7 @@ class SyncAssetV1 {
other.duration == duration &&
other.fileCreatedAt == fileCreatedAt &&
other.fileModifiedAt == fileModifiedAt &&
other.height == height &&
other.id == id &&
other.isFavorite == isFavorite &&
other.libraryId == libraryId &&
@@ -80,7 +87,8 @@ class SyncAssetV1 {
other.stackId == stackId &&
other.thumbhash == thumbhash &&
other.type == type &&
other.visibility == visibility;
other.visibility == visibility &&
other.width == width;
@override
int get hashCode =>
@@ -90,6 +98,7 @@ class SyncAssetV1 {
(duration == null ? 0 : duration!.hashCode) +
(fileCreatedAt == null ? 0 : fileCreatedAt!.hashCode) +
(fileModifiedAt == null ? 0 : fileModifiedAt!.hashCode) +
(height == null ? 0 : height!.hashCode) +
(id.hashCode) +
(isFavorite.hashCode) +
(libraryId == null ? 0 : libraryId!.hashCode) +
@@ -100,10 +109,11 @@ class SyncAssetV1 {
(stackId == null ? 0 : stackId!.hashCode) +
(thumbhash == null ? 0 : thumbhash!.hashCode) +
(type.hashCode) +
(visibility.hashCode);
(visibility.hashCode) +
(width == null ? 0 : width!.hashCode);
@override
String toString() => 'SyncAssetV1[checksum=$checksum, deletedAt=$deletedAt, duration=$duration, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, id=$id, isFavorite=$isFavorite, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, ownerId=$ownerId, stackId=$stackId, thumbhash=$thumbhash, type=$type, visibility=$visibility]';
String toString() => 'SyncAssetV1[checksum=$checksum, deletedAt=$deletedAt, duration=$duration, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, height=$height, id=$id, isFavorite=$isFavorite, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, ownerId=$ownerId, stackId=$stackId, thumbhash=$thumbhash, type=$type, visibility=$visibility, width=$width]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -127,6 +137,11 @@ class SyncAssetV1 {
json[r'fileModifiedAt'] = this.fileModifiedAt!.toUtc().toIso8601String();
} else {
// json[r'fileModifiedAt'] = null;
}
if (this.height != null) {
json[r'height'] = this.height;
} else {
// json[r'height'] = null;
}
json[r'id'] = this.id;
json[r'isFavorite'] = this.isFavorite;
@@ -159,6 +174,11 @@ class SyncAssetV1 {
}
json[r'type'] = this.type;
json[r'visibility'] = this.visibility;
if (this.width != null) {
json[r'width'] = this.width;
} else {
// json[r'width'] = null;
}
return json;
}
@@ -176,6 +196,7 @@ class SyncAssetV1 {
duration: mapValueOfType<String>(json, r'duration'),
fileCreatedAt: mapDateTime(json, r'fileCreatedAt', r''),
fileModifiedAt: mapDateTime(json, r'fileModifiedAt', r''),
height: mapValueOfType<int>(json, r'height'),
id: mapValueOfType<String>(json, r'id')!,
isFavorite: mapValueOfType<bool>(json, r'isFavorite')!,
libraryId: mapValueOfType<String>(json, r'libraryId'),
@@ -187,6 +208,7 @@ class SyncAssetV1 {
thumbhash: mapValueOfType<String>(json, r'thumbhash'),
type: AssetTypeEnum.fromJson(json[r'type'])!,
visibility: AssetVisibility.fromJson(json[r'visibility'])!,
width: mapValueOfType<int>(json, r'width'),
);
}
return null;
@@ -239,6 +261,7 @@ class SyncAssetV1 {
'duration',
'fileCreatedAt',
'fileModifiedAt',
'height',
'id',
'isFavorite',
'libraryId',
@@ -250,6 +273,7 @@ class SyncAssetV1 {
'thumbhash',
'type',
'visibility',
'width',
};
}

View File

@@ -0,0 +1,185 @@
import 'package:drift/drift.dart' as drift;
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
import 'package:openapi/api.dart';
SyncUserV1 _createUser({String id = 'user-1'}) {
return SyncUserV1(
id: id,
name: 'Test User',
email: 'test@test.com',
deletedAt: null,
avatarColor: null,
hasProfileImage: false,
profileChangedAt: DateTime(2024, 1, 1),
);
}
SyncAssetV1 _createAsset({
required String id,
required String checksum,
required String fileName,
String ownerId = 'user-1',
int? width,
int? height,
}) {
return SyncAssetV1(
id: id,
checksum: checksum,
originalFileName: fileName,
type: AssetTypeEnum.IMAGE,
ownerId: ownerId,
isFavorite: false,
fileCreatedAt: DateTime(2024, 1, 1),
fileModifiedAt: DateTime(2024, 1, 1),
localDateTime: DateTime(2024, 1, 1),
visibility: AssetVisibility.timeline,
width: width,
height: height,
deletedAt: null,
duration: null,
libraryId: null,
livePhotoVideoId: null,
stackId: null,
thumbhash: null,
);
}
SyncAssetExifV1 _createExif({
required String assetId,
required int width,
required int height,
required String orientation,
}) {
return SyncAssetExifV1(
assetId: assetId,
exifImageWidth: width,
exifImageHeight: height,
orientation: orientation,
city: null,
country: null,
dateTimeOriginal: null,
description: null,
exposureTime: null,
fNumber: null,
fileSizeInByte: null,
focalLength: null,
fps: null,
iso: null,
latitude: null,
lensModel: null,
longitude: null,
make: null,
model: null,
modifyDate: null,
profileDescription: null,
projectionType: null,
rating: null,
state: null,
timeZone: null,
);
}
void main() {
late Drift db;
late SyncStreamRepository sut;
setUp(() async {
db = Drift(drift.DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
sut = SyncStreamRepository(db);
});
tearDown(() async {
await db.close();
});
group('SyncStreamRepository - Dimension swapping based on orientation', () {
test('swaps dimensions for asset with rotated orientation', () async {
final flippedOrientations = ['5', '6', '7', '8', '90', '-90'];
for (final orientation in flippedOrientations) {
final assetId = 'asset-$orientation-degrees';
await sut.updateUsersV1([_createUser()]);
final asset = _createAsset(
id: assetId,
checksum: 'checksum-$orientation',
fileName: 'rotated_$orientation.jpg',
);
await sut.updateAssetsV1([asset]);
final exif = _createExif(
assetId: assetId,
width: 1920,
height: 1080,
orientation: orientation, // EXIF orientation value for 90 degrees CW
);
await sut.updateAssetsExifV1([exif]);
final query = db.remoteAssetEntity.select()..where((tbl) => tbl.id.equals(assetId));
final result = await query.getSingle();
expect(result.width, equals(1080));
expect(result.height, equals(1920));
}
});
test('does not swap dimensions for asset with normal orientation', () async {
final nonFlippedOrientations = ['1', '2', '3', '4'];
for (final orientation in nonFlippedOrientations) {
final assetId = 'asset-$orientation-degrees';
await sut.updateUsersV1([_createUser()]);
final asset = _createAsset(id: assetId, checksum: 'checksum-$orientation', fileName: 'normal_$orientation.jpg');
await sut.updateAssetsV1([asset]);
final exif = _createExif(
assetId: assetId,
width: 1920,
height: 1080,
orientation: orientation, // EXIF orientation value for normal
);
await sut.updateAssetsExifV1([exif]);
final query = db.remoteAssetEntity.select()..where((tbl) => tbl.id.equals(assetId));
final result = await query.getSingle();
expect(result.width, equals(1920));
expect(result.height, equals(1080));
}
});
test('does not update dimensions if asset already has width and height', () async {
const assetId = 'asset-with-dimensions';
const existingWidth = 1920;
const existingHeight = 1080;
const exifWidth = 3840;
const exifHeight = 2160;
await sut.updateUsersV1([_createUser()]);
final asset = _createAsset(
id: assetId,
checksum: 'checksum-with-dims',
fileName: 'with_dimensions.jpg',
width: existingWidth,
height: existingHeight,
);
await sut.updateAssetsV1([asset]);
final exif = _createExif(assetId: assetId, width: exifWidth, height: exifHeight, orientation: '6');
await sut.updateAssetsExifV1([exif]);
// Verify the asset still has original dimensions (not updated from EXIF)
final query = db.remoteAssetEntity.select()..where((tbl) => tbl.id.equals(assetId));
final result = await query.getSingle();
expect(result.width, equals(existingWidth), reason: 'Width should remain as originally set');
expect(result.height, equals(existingHeight), reason: 'Height should remain as originally set');
});
});
}

View File

@@ -1,4 +1,3 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/domain/services/asset.service.dart';
@@ -22,42 +21,6 @@ void main() {
});
group('getAspectRatio', () {
test('flips dimensions on Android for 90° and 270° orientations', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
for (final orientation in [90, 270]) {
final localAsset = TestUtils.createLocalAsset(
id: 'local-$orientation',
width: 1920,
height: 1080,
orientation: orientation,
);
final result = await sut.getAspectRatio(localAsset);
expect(result, 1080 / 1920, reason: 'Orientation $orientation should flip on Android');
}
});
test('does not flip dimensions on iOS regardless of orientation', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
for (final orientation in [0, 90, 270]) {
final localAsset = TestUtils.createLocalAsset(
id: 'local-$orientation',
width: 1920,
height: 1080,
orientation: orientation,
);
final result = await sut.getAspectRatio(localAsset);
expect(result, 1920 / 1080, reason: 'iOS should never flip dimensions');
}
});
test('fetches dimensions from remote repository when missing from asset', () async {
final remoteAsset = TestUtils.createRemoteAsset(id: 'remote-1', width: null, height: null);
@@ -112,54 +75,23 @@ void main() {
expect(result, 1.0);
});
test('handles local asset with remoteId and uses exif from remote', () async {
test('handles local asset with remoteId and uses remote dimensions', () async {
final localAsset = TestUtils.createLocalAsset(
id: 'local-1',
remoteId: 'remote-1',
width: 1920,
height: 1080,
width: null,
height: null,
orientation: 0,
);
final exif = const ExifInfo(orientation: '6');
when(() => mockRemoteAssetRepository.getExif('remote-1')).thenAnswer((_) async => exif);
when(
() => mockRemoteAssetRepository.get('remote-1'),
).thenAnswer((_) async => TestUtils.createRemoteAsset(id: 'remote-1', width: 1920, height: 1080));
final result = await sut.getAspectRatio(localAsset);
verify(() => mockRemoteAssetRepository.get('remote-1')).called(1);
expect(result, 1080 / 1920);
});
test('handles various flipped EXIF orientations correctly', () async {
final flippedOrientations = ['5', '6', '7', '8', '90', '-90'];
for (final orientation in flippedOrientations) {
final remoteAsset = TestUtils.createRemoteAsset(id: 'remote-$orientation', width: 1920, height: 1080);
final exif = ExifInfo(orientation: orientation);
when(() => mockRemoteAssetRepository.getExif('remote-$orientation')).thenAnswer((_) async => exif);
final result = await sut.getAspectRatio(remoteAsset);
expect(result, 1080 / 1920, reason: 'Orientation $orientation should flip dimensions');
}
});
test('handles various non-flipped EXIF orientations correctly', () async {
final nonFlippedOrientations = ['1', '2', '3', '4'];
for (final orientation in nonFlippedOrientations) {
final remoteAsset = TestUtils.createRemoteAsset(id: 'remote-$orientation', width: 1920, height: 1080);
final exif = ExifInfo(orientation: orientation);
when(() => mockRemoteAssetRepository.getExif('remote-$orientation')).thenAnswer((_) async => exif);
final result = await sut.getAspectRatio(remoteAsset);
expect(result, 1920 / 1080, reason: 'Orientation $orientation should NOT flip dimensions');
}
expect(result, 1920 / 1080);
});
});
}

View File

@@ -94,25 +94,11 @@ abstract final class SyncStreamStub {
required String ack,
DateTime? trashedAt,
}) {
return _assetV1(
id: id,
checksum: checksum,
deletedAt: trashedAt ?? DateTime(2025, 1, 1),
ack: ack,
);
return _assetV1(id: id, checksum: checksum, deletedAt: trashedAt ?? DateTime(2025, 1, 1), ack: ack);
}
static SyncEvent assetModified({
required String id,
required String checksum,
required String ack,
}) {
return _assetV1(
id: id,
checksum: checksum,
deletedAt: null,
ack: ack,
);
static SyncEvent assetModified({required String id, required String checksum, required String ack}) {
return _assetV1(id: id, checksum: checksum, deletedAt: null, ack: ack);
}
static SyncEvent _assetV1({
@@ -140,6 +126,8 @@ abstract final class SyncStreamStub {
thumbhash: null,
type: AssetTypeEnum.IMAGE,
visibility: AssetVisibility.timeline,
width: null,
height: null,
),
ack: ack,
);

View File

@@ -45,5 +45,17 @@ void main() {
addDefault(value, keys, defaultValue);
expect(value['alpha']['beta'], 'gamma');
});
test('addDefault with null', () {
dynamic value = jsonDecode("""
{
"download": {
"archiveSize": 4294967296,
"includeEmbeddedVideos": false
}
}
""");
expect(value['download']['unknownKey'], isNull);
});
});
}