From 04ce8c3d5453d3ce4d2483a08c1a399c4822e7ee Mon Sep 17 00:00:00 2001 From: monsieurtanuki Date: Thu, 8 May 2025 20:54:52 +0200 Subject: [PATCH 01/12] feat: 1072 - new nutriscore details product field New files: * `nutriscore_data_2021.dart`: Detailed data of NutriScore version 2021. * `nutriscore_data_2021.g.dart`: generated * `nutriscore_data_2023.dart`: Detailed data of NutriScore version 2023. * `nutriscore_data_2023.g.dart`: generated * `nutriscore_detail_2021.dart`: Data of NutriScore version 2021. * `nutriscore_detail_2021.g.dart`: generated * `nutriscore_detail_2023.dart`: Data of NutriScore version 2023. * `nutriscore_detail_2023.g.dart`: generated * `nutriscore_details.dart`: NutriScore detailed info. * `nutriscore_details.g.dart`: generated * `nutriscore_grade.dart`: Grade of NutriScore. Impacted files: * `api_get_product_test.dart`: new tests around nutriscoreDetails * `product.dart`: new nutriscoreDetails field * `product.g.dart`: generated * `product_fields.dart`: new nutriscore field --- .../nutriscore/nutriscore_data_2021.dart | 51 ++++ .../nutriscore/nutriscore_data_2021.g.dart | 26 ++ .../nutriscore/nutriscore_data_2023.dart | 82 +++++++ .../nutriscore/nutriscore_data_2023.g.dart | 38 +++ .../nutriscore/nutriscore_detail_2021.dart | 59 +++++ .../nutriscore/nutriscore_detail_2021.g.dart | 47 ++++ .../nutriscore/nutriscore_detail_2023.dart | 59 +++++ .../nutriscore/nutriscore_detail_2023.g.dart | 47 ++++ .../model/nutriscore/nutriscore_details.dart | 24 ++ .../nutriscore/nutriscore_details.g.dart | 22 ++ .../model/nutriscore/nutriscore_grade.dart | 29 +++ lib/src/model/product.dart | 4 + lib/src/model/product.g.dart | 5 + lib/src/utils/product_fields.dart | 1 + test/api_get_product_test.dart | 230 ++++++++++++++++++ 15 files changed, 724 insertions(+) create mode 100644 lib/src/model/nutriscore/nutriscore_data_2021.dart create mode 100644 lib/src/model/nutriscore/nutriscore_data_2021.g.dart create mode 100644 lib/src/model/nutriscore/nutriscore_data_2023.dart create mode 100644 lib/src/model/nutriscore/nutriscore_data_2023.g.dart create mode 100644 lib/src/model/nutriscore/nutriscore_detail_2021.dart create mode 100644 lib/src/model/nutriscore/nutriscore_detail_2021.g.dart create mode 100644 lib/src/model/nutriscore/nutriscore_detail_2023.dart create mode 100644 lib/src/model/nutriscore/nutriscore_detail_2023.g.dart create mode 100644 lib/src/model/nutriscore/nutriscore_details.dart create mode 100644 lib/src/model/nutriscore/nutriscore_details.g.dart create mode 100644 lib/src/model/nutriscore/nutriscore_grade.dart diff --git a/lib/src/model/nutriscore/nutriscore_data_2021.dart b/lib/src/model/nutriscore/nutriscore_data_2021.dart new file mode 100644 index 0000000000..9c289606db --- /dev/null +++ b/lib/src/model/nutriscore/nutriscore_data_2021.dart @@ -0,0 +1,51 @@ +import 'package:json_annotation/json_annotation.dart'; +import '../../interface/json_object.dart'; +import '../../utils/json_helper.dart'; + +part 'nutriscore_data_2021.g.dart'; + +/// Detailed data of NutriScore version 2021. +@JsonSerializable() +class NutriScoreData2021 extends JsonObject { + @JsonKey( + name: 'is_beverage', + toJson: JsonHelper.boolToJSON, + fromJson: JsonHelper.boolFromJSON, + ) + bool? isBeverage; + + @JsonKey( + name: 'is_cheese', + toJson: JsonHelper.boolToJSON, + fromJson: JsonHelper.boolFromJSON, + ) + bool? isCheese; + + @JsonKey( + name: 'is_fat', + toJson: JsonHelper.boolToJSON, + fromJson: JsonHelper.boolFromJSON, + ) + bool? isFat; + + @JsonKey( + name: 'is_water', + toJson: JsonHelper.boolToJSON, + fromJson: JsonHelper.boolFromJSON, + ) + bool? isWater; + + @JsonKey(name: 'negative_points') + int? negativePoints; + + @JsonKey(name: 'positive_points') + int? positivePoints; + + NutriScoreData2021(); + + factory NutriScoreData2021.fromJson(Map json) => + _$NutriScoreData2021FromJson(json); + + @override + Map toJson() => _$NutriScoreData2021ToJson(this); +} diff --git a/lib/src/model/nutriscore/nutriscore_data_2021.g.dart b/lib/src/model/nutriscore/nutriscore_data_2021.g.dart new file mode 100644 index 0000000000..8cadb3c32e --- /dev/null +++ b/lib/src/model/nutriscore/nutriscore_data_2021.g.dart @@ -0,0 +1,26 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'nutriscore_data_2021.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +NutriScoreData2021 _$NutriScoreData2021FromJson(Map json) => + NutriScoreData2021() + ..isBeverage = JsonHelper.boolFromJSON(json['is_beverage']) + ..isCheese = JsonHelper.boolFromJSON(json['is_cheese']) + ..isFat = JsonHelper.boolFromJSON(json['is_fat']) + ..isWater = JsonHelper.boolFromJSON(json['is_water']) + ..negativePoints = (json['negative_points'] as num?)?.toInt() + ..positivePoints = (json['positive_points'] as num?)?.toInt(); + +Map _$NutriScoreData2021ToJson(NutriScoreData2021 instance) => + { + 'is_beverage': JsonHelper.boolToJSON(instance.isBeverage), + 'is_cheese': JsonHelper.boolToJSON(instance.isCheese), + 'is_fat': JsonHelper.boolToJSON(instance.isFat), + 'is_water': JsonHelper.boolToJSON(instance.isWater), + 'negative_points': instance.negativePoints, + 'positive_points': instance.positivePoints, + }; diff --git a/lib/src/model/nutriscore/nutriscore_data_2023.dart b/lib/src/model/nutriscore/nutriscore_data_2023.dart new file mode 100644 index 0000000000..2491b07b77 --- /dev/null +++ b/lib/src/model/nutriscore/nutriscore_data_2023.dart @@ -0,0 +1,82 @@ +import 'package:json_annotation/json_annotation.dart'; +import '../../interface/json_object.dart'; +import '../../utils/json_helper.dart'; + +part 'nutriscore_data_2023.g.dart'; + +/// Detailed data of NutriScore version 2023. +@JsonSerializable() +class NutriScoreData2023 extends JsonObject { + @JsonKey( + name: 'count_proteins', + toJson: JsonHelper.boolToJSON, + fromJson: JsonHelper.boolFromJSON, + ) + bool? countProteins; // or int? + + @JsonKey(name: 'count_proteins_reason') + String? countProteinsReason; + + @JsonKey( + name: 'is_beverage', + toJson: JsonHelper.boolToJSON, + fromJson: JsonHelper.boolFromJSON, + ) + bool? isBeverage; + + @JsonKey( + name: 'is_cheese', + toJson: JsonHelper.boolToJSON, + fromJson: JsonHelper.boolFromJSON, + ) + bool? isCheese; + + @JsonKey( + name: 'is_fat_oil_nuts_seeds', + toJson: JsonHelper.boolToJSON, + fromJson: JsonHelper.boolFromJSON, + ) + bool? isFatOilNutsSeeds; + + @JsonKey( + name: 'is_red_meat_product', + toJson: JsonHelper.boolToJSON, + fromJson: JsonHelper.boolFromJSON, + ) + bool? isRedMeatProduct; + + @JsonKey( + name: 'is_water', + toJson: JsonHelper.boolToJSON, + fromJson: JsonHelper.boolFromJSON, + ) + bool? isWater; + + @JsonKey(name: 'negative_points') + int? negativePoints; + + @JsonKey(name: 'negative_points_max') + int? negativePointsMax; + + /* + "positive_nutrients": [ + "proteins", + "fiber", + "fruits_vegetables_legumes" + ], + + */ + @JsonKey(name: 'positive_points') + int? positivePoints; + + @JsonKey(name: 'positive_points_max') + int? positivePointsMax; + + NutriScoreData2023(); + + factory NutriScoreData2023.fromJson(Map json) => + _$NutriScoreData2023FromJson(json); + + @override + Map toJson() => _$NutriScoreData2023ToJson(this); +} diff --git a/lib/src/model/nutriscore/nutriscore_data_2023.g.dart b/lib/src/model/nutriscore/nutriscore_data_2023.g.dart new file mode 100644 index 0000000000..45c7235f2d --- /dev/null +++ b/lib/src/model/nutriscore/nutriscore_data_2023.g.dart @@ -0,0 +1,38 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'nutriscore_data_2023.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +NutriScoreData2023 _$NutriScoreData2023FromJson(Map json) => + NutriScoreData2023() + ..countProteins = JsonHelper.boolFromJSON(json['count_proteins']) + ..countProteinsReason = json['count_proteins_reason'] as String? + ..isBeverage = JsonHelper.boolFromJSON(json['is_beverage']) + ..isCheese = JsonHelper.boolFromJSON(json['is_cheese']) + ..isFatOilNutsSeeds = + JsonHelper.boolFromJSON(json['is_fat_oil_nuts_seeds']) + ..isRedMeatProduct = JsonHelper.boolFromJSON(json['is_red_meat_product']) + ..isWater = JsonHelper.boolFromJSON(json['is_water']) + ..negativePoints = (json['negative_points'] as num?)?.toInt() + ..negativePointsMax = (json['negative_points_max'] as num?)?.toInt() + ..positivePoints = (json['positive_points'] as num?)?.toInt() + ..positivePointsMax = (json['positive_points_max'] as num?)?.toInt(); + +Map _$NutriScoreData2023ToJson(NutriScoreData2023 instance) => + { + 'count_proteins': JsonHelper.boolToJSON(instance.countProteins), + 'count_proteins_reason': instance.countProteinsReason, + 'is_beverage': JsonHelper.boolToJSON(instance.isBeverage), + 'is_cheese': JsonHelper.boolToJSON(instance.isCheese), + 'is_fat_oil_nuts_seeds': + JsonHelper.boolToJSON(instance.isFatOilNutsSeeds), + 'is_red_meat_product': JsonHelper.boolToJSON(instance.isRedMeatProduct), + 'is_water': JsonHelper.boolToJSON(instance.isWater), + 'negative_points': instance.negativePoints, + 'negative_points_max': instance.negativePointsMax, + 'positive_points': instance.positivePoints, + 'positive_points_max': instance.positivePointsMax, + }; diff --git a/lib/src/model/nutriscore/nutriscore_detail_2021.dart b/lib/src/model/nutriscore/nutriscore_detail_2021.dart new file mode 100644 index 0000000000..84cf6a94b9 --- /dev/null +++ b/lib/src/model/nutriscore/nutriscore_detail_2021.dart @@ -0,0 +1,59 @@ +import 'package:json_annotation/json_annotation.dart'; +import '../../interface/json_object.dart'; +import '../../utils/json_helper.dart'; +import 'nutriscore_data_2021.dart'; +import 'nutriscore_grade.dart'; + +part 'nutriscore_detail_2021.g.dart'; + +/// Data of NutriScore version 2021. +@JsonSerializable() +class NutriScoreDetail2021 extends JsonObject { + @JsonKey() + NutriScoreGrade? grade; + + @JsonKey() + int? score; + + @JsonKey() + NutriScoreData2021? data; + + @JsonKey(name: 'not_applicable_category') + String? notApplicableCategory; + + @JsonKey( + name: 'category_available', + toJson: JsonHelper.boolToJSON, + fromJson: JsonHelper.boolFromJSON, + ) + bool? categoryAvailable; + + @JsonKey( + name: 'nutrients_available', + toJson: JsonHelper.boolToJSON, + fromJson: JsonHelper.boolFromJSON, + ) + bool? nutrientsAvailable; + + @JsonKey( + name: 'nutriscore_applicable', + toJson: JsonHelper.boolToJSON, + fromJson: JsonHelper.boolFromJSON, + ) + bool? nutriScoreApplicable; + + @JsonKey( + name: 'nutriscore_computed', + toJson: JsonHelper.boolToJSON, + fromJson: JsonHelper.boolFromJSON, + ) + bool? nutriScoreComputed; + + NutriScoreDetail2021(); + + factory NutriScoreDetail2021.fromJson(Map json) => + _$NutriScoreDetail2021FromJson(json); + + @override + Map toJson() => _$NutriScoreDetail2021ToJson(this); +} diff --git a/lib/src/model/nutriscore/nutriscore_detail_2021.g.dart b/lib/src/model/nutriscore/nutriscore_detail_2021.g.dart new file mode 100644 index 0000000000..17ca2d551c --- /dev/null +++ b/lib/src/model/nutriscore/nutriscore_detail_2021.g.dart @@ -0,0 +1,47 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'nutriscore_detail_2021.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +NutriScoreDetail2021 _$NutriScoreDetail2021FromJson( + Map json) => + NutriScoreDetail2021() + ..grade = $enumDecodeNullable(_$NutriScoreGradeEnumMap, json['grade']) + ..score = (json['score'] as num?)?.toInt() + ..data = json['data'] == null + ? null + : NutriScoreData2021.fromJson(json['data'] as Map) + ..notApplicableCategory = json['not_applicable_category'] as String? + ..categoryAvailable = JsonHelper.boolFromJSON(json['category_available']) + ..nutrientsAvailable = + JsonHelper.boolFromJSON(json['nutrients_available']) + ..nutriScoreApplicable = + JsonHelper.boolFromJSON(json['nutriscore_applicable']) + ..nutriScoreComputed = + JsonHelper.boolFromJSON(json['nutriscore_computed']); + +Map _$NutriScoreDetail2021ToJson( + NutriScoreDetail2021 instance) => + { + 'grade': _$NutriScoreGradeEnumMap[instance.grade], + 'score': instance.score, + 'data': instance.data, + 'not_applicable_category': instance.notApplicableCategory, + 'category_available': JsonHelper.boolToJSON(instance.categoryAvailable), + 'nutrients_available': JsonHelper.boolToJSON(instance.nutrientsAvailable), + 'nutriscore_applicable': + JsonHelper.boolToJSON(instance.nutriScoreApplicable), + 'nutriscore_computed': JsonHelper.boolToJSON(instance.nutriScoreComputed), + }; + +const _$NutriScoreGradeEnumMap = { + NutriScoreGrade.a: 'a', + NutriScoreGrade.b: 'b', + NutriScoreGrade.c: 'c', + NutriScoreGrade.d: 'd', + NutriScoreGrade.e: 'e', + NutriScoreGrade.notApplicable: 'not-applicable', +}; diff --git a/lib/src/model/nutriscore/nutriscore_detail_2023.dart b/lib/src/model/nutriscore/nutriscore_detail_2023.dart new file mode 100644 index 0000000000..c8cb1187cc --- /dev/null +++ b/lib/src/model/nutriscore/nutriscore_detail_2023.dart @@ -0,0 +1,59 @@ +import 'package:json_annotation/json_annotation.dart'; +import '../../interface/json_object.dart'; +import '../../utils/json_helper.dart'; +import 'nutriscore_data_2023.dart'; +import 'nutriscore_grade.dart'; + +part 'nutriscore_detail_2023.g.dart'; + +/// Data of NutriScore version 2023. +@JsonSerializable() +class NutriScoreDetail2023 extends JsonObject { + @JsonKey() + NutriScoreGrade? grade; + + @JsonKey() + int? score; + + @JsonKey() + NutriScoreData2023? data; + + @JsonKey(name: 'not_applicable_category') + String? notApplicableCategory; + + @JsonKey( + name: 'category_available', + toJson: JsonHelper.boolToJSON, + fromJson: JsonHelper.boolFromJSON, + ) + bool? categoryAvailable; + + @JsonKey( + name: 'nutrients_available', + toJson: JsonHelper.boolToJSON, + fromJson: JsonHelper.boolFromJSON, + ) + bool? nutrientsAvailable; + + @JsonKey( + name: 'nutriscore_applicable', + toJson: JsonHelper.boolToJSON, + fromJson: JsonHelper.boolFromJSON, + ) + bool? nutriScoreApplicable; + + @JsonKey( + name: 'nutriscore_computed', + toJson: JsonHelper.boolToJSON, + fromJson: JsonHelper.boolFromJSON, + ) + bool? nutriScoreComputed; + + NutriScoreDetail2023(); + + factory NutriScoreDetail2023.fromJson(Map json) => + _$NutriScoreDetail2023FromJson(json); + + @override + Map toJson() => _$NutriScoreDetail2023ToJson(this); +} diff --git a/lib/src/model/nutriscore/nutriscore_detail_2023.g.dart b/lib/src/model/nutriscore/nutriscore_detail_2023.g.dart new file mode 100644 index 0000000000..5ef65f5985 --- /dev/null +++ b/lib/src/model/nutriscore/nutriscore_detail_2023.g.dart @@ -0,0 +1,47 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'nutriscore_detail_2023.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +NutriScoreDetail2023 _$NutriScoreDetail2023FromJson( + Map json) => + NutriScoreDetail2023() + ..grade = $enumDecodeNullable(_$NutriScoreGradeEnumMap, json['grade']) + ..score = (json['score'] as num?)?.toInt() + ..data = json['data'] == null + ? null + : NutriScoreData2023.fromJson(json['data'] as Map) + ..notApplicableCategory = json['not_applicable_category'] as String? + ..categoryAvailable = JsonHelper.boolFromJSON(json['category_available']) + ..nutrientsAvailable = + JsonHelper.boolFromJSON(json['nutrients_available']) + ..nutriScoreApplicable = + JsonHelper.boolFromJSON(json['nutriscore_applicable']) + ..nutriScoreComputed = + JsonHelper.boolFromJSON(json['nutriscore_computed']); + +Map _$NutriScoreDetail2023ToJson( + NutriScoreDetail2023 instance) => + { + 'grade': _$NutriScoreGradeEnumMap[instance.grade], + 'score': instance.score, + 'data': instance.data, + 'not_applicable_category': instance.notApplicableCategory, + 'category_available': JsonHelper.boolToJSON(instance.categoryAvailable), + 'nutrients_available': JsonHelper.boolToJSON(instance.nutrientsAvailable), + 'nutriscore_applicable': + JsonHelper.boolToJSON(instance.nutriScoreApplicable), + 'nutriscore_computed': JsonHelper.boolToJSON(instance.nutriScoreComputed), + }; + +const _$NutriScoreGradeEnumMap = { + NutriScoreGrade.a: 'a', + NutriScoreGrade.b: 'b', + NutriScoreGrade.c: 'c', + NutriScoreGrade.d: 'd', + NutriScoreGrade.e: 'e', + NutriScoreGrade.notApplicable: 'not-applicable', +}; diff --git a/lib/src/model/nutriscore/nutriscore_details.dart b/lib/src/model/nutriscore/nutriscore_details.dart new file mode 100644 index 0000000000..b61e9b59f6 --- /dev/null +++ b/lib/src/model/nutriscore/nutriscore_details.dart @@ -0,0 +1,24 @@ +import 'package:json_annotation/json_annotation.dart'; +import '../../interface/json_object.dart'; +import 'nutriscore_detail_2021.dart'; +import 'nutriscore_detail_2023.dart'; + +part 'nutriscore_details.g.dart'; + +/// NutriScore detailed info. +@JsonSerializable() +class NutriScoreDetails extends JsonObject { + @JsonKey(name: '2021') + NutriScoreDetail2021? nutriScore2021; + + @JsonKey(name: '2023') + NutriScoreDetail2023? nutriScore2023; + + NutriScoreDetails(); + + factory NutriScoreDetails.fromJson(Map json) => + _$NutriScoreDetailsFromJson(json); + + @override + Map toJson() => _$NutriScoreDetailsToJson(this); +} diff --git a/lib/src/model/nutriscore/nutriscore_details.g.dart b/lib/src/model/nutriscore/nutriscore_details.g.dart new file mode 100644 index 0000000000..8585385df9 --- /dev/null +++ b/lib/src/model/nutriscore/nutriscore_details.g.dart @@ -0,0 +1,22 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'nutriscore_details.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +NutriScoreDetails _$NutriScoreDetailsFromJson(Map json) => + NutriScoreDetails() + ..nutriScore2021 = json['2021'] == null + ? null + : NutriScoreDetail2021.fromJson(json['2021'] as Map) + ..nutriScore2023 = json['2023'] == null + ? null + : NutriScoreDetail2023.fromJson(json['2023'] as Map); + +Map _$NutriScoreDetailsToJson(NutriScoreDetails instance) => + { + '2021': instance.nutriScore2021, + '2023': instance.nutriScore2023, + }; diff --git a/lib/src/model/nutriscore/nutriscore_grade.dart b/lib/src/model/nutriscore/nutriscore_grade.dart new file mode 100644 index 0000000000..a7edb6817c --- /dev/null +++ b/lib/src/model/nutriscore/nutriscore_grade.dart @@ -0,0 +1,29 @@ +import 'package:json_annotation/json_annotation.dart'; + +import '../off_tagged.dart'; + +/// Grade of NutriScore. +enum NutriScoreGrade implements OffTagged { + @JsonValue('a') + a('a'), + @JsonValue('b') + b('b'), + @JsonValue('c') + c('c'), + @JsonValue('d') + d('d'), + @JsonValue('e') + e('e'), + @JsonValue('not-applicable') + notApplicable('not-applicable'), + ; + + const NutriScoreGrade(this.offTag); + + @override + final String offTag; + + /// Returns the first [NutriScoreGrade] that matches the [offTag]. + static NutriScoreGrade? fromOffTag(final String? offTag) => + OffTagged.fromOffTag(offTag, NutriScoreGrade.values) as NutriScoreGrade?; +} diff --git a/lib/src/model/product.dart b/lib/src/model/product.dart index ed3aee5ab1..eadd5e2786 100644 --- a/lib/src/model/product.dart +++ b/lib/src/model/product.dart @@ -4,6 +4,7 @@ import '../interface/json_object.dart'; import '../utils/json_helper.dart'; import '../utils/language_helper.dart'; import '../utils/product_fields.dart'; +import 'nutriscore/nutriscore_details.dart'; import 'additives.dart'; import 'allergens.dart'; import 'attribute.dart'; @@ -404,6 +405,9 @@ class Product extends JsonObject { ) bool? nutritionData; + @JsonKey(name: 'nutriscore') + NutriScoreDetails? nutriScoreDetails; + /// Size of the product sample for "nutrition data for product as sold". /// /// Typical values: [nutrimentPer100g] or [nutrimentPerServing]. diff --git a/lib/src/model/product.g.dart b/lib/src/model/product.g.dart index fc3ee6b2e2..9bd132b74f 100644 --- a/lib/src/model/product.g.dart +++ b/lib/src/model/product.g.dart @@ -131,6 +131,10 @@ Product _$ProductFromJson(Map json) => Product( ..allergensTagsInLanguages = LanguageHelper.fromJsonStringsListMap( json['allergens_tags_in_languages']) ..nutritionData = JsonHelper.checkboxFromJSON(json['nutrition_data']) + ..nutriScoreDetails = json['nutriscore'] == null + ? null + : NutriScoreDetails.fromJson( + json['nutriscore'] as Map) ..comparedToCategory = json['compared_to_category'] as String? ..packagings = (json['packagings'] as List?) ?.map(ProductPackaging.fromJson) @@ -280,6 +284,7 @@ Map _$ProductToJson(Product instance) { 'nutrient_levels', NutrientLevels.toJson(instance.nutrientLevels)); writeNotNull('nutriment_energy_unit', instance.nutrimentEnergyUnit); val['nutrition_data'] = JsonHelper.checkboxToJSON(instance.nutritionData); + writeNotNull('nutriscore', instance.nutriScoreDetails); writeNotNull('nutrition_data_per', instance.nutrimentDataPer); writeNotNull('nutrition_grade_fr', instance.nutriscore); writeNotNull('compared_to_category', instance.comparedToCategory); diff --git a/lib/src/utils/product_fields.dart b/lib/src/utils/product_fields.dart index d010d7cbb8..e0ee751c98 100644 --- a/lib/src/utils/product_fields.dart +++ b/lib/src/utils/product_fields.dart @@ -127,6 +127,7 @@ enum ProductField implements OffTagged { NUTRIMENT_DATA_PER(offTag: 'nutrition_data_per'), NUTRITION_DATA(offTag: 'nutrition_data'), NUTRISCORE(offTag: 'nutrition_grade_fr'), + NUTRISCORE_DETAILS(offTag: 'nutriscore'), COMPARED_TO_CATEGORY(offTag: 'compared_to_category'), CATEGORIES(offTag: 'categories'), CATEGORIES_TAGS( diff --git a/test/api_get_product_test.dart b/test/api_get_product_test.dart index 5b1c1e0f3a..26b7b1b7b0 100644 --- a/test/api_get_product_test.dart +++ b/test/api_get_product_test.dart @@ -1,5 +1,6 @@ import 'package:http/http.dart' as http; import 'package:openfoodfacts/openfoodfacts.dart'; +import 'package:openfoodfacts/src/model/nutriscore/nutriscore_grade.dart'; import 'package:test/test.dart'; import 'test_constants.dart'; @@ -140,6 +141,235 @@ void main() { ); }); + test('check nutriscore data for alcohol', () async { + const String barcode = '3119780259625'; + const String category = 'en:alcoholic-beverages'; + + final ProductQueryConfiguration configurations = + ProductQueryConfiguration( + barcode, + language: OpenFoodFactsLanguage.ENGLISH, + fields: [ProductField.NUTRISCORE_DETAILS], + version: ProductQueryVersion.v3, + ); + final ProductResultV3 result = await getProductV3InProd( + configurations, + ); + expect(result.status, ProductResultV3.statusSuccess); + expect(result.barcode, barcode); + expect(result.product, isNotNull); + + expect(result.product!.nutriScoreDetails, isNotNull); + expect(result.product!.nutriScoreDetails!.nutriScore2021, isNotNull); + expect( + result.product!.nutriScoreDetails!.nutriScore2021!.grade, + NutriScoreGrade.notApplicable, + ); + expect( + result.product!.nutriScoreDetails!.nutriScore2021!.categoryAvailable, + isTrue, + ); + expect( + result.product!.nutriScoreDetails!.nutriScore2021!.score, + isNull, + ); + expect( + result.product!.nutriScoreDetails!.nutriScore2021!.nutrientsAvailable, + isTrue, + ); + expect( + result.product!.nutriScoreDetails!.nutriScore2021!.nutriScoreComputed, + isFalse, + ); + expect( + result.product!.nutriScoreDetails!.nutriScore2021!.nutriScoreApplicable, + isFalse, + ); + expect( + result.product!.nutriScoreDetails!.nutriScore2021!.data, + isNotNull, + ); + expect( + result.product!.nutriScoreDetails!.nutriScore2021!.data!.isBeverage, + isTrue, + ); + expect( + result.product!.nutriScoreDetails!.nutriScore2021!.data!.isWater, + isFalse, + ); + expect( + result.product!.nutriScoreDetails!.nutriScore2021!.data!.isCheese, + isFalse, + ); + expect( + result.product!.nutriScoreDetails!.nutriScore2021!.data!.isFat, + isFalse, + ); + expect( + result + .product!.nutriScoreDetails?.nutriScore2021?.notApplicableCategory, + category, + ); + + expect( + result.product!.nutriScoreDetails!.nutriScore2023, + isNotNull, + ); + expect( + result.product!.nutriScoreDetails!.nutriScore2023!.grade, + NutriScoreGrade.notApplicable, + ); + expect( + result.product!.nutriScoreDetails!.nutriScore2023!.score, + isNull, + ); + expect( + result.product!.nutriScoreDetails!.nutriScore2023!.data, + isNotNull, + ); + expect( + result + .product!.nutriScoreDetails!.nutriScore2023!.notApplicableCategory, + category, + ); + expect( + result.product!.nutriScoreDetails!.nutriScore2023!.data!.isBeverage, + isTrue, + ); + expect( + result + .product!.nutriScoreDetails!.nutriScore2023!.data!.isRedMeatProduct, + isFalse, + ); + expect( + result.product!.nutriScoreDetails!.nutriScore2023!.data! + .isFatOilNutsSeeds, + isFalse, + ); + expect( + result.product!.nutriScoreDetails!.nutriScore2023!.data!.isWater, + isFalse, + ); + expect( + result.product!.nutriScoreDetails!.nutriScore2023!.data!.isCheese, + isFalse, + ); + }); + + test('check nutriscore data for cookies', () async { + const String barcode = BARCODE_DANISH_BUTTER_COOKIES; + final ProductQueryConfiguration configurations = + ProductQueryConfiguration( + barcode, + language: OpenFoodFactsLanguage.GERMAN, + fields: [ProductField.NUTRISCORE_DETAILS], + version: ProductQueryVersion.v3, + ); + final ProductResultV3 result = await getProductV3InProd( + configurations, + ); + + expect(result.status, ProductResultV3.statusSuccess); + expect(result.barcode, barcode); + expect(result.product, isNotNull); + + expect(result.product!.nutriScoreDetails, isNotNull); + expect(result.product!.nutriScoreDetails!.nutriScore2021, isNotNull); + expect( + result.product!.nutriScoreDetails!.nutriScore2021!.grade, + NutriScoreGrade.e, + ); + expect( + result.product!.nutriScoreDetails!.nutriScore2021!.categoryAvailable, + isTrue, + ); + expect( + result.product!.nutriScoreDetails!.nutriScore2021!.score, + 23, + ); + expect( + result.product!.nutriScoreDetails!.nutriScore2021!.nutrientsAvailable, + isTrue, + ); + expect( + result.product!.nutriScoreDetails!.nutriScore2021!.nutriScoreComputed, + isTrue, + ); + expect( + result.product!.nutriScoreDetails!.nutriScore2021!.nutriScoreApplicable, + isTrue, + ); + expect( + result.product!.nutriScoreDetails!.nutriScore2021!.data, + isNotNull, + ); + expect( + result.product!.nutriScoreDetails!.nutriScore2021!.data!.isBeverage, + isFalse, + ); + expect( + result.product!.nutriScoreDetails!.nutriScore2021!.data!.isWater, + isFalse, + ); + expect( + result.product!.nutriScoreDetails!.nutriScore2021!.data!.isCheese, + isFalse, + ); + expect( + result.product!.nutriScoreDetails!.nutriScore2021!.data!.isFat, + isFalse, + ); + expect( + result + .product!.nutriScoreDetails?.nutriScore2021?.notApplicableCategory, + isNull, + ); + + expect( + result.product!.nutriScoreDetails!.nutriScore2023, + isNotNull, + ); + expect( + result.product!.nutriScoreDetails!.nutriScore2023!.grade, + NutriScoreGrade.e, + ); + expect( + result.product!.nutriScoreDetails!.nutriScore2023!.score, + 25, + ); + expect( + result.product!.nutriScoreDetails!.nutriScore2023!.data, + isNotNull, + ); + expect( + result + .product!.nutriScoreDetails!.nutriScore2023!.notApplicableCategory, + isNull, + ); + expect( + result.product!.nutriScoreDetails!.nutriScore2023!.data!.isBeverage, + isFalse, + ); + expect( + result + .product!.nutriScoreDetails!.nutriScore2023!.data!.isRedMeatProduct, + isFalse, + ); + expect( + result.product!.nutriScoreDetails!.nutriScore2023!.data! + .isFatOilNutsSeeds, + isFalse, + ); + expect( + result.product!.nutriScoreDetails!.nutriScore2023!.data!.isWater, + isFalse, + ); + expect( + result.product!.nutriScoreDetails!.nutriScore2023!.data!.isCheese, + isFalse, + ); + }); + test('get product Danish Butter Cookies & Chocolate Chip Cookies', () async { const String barcode = BARCODE_DANISH_BUTTER_COOKIES; From b07038718147f91bdf88d03fed395579b0ea2087 Mon Sep 17 00:00:00 2001 From: monsieurtanuki Date: Thu, 8 May 2025 21:00:54 +0200 Subject: [PATCH 02/12] minor refactoring --- lib/openfoodfacts.dart | 6 ++++++ lib/src/model/nutriscore/nutriscore_data_2023.dart | 10 +--------- test/api_get_product_test.dart | 1 - 3 files changed, 7 insertions(+), 10 deletions(-) diff --git a/lib/openfoodfacts.dart b/lib/openfoodfacts.dart index e6d28b1728..e64287e1e5 100644 --- a/lib/openfoodfacts.dart +++ b/lib/openfoodfacts.dart @@ -86,6 +86,12 @@ export 'src/model/taxonomy_packaging_recycling.dart'; export 'src/model/taxonomy_packaging_shape.dart'; export 'src/model/user.dart'; export 'src/model/user_agent.dart'; +export 'src/model/nutriscore/nutriscore_data_2021.dart'; +export 'src/model/nutriscore/nutriscore_data_2023.dart'; +export 'src/model/nutriscore/nutriscore_detail_2021.dart'; +export 'src/model/nutriscore/nutriscore_detail_2023.dart'; +export 'src/model/nutriscore/nutriscore_details.dart'; +export 'src/model/nutriscore/nutriscore_grade.dart'; export 'src/open_food_api_client.dart'; export 'src/open_food_search_api_client.dart'; export 'src/open_prices_api_client.dart'; diff --git a/lib/src/model/nutriscore/nutriscore_data_2023.dart b/lib/src/model/nutriscore/nutriscore_data_2023.dart index 2491b07b77..495bfc2b45 100644 --- a/lib/src/model/nutriscore/nutriscore_data_2023.dart +++ b/lib/src/model/nutriscore/nutriscore_data_2023.dart @@ -12,7 +12,7 @@ class NutriScoreData2023 extends JsonObject { toJson: JsonHelper.boolToJSON, fromJson: JsonHelper.boolFromJSON, ) - bool? countProteins; // or int? + bool? countProteins; @JsonKey(name: 'count_proteins_reason') String? countProteinsReason; @@ -58,14 +58,6 @@ class NutriScoreData2023 extends JsonObject { @JsonKey(name: 'negative_points_max') int? negativePointsMax; - /* - "positive_nutrients": [ - "proteins", - "fiber", - "fruits_vegetables_legumes" - ], - - */ @JsonKey(name: 'positive_points') int? positivePoints; diff --git a/test/api_get_product_test.dart b/test/api_get_product_test.dart index 26b7b1b7b0..1264b4f166 100644 --- a/test/api_get_product_test.dart +++ b/test/api_get_product_test.dart @@ -1,6 +1,5 @@ import 'package:http/http.dart' as http; import 'package:openfoodfacts/openfoodfacts.dart'; -import 'package:openfoodfacts/src/model/nutriscore/nutriscore_grade.dart'; import 'package:test/test.dart'; import 'test_constants.dart'; From 52d541fb2014ccd73e22eec32f6857a0ea853583 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olaf=20G=C3=B6rlitz?= Date: Sun, 30 Mar 2025 17:01:07 +0200 Subject: [PATCH 03/12] feat: 1072 - Add Nutri-Score details --- lib/openfoodfacts.dart | 3 + lib/src/model/nutriscore.dart | 208 +++++++++++++++++++++++++ lib/src/model/nutriscore_category.dart | 92 +++++++++++ lib/src/model/nutriscore_enums.dart | 61 ++++++++ lib/src/model/nutriscore_raw.dart | 87 +++++++++++ lib/src/model/product.dart | 11 ++ lib/src/model/product.g.dart | 1 + pubspec.yaml | 2 +- test/nutriscore_from_json_test.dart | 157 +++++++++++++++++++ 9 files changed, 621 insertions(+), 1 deletion(-) create mode 100644 lib/src/model/nutriscore.dart create mode 100644 lib/src/model/nutriscore_category.dart create mode 100644 lib/src/model/nutriscore_enums.dart create mode 100644 lib/src/model/nutriscore_raw.dart create mode 100644 test/nutriscore_from_json_test.dart diff --git a/lib/openfoodfacts.dart b/lib/openfoodfacts.dart index e64287e1e5..6714f29c7d 100644 --- a/lib/openfoodfacts.dart +++ b/lib/openfoodfacts.dart @@ -26,6 +26,9 @@ export 'src/model/nutrient_modifier.dart'; export 'src/model/nutrient.dart'; export 'src/model/nutrient_levels.dart'; export 'src/model/nutriments.dart'; +export 'src/model/nutriscore.dart'; +export 'src/model/nutriscore_category.dart'; +export 'src/model/nutriscore_enums.dart'; // export 'src/model/product_list.dart'; // not needed export 'src/model/ocr_ingredients_result.dart'; diff --git a/lib/src/model/nutriscore.dart b/lib/src/model/nutriscore.dart new file mode 100644 index 0000000000..6582858b47 --- /dev/null +++ b/lib/src/model/nutriscore.dart @@ -0,0 +1,208 @@ +import 'nutriscore_category.dart'; +import 'nutriscore_enums.dart'; +import 'nutriscore_raw.dart'; + +/// Represents the computed or inferred Nutri-Score of a product. +/// +/// The Nutri-Score may include different information depending on its [status] and [version]. +/// +/// The [NutriScoreStatus] can be: +/// - [unknown]: The Nutri-Score is unknown (see [missingData]). +/// - [notApplicable]: The Nutri-Score is not applicable (see [notApplicableCategory]). +/// - [computed]: The Nutri-Score is computed (see [grade], [category], [score]). +/// - [invalid]: The Nutri-Score is invalid (see [errorMessage]). +/// +/// Example: +/// ```dart +/// final nutriScore = product.nutriscores?[NutriScoreVersion.v2023]; +/// +/// final category = switch (nutriScore?.category?.as2023) { +/// NutriScoreCategory2023.water => 'Water', +/// NutriScoreCategory2023.beverage => 'Beverage', +/// NutriScoreCategory2023.fatOilNutsSeeds => 'Fat/Oil/Nuts/Seeds', +/// NutriScoreCategory2023.cheese => 'Cheese', +/// NutriScoreCategory2023.redMeatProduct => 'Red Meat Product', +/// NutriScoreCategory2023.general => 'General Food', +/// _ => 'Unknown Category', +/// }; +/// +/// final infoText = switch (nutriScore?.status) { +/// NutriScoreStatus.unknown => +/// 'Unknown (missing data: ${nutriScore?.missingData?.join(', ') ?? 'n/a'})', +/// NutriScoreStatus.notApplicable => +/// 'Not Applicable (category: ${nutriScore?.notApplicableCategory ?? 'n/a'})', +/// NutriScoreStatus.computed => +/// 'Computed (grade: ${nutriScore?.grade}, score: ${nutriScore?.score}, category: $category)', +/// NutriScoreStatus.invalid => +/// 'Invalid (error: ${nutriScore?.errorMessage ?? 'unknown error'})', +/// null => 'No Nutri-Score available', +/// }; +/// +/// print('Nutri-Score: $infoText'); +/// ``` +class NutriScore { + final NutriScoreStatus status; + final NutriScoreGrade? grade; + final NutriScoreVersion? version; + final NutriScoreCategory? category; + final int? score; + final String? notApplicableCategory; + final List? missingData; + final String? errorMessage; + + /// Private Constructor. + /// Use the factory constructors to create instances of [NutriScore]. + const NutriScore._({ + required this.status, + this.grade, + this.version, + this.category, + this.score, + this.notApplicableCategory, + this.errorMessage, + this.missingData, + }); + + /// Creates a [NutriScore] with [NutriScoreStatus.unknown]. + /// + /// This indicates that the Nutri-Score could not be computed due to missing data, + /// but partial information such as [version] and [category] may still be present. + /// + /// The [missingData] parameter indicates which data is missing. + factory NutriScore.unknown([ + NutriScoreVersion? version, + NutriScoreCategory? category, + List? missingData, + ]) { + return NutriScore._( + status: NutriScoreStatus.unknown, + version: version, + category: category, + missingData: missingData, + ); + } + + /// Creates a [NutriScore] with [NutriScoreStatus.notApplicable]. + /// + /// Used when the Nutri-Score does not apply to the product category (e.g., alcoholic beverages). + /// See [notApplicableCategory] for the explanation provided by the API. + factory NutriScore.notApplicable( + NutriScoreVersion version, + NutriScoreCategory? category, + List? missingData, + String? notApplicableCategory, + ) { + return NutriScore._( + status: NutriScoreStatus.notApplicable, + version: version, + category: category, + missingData: missingData, + notApplicableCategory: notApplicableCategory, + ); + } + + /// Creates a [NutriScore] with [NutriScoreStatus.computed]. + /// + /// This represents a successfully calculated Nutri-Score with a [grade], [score], and product [category]. + factory NutriScore.computed( + NutriScoreVersion version, + NutriScoreGrade grade, + NutriScoreCategory category, + int score, + ) { + return NutriScore._( + status: NutriScoreStatus.computed, + grade: grade, + version: version, + category: category, + score: score, + ); + } + + /// Creates a [NutriScore] with [NutriScoreStatus.invalid]. + /// + /// Used to represent an error in parsing or interpreting Nutri-Score data. + /// The [errorMessage] can be shown in the UI or logs. + factory NutriScore.invalid(String errorMessage) { + return NutriScore._( + status: NutriScoreStatus.invalid, + errorMessage: errorMessage, + ); + } + + /// Returns Map of [NutriScore]s for each version from a JSON map + static Map? fromJson(dynamic json) { + if (json == null) return null; + + if (json is! Map) { + throw FormatException( + 'Nutri-Score data is not Map, got ${json.runtimeType}'); + } + + final result = {}; + + for (final MapEntry(:key, :value) in json.entries) { + final version = NutriScoreVersion.tryParse(key); + if (version == null || value is! Map) { + continue; + } + + try { + final raw = NutriScoreRaw.fromJson(value); + result[version] = NutriScore._fromRaw(version, raw); + } catch (_) { + continue; + } + } + return result.isEmpty ? null : result; + } + + /// Parses the raw grade string from the API into a [NutriScoreStatus] and optional [NutriScoreGrade]. + /// + /// Returns a tuple where the status indicates whether the grade is computed, unknown, or not applicable. + static (NutriScoreStatus, NutriScoreGrade?) _parseGrade(String grade) => + switch (grade.toLowerCase()) { + 'not-applicable' => (NutriScoreStatus.notApplicable, null), + 'a' => (NutriScoreStatus.computed, NutriScoreGrade.A), + 'b' => (NutriScoreStatus.computed, NutriScoreGrade.B), + 'c' => (NutriScoreStatus.computed, NutriScoreGrade.C), + 'd' => (NutriScoreStatus.computed, NutriScoreGrade.D), + 'e' => (NutriScoreStatus.computed, NutriScoreGrade.E), + _ => (NutriScoreStatus.unknown, null), + }; + + /// Parses a [NutriScore] from raw API data using the given [version]. + /// + /// Internally determines the Nutri-Score status, grade, category, and score. + /// Falls back to [NutriScore.invalid] if critical fields are missing. + static NutriScore _fromRaw( + NutriScoreVersion version, + NutriScoreRaw raw, + ) { + final (status, grade) = _parseGrade(raw.grade ?? ''); + final category = NutriScoreCategory.fromRaw(version, raw.data); + final score = raw.score; + final missingData = [ + if (!raw.categoryAvailable) NutriScoreInput.category, + if (!raw.nutrientsAvailable) NutriScoreInput.nutrients, + ]; + + return switch (status) { + NutriScoreStatus.unknown => + NutriScore.unknown(version, category, missingData), + NutriScoreStatus.notApplicable => NutriScore.notApplicable( + version, category, missingData, raw.notApplicableCategory), + NutriScoreStatus.computed + when grade == null || category == null || score == null => + NutriScore.invalid('Missing required fields (grade, category, score)'), + NutriScoreStatus.computed => + NutriScore.computed(version, grade!, category!, score!), + _ => NutriScore.invalid('Unexpected NutriScore: ${raw.grade}'), + }; + } + + @override + String toString() { + return 'NutriScore{status: ${status.name}, grade: ${grade?.name}, version: $version, category: $category, score: $score, notApplicableCategory: $notApplicableCategory, errorMessage: $errorMessage, missingData: $missingData}'; + } +} diff --git a/lib/src/model/nutriscore_category.dart b/lib/src/model/nutriscore_category.dart new file mode 100644 index 0000000000..87bdf33b98 --- /dev/null +++ b/lib/src/model/nutriscore_category.dart @@ -0,0 +1,92 @@ +import 'nutriscore_enums.dart'; +import 'nutriscore_raw.dart'; + +/// Represents a product category used in Nutri-Score computation. +/// +/// This sealed class wraps version-specific enum values like [NutriScoreCategory2021] +/// or [NutriScoreCategory2023] while providing a unified interface. +sealed class NutriScoreCategory { + const NutriScoreCategory(); + + NutriScoreVersion get version; + + Enum get value; + + String get name => value.name; + + /// Returns the [NutriScoreCategory2021] enum value if this category belongs to version 2021, or null otherwise. + NutriScoreCategory2021? get as2021 => _getAs(); + + /// Returns the [NutriScoreCategory2023] enum value if this category belongs to version 2023, or null otherwise. + NutriScoreCategory2023? get as2023 => _getAs(); + + T? _getAs() => value is T ? value as T : null; + + /// Derives the [NutriScoreCategory] from [NutriScoreDataRaw] for the given [version]. + /// + /// Used when parsing raw Nutri-Score API responses. + static NutriScoreCategory? fromRaw( + NutriScoreVersion version, + NutriScoreDataRaw? data, + ) { + if (data == null) return null; + return switch (version) { + NutriScoreVersion.v2021 => _NutriScoreCategory2021(data.category2021), + NutriScoreVersion.v2023 => _NutriScoreCategory2023(data.category2023), + }; + } + + @override + String toString() => name; +} + +/// Internal wrapper for [NutriScoreCategory2021]. +/// Used by [NutriScoreCategory] to encapsulate version-specific category types. +class _NutriScoreCategory2021 extends NutriScoreCategory { + final NutriScoreCategory2021 _value; + + const _NutriScoreCategory2021(this._value); + + @override + Enum get value => _value; + + @override + NutriScoreVersion get version => NutriScoreVersion.v2021; +} + +/// Internal wrapper for [NutriScoreCategory2023]. +/// Used by [NutriScoreCategory] to encapsulate version-specific category types. +class _NutriScoreCategory2023 extends NutriScoreCategory { + final NutriScoreCategory2023 _value; + + const _NutriScoreCategory2023(this._value); + + @override + Enum get value => _value; + + @override + NutriScoreVersion get version => NutriScoreVersion.v2023; +} + +/// Extension to infer [NutriScoreCategory2021] or [NutriScoreCategory2023] +/// from raw boolean flags in [NutriScoreDataRaw]. +extension NutriScoreDataExtension on NutriScoreDataRaw { + NutriScoreCategory2021 get category2021 { + // water must be checked first to avoid beverage+water conflict + if (isWater) return NutriScoreCategory2021.water; + if (isBeverage) return NutriScoreCategory2021.beverage; + if (isFat) return NutriScoreCategory2021.fat; + if (isCheese) return NutriScoreCategory2021.cheese; + return NutriScoreCategory2021.general; + } + + NutriScoreCategory2023 get category2023 { + // water must be checked first to avoid beverage+water conflict + if (isWater) return NutriScoreCategory2023.water; + if (isBeverage) return NutriScoreCategory2023.beverage; + if (isFatOilNutsSeeds) return NutriScoreCategory2023.fatOilNutsSeeds; + if (isCheese) return NutriScoreCategory2023.cheese; + if (isRedMeatProduct) return NutriScoreCategory2023.redMeatProduct; + return NutriScoreCategory2023.general; + } +} diff --git a/lib/src/model/nutriscore_enums.dart b/lib/src/model/nutriscore_enums.dart new file mode 100644 index 0000000000..cc14ff2120 --- /dev/null +++ b/lib/src/model/nutriscore_enums.dart @@ -0,0 +1,61 @@ +enum NutriScoreStatus { unknown, notApplicable, computed, invalid } + +enum NutriScoreGrade { A, B, C, D, E } + +enum NutriScoreInput { category, nutrients, ingredients } + +enum NutriScoreVersion { + v2021, + v2023; + + /// Returns the name of the NutriScore version. + String get name => switch (this) { + NutriScoreVersion.v2021 => '2021', + NutriScoreVersion.v2023 => '2023', + }; + + /// Parses a Nutri-Score version from a string such as '2021' or '2023'. + /// + /// Returns `null` if the string does not match any known version. + static NutriScoreVersion? tryParse(String? value) { + return switch (value?.toLowerCase()) { + '2021' => NutriScoreVersion.v2021, + '2023' => NutriScoreVersion.v2023, + _ => null, + }; + } +} + +enum NutriScoreCategory2021 { + general, + cheese, + fat, + beverage, + water, +} + +enum NutriScoreCategory2023 { + general, + cheese, + redMeatProduct, + fatOilNutsSeeds, + beverage, + water, +} + +extension NutriScoreCategoryExtension2021 on NutriScoreCategory2021 { + bool get isWater => this == NutriScoreCategory2021.water; + bool get isBeverage => this == NutriScoreCategory2021.beverage; + bool get isFat => this == NutriScoreCategory2021.fat; + bool get isCheese => this == NutriScoreCategory2021.cheese; + bool get isGeneral => this == NutriScoreCategory2021.general; +} + +extension NutriScoreCategoryExtension2023 on NutriScoreCategory2023 { + bool get isWater => this == NutriScoreCategory2023.water; + bool get isBeverage => this == NutriScoreCategory2023.beverage; + bool get isFatOilNutsSeeds => this == NutriScoreCategory2023.fatOilNutsSeeds; + bool get isCheese => this == NutriScoreCategory2023.cheese; + bool get isRedMeatProduct => this == NutriScoreCategory2023.redMeatProduct; + bool get isGeneral => this == NutriScoreCategory2023.general; +} diff --git a/lib/src/model/nutriscore_raw.dart b/lib/src/model/nutriscore_raw.dart new file mode 100644 index 0000000000..1755f77a9f --- /dev/null +++ b/lib/src/model/nutriscore_raw.dart @@ -0,0 +1,87 @@ +/// Raw model representing Nutri-Score details, including grade, score, and calculation data. +class NutriScoreRaw { + final String? grade; + final int? score; + final NutriScoreDataRaw? data; + final String? notApplicableCategory; + final bool categoryAvailable; + final bool nutrientsAvailable; + + /// The constructor is private to ensure that the object is created only through the factory method. + NutriScoreRaw._({ + this.grade, + this.score, + this.data, + this.notApplicableCategory, + this.categoryAvailable = false, + this.nutrientsAvailable = false, + }); + + factory NutriScoreRaw.fromJson(Map json) { + return NutriScoreRaw._( + grade: json['grade'] as String?, + score: json['score'] as int?, + categoryAvailable: parseBool(json, 'category_available'), + nutrientsAvailable: parseBool(json, 'nutrients_available'), + notApplicableCategory: json['not_applicable_category'] as String?, + data: json['data'] != null + ? NutriScoreDataRaw.fromJson(json['data']) + : null, + ); + } + + @override + String toString() { + return 'Nutri-Score(grade: $grade, score: $score, $data)'; + } +} + +/// Raw model for additional data used in Nutri-Score 2021/2023 calculation, indicating specific product characteristics. +class NutriScoreDataRaw { + final bool isBeverage; + final bool isCheese; + final bool isFat; // 2021 + final bool isFatOilNutsSeeds; // 2023 + final bool isRedMeatProduct; // 2023 + final bool isWater; + + /// The constructor is private to ensure that the object is created only through the factory method. + NutriScoreDataRaw._({ + this.isBeverage = false, + this.isCheese = false, + this.isFat = false, + this.isFatOilNutsSeeds = false, + this.isRedMeatProduct = false, + this.isWater = false, + }); + + factory NutriScoreDataRaw.fromJson(Map json) { + return NutriScoreDataRaw._( + isBeverage: parseBool(json, 'is_beverage'), + isCheese: parseBool(json, 'is_cheese'), + isFat: parseBool(json, 'is_fat'), // 2021 + isFatOilNutsSeeds: parseBool(json, 'is_fat_oil_nuts_seeds'), // 2023 + isRedMeatProduct: parseBool(json, 'is_red_meat_product'), // 2023 + isWater: parseBool(json, 'is_water'), + ); + } + + @override + String toString() { + final flags = [ + if (isBeverage) 'isBeverage', + if (isCheese) 'isCheese', + if (isFat) 'isFat', // 2021 + if (isFatOilNutsSeeds) 'isFatOilNutsSeeds', // 2023 + if (isRedMeatProduct) 'isRedMeatProduct', // 2023 + if (isWater) 'isWater', + ]; + + return 'data(${flags.join(', ')})'; + } +} + +bool parseBool(Map json, String key) { + final value = json[key]; + return value == true || value == 1 || value == '1'; +} diff --git a/lib/src/model/product.dart b/lib/src/model/product.dart index eadd5e2786..b80cf0bdff 100644 --- a/lib/src/model/product.dart +++ b/lib/src/model/product.dart @@ -15,6 +15,8 @@ import 'ingredients_analysis_tags.dart'; import 'knowledge_panels.dart'; import 'nutrient_levels.dart'; import 'nutriments.dart'; +import 'nutriscore.dart'; +import 'nutriscore_enums.dart'; import 'owner_field.dart'; import 'product_image.dart'; import 'product_packaging.dart'; @@ -416,6 +418,14 @@ class Product extends JsonObject { @JsonKey(name: 'nutrition_grade_fr') String? nutriscore; + /// Map of computed Nutri-Scores for different versions (e.g. 2021, 2023). + @JsonKey( + name: 'nutriscore', + fromJson: NutriScore.fromJson, + includeToJson: false, + ) + Map? nutriscores; + @JsonKey(name: 'compared_to_category') String? comparedToCategory; @JsonKey(name: 'categories') @@ -672,6 +682,7 @@ class Product extends JsonObject { this.nutrimentEnergyUnit, this.nutrimentDataPer, this.nutriscore, + this.nutriscores, this.categories, this.categoriesTags, this.categoriesTagsInLanguages, diff --git a/lib/src/model/product.g.dart b/lib/src/model/product.g.dart index 9bd132b74f..044c8f77fc 100644 --- a/lib/src/model/product.g.dart +++ b/lib/src/model/product.g.dart @@ -57,6 +57,7 @@ Product _$ProductFromJson(Map json) => Product( nutrimentEnergyUnit: json['nutriment_energy_unit'] as String?, nutrimentDataPer: json['nutrition_data_per'] as String?, nutriscore: json['nutrition_grade_fr'] as String?, + nutriscores: NutriScore.fromJson(json['nutriscore']), categories: json['categories'] as String?, categoriesTags: (json['categories_tags'] as List?) ?.map((e) => e as String) diff --git a/pubspec.yaml b/pubspec.yaml index b1161dc063..5fa7c5ec7b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -5,7 +5,7 @@ version: 3.22.0 homepage: https://github.com/openfoodfacts/openfoodfacts-dart environment: - sdk: '>=2.17.0 <4.0.0' + sdk: '>=3.0.0 <4.0.0' dependencies: json_annotation: ^4.9.0 diff --git a/test/nutriscore_from_json_test.dart b/test/nutriscore_from_json_test.dart new file mode 100644 index 0000000000..3fca9e57ae --- /dev/null +++ b/test/nutriscore_from_json_test.dart @@ -0,0 +1,157 @@ +import 'package:openfoodfacts/openfoodfacts.dart'; +import 'package:test/test.dart'; + +void main() { + group('NutriScore.fromJson', () { + test('returns null when input is null', () { + final result = NutriScore.fromJson(null); + expect(result, isNull); + }); + + test('throws FormatException on non-map input', () { + expect( + () => NutriScore.fromJson(['not', 'a', 'map']), + throwsA(isA()), + ); + }); + + test('ignores entries with unknown versions', () { + final result = NutriScore.fromJson({ + 'invalid_version': {'grade': 'a'} + }); + expect(result, isNull); + }); + + test('ignores entries with invalid structure', () { + final result = NutriScore.fromJson({ + '2023': 'not a map', + '2021': null, + }); + expect(result, isNull); + }); + + test('parses valid NutriScore for version 2023', () { + final json = { + '2023': { + 'grade': 'b', + 'score': 2, + 'category_available': 1, + 'nutrients_available': 1, + 'data': { + 'is_beverage': 1, + 'is_cheese': 0, + 'is_fat_oil_nuts_seeds': 0, + 'is_red_meat_product': 0, + 'is_water': 0, + } + } + }; + + final result = NutriScore.fromJson(json); + expect(result, isNotNull); + expect(result!.length, 1); + expect(result, contains(NutriScoreVersion.v2023)); + + final nutriScore = result[NutriScoreVersion.v2023]!; + expect(nutriScore.version, NutriScoreVersion.v2023); + expect(nutriScore.status, NutriScoreStatus.computed); + expect(nutriScore.grade, NutriScoreGrade.B); + expect(nutriScore.score, 2); + expect(nutriScore.category?.version, NutriScoreVersion.v2023); + expect(nutriScore.category?.as2023, NutriScoreCategory2023.beverage); + }); + + test('parses unknown NutriScore for version 2023', () { + final json = { + '2023': { + 'grade': 'unknown', + 'category_available': 1, + 'data': { + 'is_beverage': 0, + 'is_cheese': 0, + 'is_fat_oil_nuts_seeds': 0, + 'is_red_meat_product': 0, + 'is_water': 0, + } + } + }; + + final result = NutriScore.fromJson(json); + expect(result, isNotNull); + expect(result!.length, 1); + expect(result, contains(NutriScoreVersion.v2023)); + + final nutriScore = result[NutriScoreVersion.v2023]!; + expect(nutriScore.version, NutriScoreVersion.v2023); + expect(nutriScore.status, NutriScoreStatus.unknown); + expect(nutriScore.grade, isNull); + expect(nutriScore.score, isNull); + expect(nutriScore.category?.version, NutriScoreVersion.v2023); + expect(nutriScore.category?.as2023, NutriScoreCategory2023.general); + expect(nutriScore.missingData, contains(NutriScoreInput.nutrients)); + }); + + test('parses not applicable NutriScore for version 2023', () { + final json = { + '2023': { + 'grade': 'not-applicable', + 'category_available': 1, + 'nutrients_available': 1, + 'not_applicable_category': 'en:alcoholic-beverages', + 'data': { + 'is_beverage': 1, + 'is_cheese': 0, + 'is_fat_oil_nuts_seeds': 0, + 'is_red_meat_product': 0, + 'is_water': 0, + } + } + }; + + final result = NutriScore.fromJson(json); + expect(result, isNotNull); + expect(result!.length, 1); + expect(result, contains(NutriScoreVersion.v2023)); + + final nutriScore = result[NutriScoreVersion.v2023]!; + expect(nutriScore.version, NutriScoreVersion.v2023); + expect(nutriScore.status, NutriScoreStatus.notApplicable); + expect(nutriScore.grade, isNull); + expect(nutriScore.score, isNull); + expect(nutriScore.category?.as2023, NutriScoreCategory2023.beverage); + expect(nutriScore.missingData, isEmpty); + }); + + test('parses inconsistently formatted NutriScore data for version 2021', + () { + final json = { + '2021': { + 'grade': 'not-applicable', + 'category_available': 1, + 'nutrients_available': 1, + 'not_applicable_category': 'en:alcoholic-beverages', + 'data': { + 'is_beverage': 1, + 'is_cheese': 0, + 'is_fat': 0, + 'is_red_meat_product': 0, + 'is_water': "1", + } + } + }; + + final result = NutriScore.fromJson(json); + expect(result, isNotNull); + expect(result!.length, 1); + expect(result, contains(NutriScoreVersion.v2021)); + + final nutriScore = result[NutriScoreVersion.v2021]!; + expect(nutriScore.version, NutriScoreVersion.v2021); + expect(nutriScore.status, NutriScoreStatus.notApplicable); + expect(nutriScore.grade, isNull); + expect(nutriScore.score, isNull); + expect(nutriScore.category?.as2021, NutriScoreCategory2021.water); + expect(nutriScore.missingData, isEmpty); + }); + }); +} From 772358676d0ded29668e4311ecf87e8bc5ea6cd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olaf=20G=C3=B6rlitz?= Date: Wed, 7 May 2025 19:21:44 +0200 Subject: [PATCH 04/12] improvements after code review - replaced factories with named constructors - using JsonObject.parseBool - added toJson() - put all json-related code into new json helper - added == operator and hashcode - added tests for toJson() and fromJson() - added tests to live product fetching - other minor improvements --- lib/openfoodfacts.dart | 1 + lib/src/model/nutriscore.dart | 223 ++++++++++----------------- lib/src/model/nutriscore_enums.dart | 18 --- lib/src/model/nutriscore_raw.dart | 75 +++------ lib/src/model/product.dart | 9 +- lib/src/model/product.g.dart | 4 +- lib/src/utils/nutriscore_helper.dart | 148 ++++++++++++++++++ test/api_get_product_test.dart | 24 +++ test/nutriscore_from_json_test.dart | 173 ++++++++++++--------- 9 files changed, 385 insertions(+), 290 deletions(-) create mode 100644 lib/src/utils/nutriscore_helper.dart diff --git a/lib/openfoodfacts.dart b/lib/openfoodfacts.dart index 6714f29c7d..075e3c6868 100644 --- a/lib/openfoodfacts.dart +++ b/lib/openfoodfacts.dart @@ -162,6 +162,7 @@ export 'src/utils/invalid_barcodes.dart'; export 'src/utils/json_helper.dart'; export 'src/utils/language_helper.dart'; export 'src/utils/nutriments_helper.dart'; +export 'src/utils/nutriscore_helper.dart'; export 'src/utils/ocr_field.dart'; export 'src/utils/open_food_api_configuration.dart'; export 'src/utils/pnns_groups.dart'; diff --git a/lib/src/model/nutriscore.dart b/lib/src/model/nutriscore.dart index 6582858b47..0c2c83badd 100644 --- a/lib/src/model/nutriscore.dart +++ b/lib/src/model/nutriscore.dart @@ -1,6 +1,5 @@ import 'nutriscore_category.dart'; import 'nutriscore_enums.dart'; -import 'nutriscore_raw.dart'; /// Represents the computed or inferred Nutri-Score of a product. /// @@ -14,7 +13,7 @@ import 'nutriscore_raw.dart'; /// /// Example: /// ```dart -/// final nutriScore = product.nutriscores?[NutriScoreVersion.v2023]; +/// final nutriScore = product.nutriscoreDetails?[NutriScoreVersion.v2023]; /// /// final category = switch (nutriScore?.category?.as2023) { /// NutriScoreCategory2023.water => 'Water', @@ -47,162 +46,104 @@ class NutriScore { final NutriScoreCategory? category; final int? score; final String? notApplicableCategory; - final List? missingData; + final List missingData; final String? errorMessage; - /// Private Constructor. - /// Use the factory constructors to create instances of [NutriScore]. - const NutriScore._({ - required this.status, - this.grade, - this.version, - this.category, - this.score, - this.notApplicableCategory, - this.errorMessage, - this.missingData, - }); - /// Creates a [NutriScore] with [NutriScoreStatus.unknown]. /// - /// This indicates that the Nutri-Score could not be computed due to missing data, - /// but partial information such as [version] and [category] may still be present. - /// + /// Used when Nutri-Score computation is not possible due to missing input. + /// Partial data like [version] and [category] may still be present. /// The [missingData] parameter indicates which data is missing. - factory NutriScore.unknown([ - NutriScoreVersion? version, - NutriScoreCategory? category, - List? missingData, - ]) { - return NutriScore._( - status: NutriScoreStatus.unknown, - version: version, - category: category, - missingData: missingData, - ); - } + NutriScore.unknown([ + this.version, + this.category, + this.missingData = const [], + ]) : status = NutriScoreStatus.unknown, + grade = null, + score = null, + notApplicableCategory = null, + errorMessage = null; /// Creates a [NutriScore] with [NutriScoreStatus.notApplicable]. /// - /// Used when the Nutri-Score does not apply to the product category (e.g., alcoholic beverages). - /// See [notApplicableCategory] for the explanation provided by the API. - factory NutriScore.notApplicable( - NutriScoreVersion version, - NutriScoreCategory? category, - List? missingData, - String? notApplicableCategory, - ) { - return NutriScore._( - status: NutriScoreStatus.notApplicable, - version: version, - category: category, - missingData: missingData, - notApplicableCategory: notApplicableCategory, - ); - } + /// Used when the Nutri-Score does not apply to the product category + /// (e.g., alcoholic beverages). See [notApplicableCategory]. + NutriScore.notApplicable( + NutriScoreVersion this.version, + this.category, + this.missingData, + this.notApplicableCategory, + ) : status = NutriScoreStatus.notApplicable, + grade = null, + score = null, + errorMessage = null; /// Creates a [NutriScore] with [NutriScoreStatus.computed]. /// - /// This represents a successfully calculated Nutri-Score with a [grade], [score], and product [category]. - factory NutriScore.computed( - NutriScoreVersion version, - NutriScoreGrade grade, - NutriScoreCategory category, - int score, - ) { - return NutriScore._( - status: NutriScoreStatus.computed, - grade: grade, - version: version, - category: category, - score: score, - ); - } + /// Used when the Nutri-Score has been successfully calculated. + /// See [grade], [score], and product [category]. + NutriScore.computed( + NutriScoreVersion this.version, + NutriScoreGrade this.grade, + NutriScoreCategory this.category, + int this.score, + ) : status = NutriScoreStatus.computed, + notApplicableCategory = null, + errorMessage = null, + missingData = const []; /// Creates a [NutriScore] with [NutriScoreStatus.invalid]. /// - /// Used to represent an error in parsing or interpreting Nutri-Score data. - /// The [errorMessage] can be shown in the UI or logs. - factory NutriScore.invalid(String errorMessage) { - return NutriScore._( - status: NutriScoreStatus.invalid, - errorMessage: errorMessage, - ); - } + /// Used to represent an unexpected or malformed Nutri-Score entry. + /// See [errorMessage] for details. + NutriScore.invalid(String this.errorMessage) + : status = NutriScoreStatus.invalid, + version = null, + grade = null, + category = null, + score = null, + notApplicableCategory = null, + missingData = const []; - /// Returns Map of [NutriScore]s for each version from a JSON map - static Map? fromJson(dynamic json) { - if (json == null) return null; - - if (json is! Map) { - throw FormatException( - 'Nutri-Score data is not Map, got ${json.runtimeType}'); - } - - final result = {}; - - for (final MapEntry(:key, :value) in json.entries) { - final version = NutriScoreVersion.tryParse(key); - if (version == null || value is! Map) { - continue; - } - - try { - final raw = NutriScoreRaw.fromJson(value); - result[version] = NutriScore._fromRaw(version, raw); - } catch (_) { - continue; - } - } - return result.isEmpty ? null : result; + @override + String toString() { + return 'NutriScore{' + 'status: ${status.name}, ' + 'grade: ${grade?.name}, ' + 'version: ${version?.name}, ' + 'category: $category, ' + 'score: $score, ' + 'notApplicableCategory: $notApplicableCategory, ' + 'errorMessage: $errorMessage, ' + 'missingData: $missingData' + '}'; } - /// Parses the raw grade string from the API into a [NutriScoreStatus] and optional [NutriScoreGrade]. - /// - /// Returns a tuple where the status indicates whether the grade is computed, unknown, or not applicable. - static (NutriScoreStatus, NutriScoreGrade?) _parseGrade(String grade) => - switch (grade.toLowerCase()) { - 'not-applicable' => (NutriScoreStatus.notApplicable, null), - 'a' => (NutriScoreStatus.computed, NutriScoreGrade.A), - 'b' => (NutriScoreStatus.computed, NutriScoreGrade.B), - 'c' => (NutriScoreStatus.computed, NutriScoreGrade.C), - 'd' => (NutriScoreStatus.computed, NutriScoreGrade.D), - 'e' => (NutriScoreStatus.computed, NutriScoreGrade.E), - _ => (NutriScoreStatus.unknown, null), - }; - - /// Parses a [NutriScore] from raw API data using the given [version]. - /// - /// Internally determines the Nutri-Score status, grade, category, and score. - /// Falls back to [NutriScore.invalid] if critical fields are missing. - static NutriScore _fromRaw( - NutriScoreVersion version, - NutriScoreRaw raw, - ) { - final (status, grade) = _parseGrade(raw.grade ?? ''); - final category = NutriScoreCategory.fromRaw(version, raw.data); - final score = raw.score; - final missingData = [ - if (!raw.categoryAvailable) NutriScoreInput.category, - if (!raw.nutrientsAvailable) NutriScoreInput.nutrients, - ]; - - return switch (status) { - NutriScoreStatus.unknown => - NutriScore.unknown(version, category, missingData), - NutriScoreStatus.notApplicable => NutriScore.notApplicable( - version, category, missingData, raw.notApplicableCategory), - NutriScoreStatus.computed - when grade == null || category == null || score == null => - NutriScore.invalid('Missing required fields (grade, category, score)'), - NutriScoreStatus.computed => - NutriScore.computed(version, grade!, category!, score!), - _ => NutriScore.invalid('Unexpected NutriScore: ${raw.grade}'), - }; - } + @override + bool operator ==(Object other) => + identical(this, other) || + other is NutriScore && + status == other.status && + grade == other.grade && + version == other.version && + category?.value == other.category?.value && + score == other.score && + notApplicableCategory == other.notApplicableCategory && + errorMessage == other.errorMessage && + _unorderedEquals(missingData, other.missingData); @override - String toString() { - return 'NutriScore{status: ${status.name}, grade: ${grade?.name}, version: $version, category: $category, score: $score, notApplicableCategory: $notApplicableCategory, errorMessage: $errorMessage, missingData: $missingData}'; - } + int get hashCode => Object.hash( + status, + grade, + version, + category?.value, + score, + notApplicableCategory, + errorMessage, + missingData.fold(0, (acc, e) => acc ^ e.hashCode), + ); + + bool _unorderedEquals(List a, List b) => + Set.from(a).containsAll(b) && Set.from(b).containsAll(a); } diff --git a/lib/src/model/nutriscore_enums.dart b/lib/src/model/nutriscore_enums.dart index cc14ff2120..099b66e09d 100644 --- a/lib/src/model/nutriscore_enums.dart +++ b/lib/src/model/nutriscore_enums.dart @@ -15,7 +15,6 @@ enum NutriScoreVersion { }; /// Parses a Nutri-Score version from a string such as '2021' or '2023'. - /// /// Returns `null` if the string does not match any known version. static NutriScoreVersion? tryParse(String? value) { return switch (value?.toLowerCase()) { @@ -42,20 +41,3 @@ enum NutriScoreCategory2023 { beverage, water, } - -extension NutriScoreCategoryExtension2021 on NutriScoreCategory2021 { - bool get isWater => this == NutriScoreCategory2021.water; - bool get isBeverage => this == NutriScoreCategory2021.beverage; - bool get isFat => this == NutriScoreCategory2021.fat; - bool get isCheese => this == NutriScoreCategory2021.cheese; - bool get isGeneral => this == NutriScoreCategory2021.general; -} - -extension NutriScoreCategoryExtension2023 on NutriScoreCategory2023 { - bool get isWater => this == NutriScoreCategory2023.water; - bool get isBeverage => this == NutriScoreCategory2023.beverage; - bool get isFatOilNutsSeeds => this == NutriScoreCategory2023.fatOilNutsSeeds; - bool get isCheese => this == NutriScoreCategory2023.cheese; - bool get isRedMeatProduct => this == NutriScoreCategory2023.redMeatProduct; - bool get isGeneral => this == NutriScoreCategory2023.general; -} diff --git a/lib/src/model/nutriscore_raw.dart b/lib/src/model/nutriscore_raw.dart index 1755f77a9f..43a705a73e 100644 --- a/lib/src/model/nutriscore_raw.dart +++ b/lib/src/model/nutriscore_raw.dart @@ -1,4 +1,7 @@ -/// Raw model representing Nutri-Score details, including grade, score, and calculation data. +import '../interface/json_object.dart'; + +/// Raw model representing Nutri-Score details, including grade, score, +/// and calculation data. class NutriScoreRaw { final String? grade; final int? score; @@ -7,36 +10,22 @@ class NutriScoreRaw { final bool categoryAvailable; final bool nutrientsAvailable; - /// The constructor is private to ensure that the object is created only through the factory method. - NutriScoreRaw._({ - this.grade, - this.score, - this.data, - this.notApplicableCategory, - this.categoryAvailable = false, - this.nutrientsAvailable = false, - }); - - factory NutriScoreRaw.fromJson(Map json) { - return NutriScoreRaw._( - grade: json['grade'] as String?, - score: json['score'] as int?, - categoryAvailable: parseBool(json, 'category_available'), - nutrientsAvailable: parseBool(json, 'nutrients_available'), - notApplicableCategory: json['not_applicable_category'] as String?, - data: json['data'] != null - ? NutriScoreDataRaw.fromJson(json['data']) - : null, - ); - } + NutriScoreRaw.fromJson(Map json) + : grade = json['grade'] as String?, + score = json['score'] as int?, + categoryAvailable = JsonObject.parseBool(json['category_available']), + nutrientsAvailable = JsonObject.parseBool(json['nutrients_available']), + notApplicableCategory = json['not_applicable_category'] as String?, + data = json['data'] != null + ? NutriScoreDataRaw.fromJson(json['data']) + : null; @override - String toString() { - return 'Nutri-Score(grade: $grade, score: $score, $data)'; - } + String toString() => 'Nutri-Score(grade: $grade, score: $score, $data)'; } -/// Raw model for additional data used in Nutri-Score 2021/2023 calculation, indicating specific product characteristics. +/// Raw model for additional data used in Nutri-Score 2021/2023 calculation, +/// indicating specific product characteristics. class NutriScoreDataRaw { final bool isBeverage; final bool isCheese; @@ -45,26 +34,13 @@ class NutriScoreDataRaw { final bool isRedMeatProduct; // 2023 final bool isWater; - /// The constructor is private to ensure that the object is created only through the factory method. - NutriScoreDataRaw._({ - this.isBeverage = false, - this.isCheese = false, - this.isFat = false, - this.isFatOilNutsSeeds = false, - this.isRedMeatProduct = false, - this.isWater = false, - }); - - factory NutriScoreDataRaw.fromJson(Map json) { - return NutriScoreDataRaw._( - isBeverage: parseBool(json, 'is_beverage'), - isCheese: parseBool(json, 'is_cheese'), - isFat: parseBool(json, 'is_fat'), // 2021 - isFatOilNutsSeeds: parseBool(json, 'is_fat_oil_nuts_seeds'), // 2023 - isRedMeatProduct: parseBool(json, 'is_red_meat_product'), // 2023 - isWater: parseBool(json, 'is_water'), - ); - } + NutriScoreDataRaw.fromJson(Map json) + : isBeverage = JsonObject.parseBool(json['is_beverage']), + isCheese = JsonObject.parseBool(json['is_cheese']), + isFat = JsonObject.parseBool(json['is_fat']), + isFatOilNutsSeeds = JsonObject.parseBool(json['is_fat_oil_nuts_seeds']), + isRedMeatProduct = JsonObject.parseBool(json['is_red_meat_product']), + isWater = JsonObject.parseBool(json['is_water']); @override String toString() { @@ -80,8 +56,3 @@ class NutriScoreDataRaw { return 'data(${flags.join(', ')})'; } } - -bool parseBool(Map json, String key) { - final value = json[key]; - return value == true || value == 1 || value == '1'; -} diff --git a/lib/src/model/product.dart b/lib/src/model/product.dart index b80cf0bdff..e1fb5202ef 100644 --- a/lib/src/model/product.dart +++ b/lib/src/model/product.dart @@ -3,6 +3,7 @@ import 'package:json_annotation/json_annotation.dart'; import '../interface/json_object.dart'; import '../utils/json_helper.dart'; import '../utils/language_helper.dart'; +import '../utils/nutriscore_helper.dart'; import '../utils/product_fields.dart'; import 'nutriscore/nutriscore_details.dart'; import 'additives.dart'; @@ -421,10 +422,10 @@ class Product extends JsonObject { /// Map of computed Nutri-Scores for different versions (e.g. 2021, 2023). @JsonKey( name: 'nutriscore', - fromJson: NutriScore.fromJson, - includeToJson: false, + fromJson: NutriScoreHelper.fromJson, + toJson: NutriScoreHelper.toJson, ) - Map? nutriscores; + Map? nutriscoreDetails; @JsonKey(name: 'compared_to_category') String? comparedToCategory; @@ -682,7 +683,7 @@ class Product extends JsonObject { this.nutrimentEnergyUnit, this.nutrimentDataPer, this.nutriscore, - this.nutriscores, + this.nutriscoreDetails, this.categories, this.categoriesTags, this.categoriesTagsInLanguages, diff --git a/lib/src/model/product.g.dart b/lib/src/model/product.g.dart index 044c8f77fc..010adfaba8 100644 --- a/lib/src/model/product.g.dart +++ b/lib/src/model/product.g.dart @@ -57,7 +57,7 @@ Product _$ProductFromJson(Map json) => Product( nutrimentEnergyUnit: json['nutriment_energy_unit'] as String?, nutrimentDataPer: json['nutrition_data_per'] as String?, nutriscore: json['nutrition_grade_fr'] as String?, - nutriscores: NutriScore.fromJson(json['nutriscore']), + nutriscoreDetails: NutriScoreHelper.fromJson(json['nutriscore']), categories: json['categories'] as String?, categoriesTags: (json['categories_tags'] as List?) ?.map((e) => e as String) @@ -288,6 +288,8 @@ Map _$ProductToJson(Product instance) { writeNotNull('nutriscore', instance.nutriScoreDetails); writeNotNull('nutrition_data_per', instance.nutrimentDataPer); writeNotNull('nutrition_grade_fr', instance.nutriscore); + writeNotNull( + 'nutriscore', NutriScoreHelper.toJson(instance.nutriscoreDetails)); writeNotNull('compared_to_category', instance.comparedToCategory); writeNotNull('categories', instance.categories); writeNotNull('categories_tags', instance.categoriesTags); diff --git a/lib/src/utils/nutriscore_helper.dart b/lib/src/utils/nutriscore_helper.dart new file mode 100644 index 0000000000..9b423b14e0 --- /dev/null +++ b/lib/src/utils/nutriscore_helper.dart @@ -0,0 +1,148 @@ +import '../model/nutriscore.dart'; +import '../model/nutriscore_category.dart'; +import '../model/nutriscore_enums.dart'; +import '../model/nutriscore_raw.dart'; + +class NutriScoreHelper { + /// Returns Map of [NutriScore]s for each version from a JSON map + static Map? fromJson(dynamic json) { + if (json == null) return null; + + if (json is! Map) { + throw FormatException( + 'Nutri-Score data is not Map, got ${json.runtimeType}'); + } + + final result = {}; + + for (final MapEntry(:key, :value) in json.entries) { + final version = NutriScoreVersion.tryParse(key); + if (version == null || value is! Map) { + continue; + } + + try { + final raw = NutriScoreRaw.fromJson(value); + result[version] = _fromRaw(version, raw); + } catch (_) { + continue; + } + } + return result.isEmpty ? null : result; + } + + /// Converts a Map of [NutriScore]s to a JSON map. + static toJson(Map? nutriscoreDetails) { + if (nutriscoreDetails == null) return null; + + return { + for (final MapEntry(:key, :value) in nutriscoreDetails.entries) + key.name: value.toJson(), + }; + } + + /// Parses the raw grade string from the API into a [NutriScoreStatus] + /// and optional [NutriScoreGrade]. + /// + /// Returns a tuple where the status indicates whether the grade is + /// computed, unknown, or not applicable. + static (NutriScoreStatus, NutriScoreGrade?) _parseGrade(String grade) => + switch (grade.toLowerCase()) { + 'not-applicable' => (NutriScoreStatus.notApplicable, null), + 'a' => (NutriScoreStatus.computed, NutriScoreGrade.A), + 'b' => (NutriScoreStatus.computed, NutriScoreGrade.B), + 'c' => (NutriScoreStatus.computed, NutriScoreGrade.C), + 'd' => (NutriScoreStatus.computed, NutriScoreGrade.D), + 'e' => (NutriScoreStatus.computed, NutriScoreGrade.E), + _ => (NutriScoreStatus.unknown, null), + }; + + /// Parses a [NutriScore] from raw API data using the given [version]. + /// + /// Internally determines the Nutri-Score status, grade, category, and score. + /// Falls back to [NutriScore.invalid] if critical fields are missing. + static NutriScore _fromRaw( + NutriScoreVersion version, + NutriScoreRaw raw, + ) { + final (status, grade) = _parseGrade(raw.grade ?? ''); + final category = NutriScoreCategory.fromRaw(version, raw.data); + final score = raw.score; + final missingData = [ + if (!raw.categoryAvailable) NutriScoreInput.category, + if (!raw.nutrientsAvailable) NutriScoreInput.nutrients, + ]; + + return switch (status) { + NutriScoreStatus.unknown => + NutriScore.unknown(version, category, missingData), + NutriScoreStatus.notApplicable => NutriScore.notApplicable( + version, category, missingData, raw.notApplicableCategory), + NutriScoreStatus.computed + when grade == null || category == null || score == null => + NutriScore.invalid('Missing required fields (grade, category, score)'), + NutriScoreStatus.computed => + NutriScore.computed(version, grade!, category!, score!), + _ => NutriScore.invalid('Unexpected Nutri-Score: ${raw.grade}'), + }; + } +} + +extension _NutriScoreJsonExtension on NutriScore { + /// Converts the [NutriScore] instance to a JSON map. + Map toJson() { + return { + 'grade': _combinedGradeAndStatus, + 'category_available': _isAvailable(NutriScoreInput.category) ? 1 : 0, + 'nutrients_available': _isAvailable(NutriScoreInput.nutrients) ? 1 : 0, + 'data': category?.toJson() ?? {}, + if (score != null) 'score': score, + if (notApplicableCategory != null) + 'not_applicable_category': notApplicableCategory, + }; + } + + bool _isAvailable(NutriScoreInput input) => !missingData.contains(input); + + /// Returns a string representation of the Nutri-Score grade and status. + String get _combinedGradeAndStatus => + grade?.name.toLowerCase() ?? + (status == NutriScoreStatus.notApplicable ? 'not-applicable' : 'unknown'); +} + +extension NutriScoreCategoryJsonExtension on NutriScoreCategory { + /// Converts the [NutriScoreCategory] instance to a JSON map. + Map toJson() { + return switch (version) { + NutriScoreVersion.v2021 => as2021!.toJson(), + NutriScoreVersion.v2023 => as2023!.toJson(), + }; + } +} + +extension NutriScoreCategory2021JsonExtension on NutriScoreCategory2021 { + /// Converts the [NutriScoreCategory2021] instance to a JSON map. + Map toJson() { + return { + 'is_water': this == NutriScoreCategory2021.water ? 1 : 0, + 'is_beverage': this == NutriScoreCategory2021.beverage ? 1 : 0, + 'is_cheese': this == NutriScoreCategory2021.cheese ? 1 : 0, + 'is_fat': this == NutriScoreCategory2021.fat ? 1 : 0, + }; + } +} + +extension NutriScoreCategory2023JsonExtension on NutriScoreCategory2023 { + /// Converts the [NutriScoreCategory2023] instance to a JSON map. + Map toJson() { + return { + 'is_water': this == NutriScoreCategory2023.water ? 1 : 0, + 'is_beverage': this == NutriScoreCategory2023.beverage ? 1 : 0, + 'is_cheese': this == NutriScoreCategory2023.cheese ? 1 : 0, + 'is_red_meat_product': + this == NutriScoreCategory2023.redMeatProduct ? 1 : 0, + 'is_fat_oil_nuts_seeds': + this == NutriScoreCategory2023.fatOilNutsSeeds ? 1 : 0, + }; + } +} diff --git a/test/api_get_product_test.dart b/test/api_get_product_test.dart index 1264b4f166..ca23ba3f60 100644 --- a/test/api_get_product_test.dart +++ b/test/api_get_product_test.dart @@ -124,6 +124,18 @@ void main() { expect(result.product, isNotNull); expect(result.product!.barcode, barcode); + final nutriScore = + result.product!.nutriscoreDetails?[NutriScoreVersion.v2023]; + expect(nutriScore, isNotNull); + expect(nutriScore!.status, NutriScoreStatus.notApplicable); + expect(nutriScore.version, NutriScoreVersion.v2023); + expect(nutriScore.grade, isNull); + expect(nutriScore.score, isNull); + expect(nutriScore.category, isNotNull); + expect(nutriScore.category!.as2023, NutriScoreCategory2023.beverage); + expect(nutriScore.missingData, isEmpty); + expect(nutriScore.notApplicableCategory, "en:alcoholic-beverages"); + const Nutrient alcohol = Nutrient.alcohol; expect(result.product!.nutriments, isNotNull); final Nutriments nutriments = result.product!.nutriments!; @@ -421,6 +433,18 @@ void main() { expect(result.product!.nutriscore, 'e'); + final nutriScore = + result.product!.nutriscoreDetails?[NutriScoreVersion.v2023]; + expect(nutriScore, isNotNull); + expect(nutriScore!.status, NutriScoreStatus.computed); + expect(nutriScore.version, NutriScoreVersion.v2023); + expect(nutriScore.grade, NutriScoreGrade.E); + expect(nutriScore.score, 25); + expect(nutriScore.category, isNotNull); + expect(nutriScore.category!.as2023, NutriScoreCategory2023.general); + expect(nutriScore.missingData, isEmpty); + expect(nutriScore.notApplicableCategory, isNull); + expect(result.product!.nutriments, isNotNull); final Nutriments nutriments = result.product!.nutriments!; diff --git a/test/nutriscore_from_json_test.dart b/test/nutriscore_from_json_test.dart index 3fca9e57ae..3ecf614388 100644 --- a/test/nutriscore_from_json_test.dart +++ b/test/nutriscore_from_json_test.dart @@ -4,50 +4,34 @@ import 'package:test/test.dart'; void main() { group('NutriScore.fromJson', () { test('returns null when input is null', () { - final result = NutriScore.fromJson(null); + final result = NutriScoreHelper.fromJson(null); expect(result, isNull); }); test('throws FormatException on non-map input', () { expect( - () => NutriScore.fromJson(['not', 'a', 'map']), + () => NutriScoreHelper.fromJson(['not', 'a', 'map']), throwsA(isA()), ); }); test('ignores entries with unknown versions', () { - final result = NutriScore.fromJson({ + final result = NutriScoreHelper.fromJson({ 'invalid_version': {'grade': 'a'} }); expect(result, isNull); }); test('ignores entries with invalid structure', () { - final result = NutriScore.fromJson({ + final result = NutriScoreHelper.fromJson({ '2023': 'not a map', '2021': null, }); expect(result, isNull); }); - test('parses valid NutriScore for version 2023', () { - final json = { - '2023': { - 'grade': 'b', - 'score': 2, - 'category_available': 1, - 'nutrients_available': 1, - 'data': { - 'is_beverage': 1, - 'is_cheese': 0, - 'is_fat_oil_nuts_seeds': 0, - 'is_red_meat_product': 0, - 'is_water': 0, - } - } - }; - - final result = NutriScore.fromJson(json); + test('parses computed NutriScore for version 2023', () { + final result = NutriScoreHelper.fromJson(computed); expect(result, isNotNull); expect(result!.length, 1); expect(result, contains(NutriScoreVersion.v2023)); @@ -55,6 +39,7 @@ void main() { final nutriScore = result[NutriScoreVersion.v2023]!; expect(nutriScore.version, NutriScoreVersion.v2023); expect(nutriScore.status, NutriScoreStatus.computed); + expect(nutriScore.missingData, isEmpty); expect(nutriScore.grade, NutriScoreGrade.B); expect(nutriScore.score, 2); expect(nutriScore.category?.version, NutriScoreVersion.v2023); @@ -62,21 +47,7 @@ void main() { }); test('parses unknown NutriScore for version 2023', () { - final json = { - '2023': { - 'grade': 'unknown', - 'category_available': 1, - 'data': { - 'is_beverage': 0, - 'is_cheese': 0, - 'is_fat_oil_nuts_seeds': 0, - 'is_red_meat_product': 0, - 'is_water': 0, - } - } - }; - - final result = NutriScore.fromJson(json); + final result = NutriScoreHelper.fromJson(unknown); expect(result, isNotNull); expect(result!.length, 1); expect(result, contains(NutriScoreVersion.v2023)); @@ -84,6 +55,7 @@ void main() { final nutriScore = result[NutriScoreVersion.v2023]!; expect(nutriScore.version, NutriScoreVersion.v2023); expect(nutriScore.status, NutriScoreStatus.unknown); + expect(nutriScore.missingData.length, 1); expect(nutriScore.grade, isNull); expect(nutriScore.score, isNull); expect(nutriScore.category?.version, NutriScoreVersion.v2023); @@ -92,23 +64,7 @@ void main() { }); test('parses not applicable NutriScore for version 2023', () { - final json = { - '2023': { - 'grade': 'not-applicable', - 'category_available': 1, - 'nutrients_available': 1, - 'not_applicable_category': 'en:alcoholic-beverages', - 'data': { - 'is_beverage': 1, - 'is_cheese': 0, - 'is_fat_oil_nuts_seeds': 0, - 'is_red_meat_product': 0, - 'is_water': 0, - } - } - }; - - final result = NutriScore.fromJson(json); + final result = NutriScoreHelper.fromJson(notApplicable); expect(result, isNotNull); expect(result!.length, 1); expect(result, contains(NutriScoreVersion.v2023)); @@ -116,33 +72,17 @@ void main() { final nutriScore = result[NutriScoreVersion.v2023]!; expect(nutriScore.version, NutriScoreVersion.v2023); expect(nutriScore.status, NutriScoreStatus.notApplicable); + expect(nutriScore.missingData, isEmpty); expect(nutriScore.grade, isNull); expect(nutriScore.score, isNull); expect(nutriScore.category?.as2023, NutriScoreCategory2023.beverage); expect(nutriScore.missingData, isEmpty); }); - test('parses inconsistently formatted NutriScore data for version 2021', - () { - final json = { - '2021': { - 'grade': 'not-applicable', - 'category_available': 1, - 'nutrients_available': 1, - 'not_applicable_category': 'en:alcoholic-beverages', - 'data': { - 'is_beverage': 1, - 'is_cheese': 0, - 'is_fat': 0, - 'is_red_meat_product': 0, - 'is_water': "1", - } - } - }; - - final result = NutriScore.fromJson(json); + test('parses inconsistent NutriScore data', () { + final result = NutriScoreHelper.fromJson(inconsistent); expect(result, isNotNull); - expect(result!.length, 1); + expect(result!.length, 2); expect(result, contains(NutriScoreVersion.v2021)); final nutriScore = result[NutriScoreVersion.v2021]!; @@ -154,4 +94,89 @@ void main() { expect(nutriScore.missingData, isEmpty); }); }); + + test('round-trip toJson/fromJson', () { + for (final json in [computed, unknown, notApplicable, inconsistent]) { + final original = NutriScoreHelper.fromJson(json); + expect(original, isNotNull); + expect(original, equals(original)); + + final output = NutriScoreHelper.toJson(original!); + final copy = NutriScoreHelper.fromJson(output); + + expect(copy, isNotNull); + expect(copy, equals(original)); + } + }); } + +final computed = { + '2023': { + 'grade': 'b', + 'score': 2, + 'category_available': 1, + 'nutrients_available': 1, + 'data': { + 'is_beverage': 1, + 'is_cheese': 0, + 'is_fat_oil_nuts_seeds': 0, + 'is_red_meat_product': 0, + 'is_water': 0, + } + } +}; + +final unknown = { + '2023': { + 'grade': 'unknown', + 'category_available': 1, + 'data': { + 'is_beverage': 0, + 'is_cheese': 0, + 'is_fat_oil_nuts_seeds': 0, + 'is_red_meat_product': 0, + 'is_water': 0, + } + } +}; + +final notApplicable = { + '2023': { + 'grade': 'not-applicable', + 'category_available': 1, + 'nutrients_available': 1, + 'not_applicable_category': 'en:alcoholic-beverages', + 'data': { + 'is_beverage': 1, + 'is_cheese': 0, + 'is_fat_oil_nuts_seeds': 0, + 'is_red_meat_product': 0, + 'is_water': 0, + } + } +}; + +final inconsistent = { + '2021': { + 'grade': 'not-applicable', + 'category_available': 1, + 'nutrients_available': 1, + 'not_applicable_category': 'en:alcoholic-beverages', + 'data': { + 'is_beverage': 1, + 'is_cheese': 0, + 'is_fat': 0, + 'is_red_meat_product': 0, + 'is_water': "1", + } + }, + '2023': { + 'grade': 'unknown', + 'category_available': 1, + 'nutrients_available': 1, + 'not_applicable_category': 'en:alcoholic-beverages', + 'data': { + 'is_red_meat_product': 1, + } + } +}; From c221784db811fa4f3b3f173e1d70d55920c3470f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olaf=20G=C3=B6rlitz?= Date: Fri, 16 May 2025 21:41:49 +0200 Subject: [PATCH 05/12] refactoring and simplification of domain model on top of raw JsonObject implementation. --- lib/openfoodfacts.dart | 16 +- lib/src/model/nutriscore.dart | 149 ----------------- lib/src/model/nutriscore/nutriscore.dart | 115 +++++++++++++ .../nutriscore/nutriscore_data_2021.dart | 8 +- .../nutriscore/nutriscore_data_2021.g.dart | 8 +- .../nutriscore/nutriscore_data_2023.dart | 10 +- .../nutriscore/nutriscore_data_2023.g.dart | 11 +- .../nutriscore/nutriscore_detail_2021.dart | 5 +- .../nutriscore/nutriscore_detail_2021.g.dart | 15 +- .../nutriscore/nutriscore_detail_2023.dart | 5 +- .../nutriscore/nutriscore_detail_2023.g.dart | 15 +- .../model/nutriscore/nutriscore_details.dart | 2 +- .../nutriscore/nutriscore_details.g.dart | 4 +- .../model/nutriscore/nutriscore_grade.dart | 29 ---- lib/src/model/nutriscore_category.dart | 92 ----------- lib/src/model/nutriscore_enums.dart | 43 ----- lib/src/model/nutriscore_raw.dart | 58 ------- lib/src/model/product.dart | 16 +- lib/src/model/product.g.dart | 5 +- lib/src/utils/nutriscore_helper.dart | 148 ----------------- test/api_get_product_test.dart | 56 +++---- test/nutriscore_from_json_test.dart | 153 +++++++++++------- 22 files changed, 277 insertions(+), 686 deletions(-) delete mode 100644 lib/src/model/nutriscore.dart create mode 100644 lib/src/model/nutriscore/nutriscore.dart delete mode 100644 lib/src/model/nutriscore/nutriscore_grade.dart delete mode 100644 lib/src/model/nutriscore_category.dart delete mode 100644 lib/src/model/nutriscore_enums.dart delete mode 100644 lib/src/model/nutriscore_raw.dart delete mode 100644 lib/src/utils/nutriscore_helper.dart diff --git a/lib/openfoodfacts.dart b/lib/openfoodfacts.dart index 075e3c6868..05563eedf4 100644 --- a/lib/openfoodfacts.dart +++ b/lib/openfoodfacts.dart @@ -26,9 +26,12 @@ export 'src/model/nutrient_modifier.dart'; export 'src/model/nutrient.dart'; export 'src/model/nutrient_levels.dart'; export 'src/model/nutriments.dart'; -export 'src/model/nutriscore.dart'; -export 'src/model/nutriscore_category.dart'; -export 'src/model/nutriscore_enums.dart'; +export 'src/model/nutriscore/nutriscore.dart'; +export 'src/model/nutriscore/nutriscore_data_2021.dart'; +export 'src/model/nutriscore/nutriscore_data_2023.dart'; +export 'src/model/nutriscore/nutriscore_detail_2021.dart'; +export 'src/model/nutriscore/nutriscore_detail_2023.dart'; +export 'src/model/nutriscore/nutriscore_details.dart'; // export 'src/model/product_list.dart'; // not needed export 'src/model/ocr_ingredients_result.dart'; @@ -89,12 +92,6 @@ export 'src/model/taxonomy_packaging_recycling.dart'; export 'src/model/taxonomy_packaging_shape.dart'; export 'src/model/user.dart'; export 'src/model/user_agent.dart'; -export 'src/model/nutriscore/nutriscore_data_2021.dart'; -export 'src/model/nutriscore/nutriscore_data_2023.dart'; -export 'src/model/nutriscore/nutriscore_detail_2021.dart'; -export 'src/model/nutriscore/nutriscore_detail_2023.dart'; -export 'src/model/nutriscore/nutriscore_details.dart'; -export 'src/model/nutriscore/nutriscore_grade.dart'; export 'src/open_food_api_client.dart'; export 'src/open_food_search_api_client.dart'; export 'src/open_prices_api_client.dart'; @@ -162,7 +159,6 @@ export 'src/utils/invalid_barcodes.dart'; export 'src/utils/json_helper.dart'; export 'src/utils/language_helper.dart'; export 'src/utils/nutriments_helper.dart'; -export 'src/utils/nutriscore_helper.dart'; export 'src/utils/ocr_field.dart'; export 'src/utils/open_food_api_configuration.dart'; export 'src/utils/pnns_groups.dart'; diff --git a/lib/src/model/nutriscore.dart b/lib/src/model/nutriscore.dart deleted file mode 100644 index 0c2c83badd..0000000000 --- a/lib/src/model/nutriscore.dart +++ /dev/null @@ -1,149 +0,0 @@ -import 'nutriscore_category.dart'; -import 'nutriscore_enums.dart'; - -/// Represents the computed or inferred Nutri-Score of a product. -/// -/// The Nutri-Score may include different information depending on its [status] and [version]. -/// -/// The [NutriScoreStatus] can be: -/// - [unknown]: The Nutri-Score is unknown (see [missingData]). -/// - [notApplicable]: The Nutri-Score is not applicable (see [notApplicableCategory]). -/// - [computed]: The Nutri-Score is computed (see [grade], [category], [score]). -/// - [invalid]: The Nutri-Score is invalid (see [errorMessage]). -/// -/// Example: -/// ```dart -/// final nutriScore = product.nutriscoreDetails?[NutriScoreVersion.v2023]; -/// -/// final category = switch (nutriScore?.category?.as2023) { -/// NutriScoreCategory2023.water => 'Water', -/// NutriScoreCategory2023.beverage => 'Beverage', -/// NutriScoreCategory2023.fatOilNutsSeeds => 'Fat/Oil/Nuts/Seeds', -/// NutriScoreCategory2023.cheese => 'Cheese', -/// NutriScoreCategory2023.redMeatProduct => 'Red Meat Product', -/// NutriScoreCategory2023.general => 'General Food', -/// _ => 'Unknown Category', -/// }; -/// -/// final infoText = switch (nutriScore?.status) { -/// NutriScoreStatus.unknown => -/// 'Unknown (missing data: ${nutriScore?.missingData?.join(', ') ?? 'n/a'})', -/// NutriScoreStatus.notApplicable => -/// 'Not Applicable (category: ${nutriScore?.notApplicableCategory ?? 'n/a'})', -/// NutriScoreStatus.computed => -/// 'Computed (grade: ${nutriScore?.grade}, score: ${nutriScore?.score}, category: $category)', -/// NutriScoreStatus.invalid => -/// 'Invalid (error: ${nutriScore?.errorMessage ?? 'unknown error'})', -/// null => 'No Nutri-Score available', -/// }; -/// -/// print('Nutri-Score: $infoText'); -/// ``` -class NutriScore { - final NutriScoreStatus status; - final NutriScoreGrade? grade; - final NutriScoreVersion? version; - final NutriScoreCategory? category; - final int? score; - final String? notApplicableCategory; - final List missingData; - final String? errorMessage; - - /// Creates a [NutriScore] with [NutriScoreStatus.unknown]. - /// - /// Used when Nutri-Score computation is not possible due to missing input. - /// Partial data like [version] and [category] may still be present. - /// The [missingData] parameter indicates which data is missing. - NutriScore.unknown([ - this.version, - this.category, - this.missingData = const [], - ]) : status = NutriScoreStatus.unknown, - grade = null, - score = null, - notApplicableCategory = null, - errorMessage = null; - - /// Creates a [NutriScore] with [NutriScoreStatus.notApplicable]. - /// - /// Used when the Nutri-Score does not apply to the product category - /// (e.g., alcoholic beverages). See [notApplicableCategory]. - NutriScore.notApplicable( - NutriScoreVersion this.version, - this.category, - this.missingData, - this.notApplicableCategory, - ) : status = NutriScoreStatus.notApplicable, - grade = null, - score = null, - errorMessage = null; - - /// Creates a [NutriScore] with [NutriScoreStatus.computed]. - /// - /// Used when the Nutri-Score has been successfully calculated. - /// See [grade], [score], and product [category]. - NutriScore.computed( - NutriScoreVersion this.version, - NutriScoreGrade this.grade, - NutriScoreCategory this.category, - int this.score, - ) : status = NutriScoreStatus.computed, - notApplicableCategory = null, - errorMessage = null, - missingData = const []; - - /// Creates a [NutriScore] with [NutriScoreStatus.invalid]. - /// - /// Used to represent an unexpected or malformed Nutri-Score entry. - /// See [errorMessage] for details. - NutriScore.invalid(String this.errorMessage) - : status = NutriScoreStatus.invalid, - version = null, - grade = null, - category = null, - score = null, - notApplicableCategory = null, - missingData = const []; - - @override - String toString() { - return 'NutriScore{' - 'status: ${status.name}, ' - 'grade: ${grade?.name}, ' - 'version: ${version?.name}, ' - 'category: $category, ' - 'score: $score, ' - 'notApplicableCategory: $notApplicableCategory, ' - 'errorMessage: $errorMessage, ' - 'missingData: $missingData' - '}'; - } - - @override - bool operator ==(Object other) => - identical(this, other) || - other is NutriScore && - status == other.status && - grade == other.grade && - version == other.version && - category?.value == other.category?.value && - score == other.score && - notApplicableCategory == other.notApplicableCategory && - errorMessage == other.errorMessage && - _unorderedEquals(missingData, other.missingData); - - @override - int get hashCode => Object.hash( - status, - grade, - version, - category?.value, - score, - notApplicableCategory, - errorMessage, - missingData.fold(0, (acc, e) => acc ^ e.hashCode), - ); - - bool _unorderedEquals(List a, List b) => - Set.from(a).containsAll(b) && Set.from(b).containsAll(a); -} diff --git a/lib/src/model/nutriscore/nutriscore.dart b/lib/src/model/nutriscore/nutriscore.dart new file mode 100644 index 0000000000..59045c7d73 --- /dev/null +++ b/lib/src/model/nutriscore/nutriscore.dart @@ -0,0 +1,115 @@ +import 'package:openfoodfacts/openfoodfacts.dart'; + +enum NutriScoreCategory2021 { + general, + cheese, + fat, + beverage, + water, +} + +enum NutriScoreCategory2023 { + general, + cheese, + redMeatProduct, + fatOilNutsSeeds, + beverage, + water, +} + +enum NutriScoreGrade { A, B, C, D, E } + +enum NutriScoreInput { category, nutrients, ingredients } + +typedef NutriScore2021 = _NutriScore; +typedef NutriScore2023 = _NutriScore; + +class _NutriScore { + final NutriScoreGrade? grade; + final int? score; + final String? notApplicableCategory; + final List missingData; + final T? category; + + _NutriScore({ + String? grade, + this.category, + this.score, + this.notApplicableCategory, + bool hasCategory = false, + bool hasNutrients = false, + }) : grade = parseGrade(grade ?? ''), + missingData = [ + if (!hasCategory) NutriScoreInput.category, + if (!hasNutrients) NutriScoreInput.nutrients, + ]; + + bool get isComputed => grade != null; + bool get isNotApplicable => grade == null && notApplicableCategory != null; + bool get isUnknown => grade == null && missingData.isNotEmpty; + + static NutriScoreGrade? parseGrade(String grade) { + return switch (grade.toLowerCase()) { + 'a' => NutriScoreGrade.A, + 'b' => NutriScoreGrade.B, + 'c' => NutriScoreGrade.C, + 'd' => NutriScoreGrade.D, + 'e' => NutriScoreGrade.E, + _ => null, + }; + } +} + +extension NutriScoreDetails2021Ext on NutriScoreDetails { + NutriScore2021? get2021() { + if (nutriScore2021 == null) return null; + + return NutriScore2021( + category: nutriScore2021?.data?.category, + grade: nutriScore2021?.grade, + score: nutriScore2021?.score, + notApplicableCategory: nutriScore2021?.notApplicableCategory, + hasCategory: nutriScore2021?.categoryAvailable == true, + hasNutrients: nutriScore2021?.nutrientsAvailable == true, + ); + } + + NutriScore2023? get2023() { + if (nutriScore2023 == null) return null; + + return NutriScore2023( + category: nutriScore2023?.data?.category, + grade: nutriScore2023?.grade, + score: nutriScore2023?.score, + notApplicableCategory: nutriScore2023?.notApplicableCategory, + hasCategory: nutriScore2023?.categoryAvailable == true, + hasNutrients: nutriScore2023?.nutrientsAvailable == true, + ); + } +} + +/// Extension to infer [NutriScoreCategory2021] from boolean flags. +extension NutriScoreData2021Ext on NutriScoreData2021 { + NutriScoreCategory2021 get category { + // water must be checked first to avoid beverage+water conflict + if (isWater == true) return NutriScoreCategory2021.water; + if (isBeverage == true) return NutriScoreCategory2021.beverage; + if (isFat == true) return NutriScoreCategory2021.fat; + if (isCheese == true) return NutriScoreCategory2021.cheese; + return NutriScoreCategory2021.general; + } +} + +/// Extension to infer [NutriScoreCategory2023] from boolean flags. +extension NutriScoreData2023Ext on NutriScoreData2023 { + NutriScoreCategory2023 get category { + // water must be checked first to avoid beverage+water conflict + if (isWater == true) return NutriScoreCategory2023.water; + if (isBeverage == true) return NutriScoreCategory2023.beverage; + if (isFatOilNutsSeeds == true) + return NutriScoreCategory2023.fatOilNutsSeeds; + if (isCheese == true) return NutriScoreCategory2023.cheese; + if (isRedMeatProduct == true) return NutriScoreCategory2023.redMeatProduct; + return NutriScoreCategory2023.general; + } +} diff --git a/lib/src/model/nutriscore/nutriscore_data_2021.dart b/lib/src/model/nutriscore/nutriscore_data_2021.dart index 9c289606db..cb74680169 100644 --- a/lib/src/model/nutriscore/nutriscore_data_2021.dart +++ b/lib/src/model/nutriscore/nutriscore_data_2021.dart @@ -10,28 +10,28 @@ class NutriScoreData2021 extends JsonObject { @JsonKey( name: 'is_beverage', toJson: JsonHelper.boolToJSON, - fromJson: JsonHelper.boolFromJSON, + fromJson: JsonObject.parseBool, ) bool? isBeverage; @JsonKey( name: 'is_cheese', toJson: JsonHelper.boolToJSON, - fromJson: JsonHelper.boolFromJSON, + fromJson: JsonObject.parseBool, ) bool? isCheese; @JsonKey( name: 'is_fat', toJson: JsonHelper.boolToJSON, - fromJson: JsonHelper.boolFromJSON, + fromJson: JsonObject.parseBool, ) bool? isFat; @JsonKey( name: 'is_water', toJson: JsonHelper.boolToJSON, - fromJson: JsonHelper.boolFromJSON, + fromJson: JsonObject.parseBool, ) bool? isWater; diff --git a/lib/src/model/nutriscore/nutriscore_data_2021.g.dart b/lib/src/model/nutriscore/nutriscore_data_2021.g.dart index 8cadb3c32e..b03fad16c5 100644 --- a/lib/src/model/nutriscore/nutriscore_data_2021.g.dart +++ b/lib/src/model/nutriscore/nutriscore_data_2021.g.dart @@ -8,10 +8,10 @@ part of 'nutriscore_data_2021.dart'; NutriScoreData2021 _$NutriScoreData2021FromJson(Map json) => NutriScoreData2021() - ..isBeverage = JsonHelper.boolFromJSON(json['is_beverage']) - ..isCheese = JsonHelper.boolFromJSON(json['is_cheese']) - ..isFat = JsonHelper.boolFromJSON(json['is_fat']) - ..isWater = JsonHelper.boolFromJSON(json['is_water']) + ..isBeverage = JsonObject.parseBool(json['is_beverage']) + ..isCheese = JsonObject.parseBool(json['is_cheese']) + ..isFat = JsonObject.parseBool(json['is_fat']) + ..isWater = JsonObject.parseBool(json['is_water']) ..negativePoints = (json['negative_points'] as num?)?.toInt() ..positivePoints = (json['positive_points'] as num?)?.toInt(); diff --git a/lib/src/model/nutriscore/nutriscore_data_2023.dart b/lib/src/model/nutriscore/nutriscore_data_2023.dart index 495bfc2b45..3c56d01742 100644 --- a/lib/src/model/nutriscore/nutriscore_data_2023.dart +++ b/lib/src/model/nutriscore/nutriscore_data_2023.dart @@ -20,35 +20,35 @@ class NutriScoreData2023 extends JsonObject { @JsonKey( name: 'is_beverage', toJson: JsonHelper.boolToJSON, - fromJson: JsonHelper.boolFromJSON, + fromJson: JsonObject.parseBool, ) bool? isBeverage; @JsonKey( name: 'is_cheese', toJson: JsonHelper.boolToJSON, - fromJson: JsonHelper.boolFromJSON, + fromJson: JsonObject.parseBool, ) bool? isCheese; @JsonKey( name: 'is_fat_oil_nuts_seeds', toJson: JsonHelper.boolToJSON, - fromJson: JsonHelper.boolFromJSON, + fromJson: JsonObject.parseBool, ) bool? isFatOilNutsSeeds; @JsonKey( name: 'is_red_meat_product', toJson: JsonHelper.boolToJSON, - fromJson: JsonHelper.boolFromJSON, + fromJson: JsonObject.parseBool, ) bool? isRedMeatProduct; @JsonKey( name: 'is_water', toJson: JsonHelper.boolToJSON, - fromJson: JsonHelper.boolFromJSON, + fromJson: JsonObject.parseBool, ) bool? isWater; diff --git a/lib/src/model/nutriscore/nutriscore_data_2023.g.dart b/lib/src/model/nutriscore/nutriscore_data_2023.g.dart index 45c7235f2d..553a8d91a2 100644 --- a/lib/src/model/nutriscore/nutriscore_data_2023.g.dart +++ b/lib/src/model/nutriscore/nutriscore_data_2023.g.dart @@ -10,12 +10,11 @@ NutriScoreData2023 _$NutriScoreData2023FromJson(Map json) => NutriScoreData2023() ..countProteins = JsonHelper.boolFromJSON(json['count_proteins']) ..countProteinsReason = json['count_proteins_reason'] as String? - ..isBeverage = JsonHelper.boolFromJSON(json['is_beverage']) - ..isCheese = JsonHelper.boolFromJSON(json['is_cheese']) - ..isFatOilNutsSeeds = - JsonHelper.boolFromJSON(json['is_fat_oil_nuts_seeds']) - ..isRedMeatProduct = JsonHelper.boolFromJSON(json['is_red_meat_product']) - ..isWater = JsonHelper.boolFromJSON(json['is_water']) + ..isBeverage = JsonObject.parseBool(json['is_beverage']) + ..isCheese = JsonObject.parseBool(json['is_cheese']) + ..isFatOilNutsSeeds = JsonObject.parseBool(json['is_fat_oil_nuts_seeds']) + ..isRedMeatProduct = JsonObject.parseBool(json['is_red_meat_product']) + ..isWater = JsonObject.parseBool(json['is_water']) ..negativePoints = (json['negative_points'] as num?)?.toInt() ..negativePointsMax = (json['negative_points_max'] as num?)?.toInt() ..positivePoints = (json['positive_points'] as num?)?.toInt() diff --git a/lib/src/model/nutriscore/nutriscore_detail_2021.dart b/lib/src/model/nutriscore/nutriscore_detail_2021.dart index 84cf6a94b9..2e5c43da52 100644 --- a/lib/src/model/nutriscore/nutriscore_detail_2021.dart +++ b/lib/src/model/nutriscore/nutriscore_detail_2021.dart @@ -2,15 +2,14 @@ import 'package:json_annotation/json_annotation.dart'; import '../../interface/json_object.dart'; import '../../utils/json_helper.dart'; import 'nutriscore_data_2021.dart'; -import 'nutriscore_grade.dart'; part 'nutriscore_detail_2021.g.dart'; /// Data of NutriScore version 2021. -@JsonSerializable() +@JsonSerializable(explicitToJson: true) class NutriScoreDetail2021 extends JsonObject { @JsonKey() - NutriScoreGrade? grade; + String? grade; @JsonKey() int? score; diff --git a/lib/src/model/nutriscore/nutriscore_detail_2021.g.dart b/lib/src/model/nutriscore/nutriscore_detail_2021.g.dart index 17ca2d551c..17ddbbe96b 100644 --- a/lib/src/model/nutriscore/nutriscore_detail_2021.g.dart +++ b/lib/src/model/nutriscore/nutriscore_detail_2021.g.dart @@ -9,7 +9,7 @@ part of 'nutriscore_detail_2021.dart'; NutriScoreDetail2021 _$NutriScoreDetail2021FromJson( Map json) => NutriScoreDetail2021() - ..grade = $enumDecodeNullable(_$NutriScoreGradeEnumMap, json['grade']) + ..grade = json['grade'] as String? ..score = (json['score'] as num?)?.toInt() ..data = json['data'] == null ? null @@ -26,9 +26,9 @@ NutriScoreDetail2021 _$NutriScoreDetail2021FromJson( Map _$NutriScoreDetail2021ToJson( NutriScoreDetail2021 instance) => { - 'grade': _$NutriScoreGradeEnumMap[instance.grade], + 'grade': instance.grade, 'score': instance.score, - 'data': instance.data, + 'data': instance.data?.toJson(), 'not_applicable_category': instance.notApplicableCategory, 'category_available': JsonHelper.boolToJSON(instance.categoryAvailable), 'nutrients_available': JsonHelper.boolToJSON(instance.nutrientsAvailable), @@ -36,12 +36,3 @@ Map _$NutriScoreDetail2021ToJson( JsonHelper.boolToJSON(instance.nutriScoreApplicable), 'nutriscore_computed': JsonHelper.boolToJSON(instance.nutriScoreComputed), }; - -const _$NutriScoreGradeEnumMap = { - NutriScoreGrade.a: 'a', - NutriScoreGrade.b: 'b', - NutriScoreGrade.c: 'c', - NutriScoreGrade.d: 'd', - NutriScoreGrade.e: 'e', - NutriScoreGrade.notApplicable: 'not-applicable', -}; diff --git a/lib/src/model/nutriscore/nutriscore_detail_2023.dart b/lib/src/model/nutriscore/nutriscore_detail_2023.dart index c8cb1187cc..ab3eb27e7f 100644 --- a/lib/src/model/nutriscore/nutriscore_detail_2023.dart +++ b/lib/src/model/nutriscore/nutriscore_detail_2023.dart @@ -2,15 +2,14 @@ import 'package:json_annotation/json_annotation.dart'; import '../../interface/json_object.dart'; import '../../utils/json_helper.dart'; import 'nutriscore_data_2023.dart'; -import 'nutriscore_grade.dart'; part 'nutriscore_detail_2023.g.dart'; /// Data of NutriScore version 2023. -@JsonSerializable() +@JsonSerializable(explicitToJson: true) class NutriScoreDetail2023 extends JsonObject { @JsonKey() - NutriScoreGrade? grade; + String? grade; @JsonKey() int? score; diff --git a/lib/src/model/nutriscore/nutriscore_detail_2023.g.dart b/lib/src/model/nutriscore/nutriscore_detail_2023.g.dart index 5ef65f5985..a9af395db2 100644 --- a/lib/src/model/nutriscore/nutriscore_detail_2023.g.dart +++ b/lib/src/model/nutriscore/nutriscore_detail_2023.g.dart @@ -9,7 +9,7 @@ part of 'nutriscore_detail_2023.dart'; NutriScoreDetail2023 _$NutriScoreDetail2023FromJson( Map json) => NutriScoreDetail2023() - ..grade = $enumDecodeNullable(_$NutriScoreGradeEnumMap, json['grade']) + ..grade = json['grade'] as String? ..score = (json['score'] as num?)?.toInt() ..data = json['data'] == null ? null @@ -26,9 +26,9 @@ NutriScoreDetail2023 _$NutriScoreDetail2023FromJson( Map _$NutriScoreDetail2023ToJson( NutriScoreDetail2023 instance) => { - 'grade': _$NutriScoreGradeEnumMap[instance.grade], + 'grade': instance.grade, 'score': instance.score, - 'data': instance.data, + 'data': instance.data?.toJson(), 'not_applicable_category': instance.notApplicableCategory, 'category_available': JsonHelper.boolToJSON(instance.categoryAvailable), 'nutrients_available': JsonHelper.boolToJSON(instance.nutrientsAvailable), @@ -36,12 +36,3 @@ Map _$NutriScoreDetail2023ToJson( JsonHelper.boolToJSON(instance.nutriScoreApplicable), 'nutriscore_computed': JsonHelper.boolToJSON(instance.nutriScoreComputed), }; - -const _$NutriScoreGradeEnumMap = { - NutriScoreGrade.a: 'a', - NutriScoreGrade.b: 'b', - NutriScoreGrade.c: 'c', - NutriScoreGrade.d: 'd', - NutriScoreGrade.e: 'e', - NutriScoreGrade.notApplicable: 'not-applicable', -}; diff --git a/lib/src/model/nutriscore/nutriscore_details.dart b/lib/src/model/nutriscore/nutriscore_details.dart index b61e9b59f6..ba4e0471ce 100644 --- a/lib/src/model/nutriscore/nutriscore_details.dart +++ b/lib/src/model/nutriscore/nutriscore_details.dart @@ -6,7 +6,7 @@ import 'nutriscore_detail_2023.dart'; part 'nutriscore_details.g.dart'; /// NutriScore detailed info. -@JsonSerializable() +@JsonSerializable(explicitToJson: true) class NutriScoreDetails extends JsonObject { @JsonKey(name: '2021') NutriScoreDetail2021? nutriScore2021; diff --git a/lib/src/model/nutriscore/nutriscore_details.g.dart b/lib/src/model/nutriscore/nutriscore_details.g.dart index 8585385df9..6caa85c6ad 100644 --- a/lib/src/model/nutriscore/nutriscore_details.g.dart +++ b/lib/src/model/nutriscore/nutriscore_details.g.dart @@ -17,6 +17,6 @@ NutriScoreDetails _$NutriScoreDetailsFromJson(Map json) => Map _$NutriScoreDetailsToJson(NutriScoreDetails instance) => { - '2021': instance.nutriScore2021, - '2023': instance.nutriScore2023, + '2021': instance.nutriScore2021?.toJson(), + '2023': instance.nutriScore2023?.toJson(), }; diff --git a/lib/src/model/nutriscore/nutriscore_grade.dart b/lib/src/model/nutriscore/nutriscore_grade.dart deleted file mode 100644 index a7edb6817c..0000000000 --- a/lib/src/model/nutriscore/nutriscore_grade.dart +++ /dev/null @@ -1,29 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; - -import '../off_tagged.dart'; - -/// Grade of NutriScore. -enum NutriScoreGrade implements OffTagged { - @JsonValue('a') - a('a'), - @JsonValue('b') - b('b'), - @JsonValue('c') - c('c'), - @JsonValue('d') - d('d'), - @JsonValue('e') - e('e'), - @JsonValue('not-applicable') - notApplicable('not-applicable'), - ; - - const NutriScoreGrade(this.offTag); - - @override - final String offTag; - - /// Returns the first [NutriScoreGrade] that matches the [offTag]. - static NutriScoreGrade? fromOffTag(final String? offTag) => - OffTagged.fromOffTag(offTag, NutriScoreGrade.values) as NutriScoreGrade?; -} diff --git a/lib/src/model/nutriscore_category.dart b/lib/src/model/nutriscore_category.dart deleted file mode 100644 index 87bdf33b98..0000000000 --- a/lib/src/model/nutriscore_category.dart +++ /dev/null @@ -1,92 +0,0 @@ -import 'nutriscore_enums.dart'; -import 'nutriscore_raw.dart'; - -/// Represents a product category used in Nutri-Score computation. -/// -/// This sealed class wraps version-specific enum values like [NutriScoreCategory2021] -/// or [NutriScoreCategory2023] while providing a unified interface. -sealed class NutriScoreCategory { - const NutriScoreCategory(); - - NutriScoreVersion get version; - - Enum get value; - - String get name => value.name; - - /// Returns the [NutriScoreCategory2021] enum value if this category belongs to version 2021, or null otherwise. - NutriScoreCategory2021? get as2021 => _getAs(); - - /// Returns the [NutriScoreCategory2023] enum value if this category belongs to version 2023, or null otherwise. - NutriScoreCategory2023? get as2023 => _getAs(); - - T? _getAs() => value is T ? value as T : null; - - /// Derives the [NutriScoreCategory] from [NutriScoreDataRaw] for the given [version]. - /// - /// Used when parsing raw Nutri-Score API responses. - static NutriScoreCategory? fromRaw( - NutriScoreVersion version, - NutriScoreDataRaw? data, - ) { - if (data == null) return null; - return switch (version) { - NutriScoreVersion.v2021 => _NutriScoreCategory2021(data.category2021), - NutriScoreVersion.v2023 => _NutriScoreCategory2023(data.category2023), - }; - } - - @override - String toString() => name; -} - -/// Internal wrapper for [NutriScoreCategory2021]. -/// Used by [NutriScoreCategory] to encapsulate version-specific category types. -class _NutriScoreCategory2021 extends NutriScoreCategory { - final NutriScoreCategory2021 _value; - - const _NutriScoreCategory2021(this._value); - - @override - Enum get value => _value; - - @override - NutriScoreVersion get version => NutriScoreVersion.v2021; -} - -/// Internal wrapper for [NutriScoreCategory2023]. -/// Used by [NutriScoreCategory] to encapsulate version-specific category types. -class _NutriScoreCategory2023 extends NutriScoreCategory { - final NutriScoreCategory2023 _value; - - const _NutriScoreCategory2023(this._value); - - @override - Enum get value => _value; - - @override - NutriScoreVersion get version => NutriScoreVersion.v2023; -} - -/// Extension to infer [NutriScoreCategory2021] or [NutriScoreCategory2023] -/// from raw boolean flags in [NutriScoreDataRaw]. -extension NutriScoreDataExtension on NutriScoreDataRaw { - NutriScoreCategory2021 get category2021 { - // water must be checked first to avoid beverage+water conflict - if (isWater) return NutriScoreCategory2021.water; - if (isBeverage) return NutriScoreCategory2021.beverage; - if (isFat) return NutriScoreCategory2021.fat; - if (isCheese) return NutriScoreCategory2021.cheese; - return NutriScoreCategory2021.general; - } - - NutriScoreCategory2023 get category2023 { - // water must be checked first to avoid beverage+water conflict - if (isWater) return NutriScoreCategory2023.water; - if (isBeverage) return NutriScoreCategory2023.beverage; - if (isFatOilNutsSeeds) return NutriScoreCategory2023.fatOilNutsSeeds; - if (isCheese) return NutriScoreCategory2023.cheese; - if (isRedMeatProduct) return NutriScoreCategory2023.redMeatProduct; - return NutriScoreCategory2023.general; - } -} diff --git a/lib/src/model/nutriscore_enums.dart b/lib/src/model/nutriscore_enums.dart deleted file mode 100644 index 099b66e09d..0000000000 --- a/lib/src/model/nutriscore_enums.dart +++ /dev/null @@ -1,43 +0,0 @@ -enum NutriScoreStatus { unknown, notApplicable, computed, invalid } - -enum NutriScoreGrade { A, B, C, D, E } - -enum NutriScoreInput { category, nutrients, ingredients } - -enum NutriScoreVersion { - v2021, - v2023; - - /// Returns the name of the NutriScore version. - String get name => switch (this) { - NutriScoreVersion.v2021 => '2021', - NutriScoreVersion.v2023 => '2023', - }; - - /// Parses a Nutri-Score version from a string such as '2021' or '2023'. - /// Returns `null` if the string does not match any known version. - static NutriScoreVersion? tryParse(String? value) { - return switch (value?.toLowerCase()) { - '2021' => NutriScoreVersion.v2021, - '2023' => NutriScoreVersion.v2023, - _ => null, - }; - } -} - -enum NutriScoreCategory2021 { - general, - cheese, - fat, - beverage, - water, -} - -enum NutriScoreCategory2023 { - general, - cheese, - redMeatProduct, - fatOilNutsSeeds, - beverage, - water, -} diff --git a/lib/src/model/nutriscore_raw.dart b/lib/src/model/nutriscore_raw.dart deleted file mode 100644 index 43a705a73e..0000000000 --- a/lib/src/model/nutriscore_raw.dart +++ /dev/null @@ -1,58 +0,0 @@ -import '../interface/json_object.dart'; - -/// Raw model representing Nutri-Score details, including grade, score, -/// and calculation data. -class NutriScoreRaw { - final String? grade; - final int? score; - final NutriScoreDataRaw? data; - final String? notApplicableCategory; - final bool categoryAvailable; - final bool nutrientsAvailable; - - NutriScoreRaw.fromJson(Map json) - : grade = json['grade'] as String?, - score = json['score'] as int?, - categoryAvailable = JsonObject.parseBool(json['category_available']), - nutrientsAvailable = JsonObject.parseBool(json['nutrients_available']), - notApplicableCategory = json['not_applicable_category'] as String?, - data = json['data'] != null - ? NutriScoreDataRaw.fromJson(json['data']) - : null; - - @override - String toString() => 'Nutri-Score(grade: $grade, score: $score, $data)'; -} - -/// Raw model for additional data used in Nutri-Score 2021/2023 calculation, -/// indicating specific product characteristics. -class NutriScoreDataRaw { - final bool isBeverage; - final bool isCheese; - final bool isFat; // 2021 - final bool isFatOilNutsSeeds; // 2023 - final bool isRedMeatProduct; // 2023 - final bool isWater; - - NutriScoreDataRaw.fromJson(Map json) - : isBeverage = JsonObject.parseBool(json['is_beverage']), - isCheese = JsonObject.parseBool(json['is_cheese']), - isFat = JsonObject.parseBool(json['is_fat']), - isFatOilNutsSeeds = JsonObject.parseBool(json['is_fat_oil_nuts_seeds']), - isRedMeatProduct = JsonObject.parseBool(json['is_red_meat_product']), - isWater = JsonObject.parseBool(json['is_water']); - - @override - String toString() { - final flags = [ - if (isBeverage) 'isBeverage', - if (isCheese) 'isCheese', - if (isFat) 'isFat', // 2021 - if (isFatOilNutsSeeds) 'isFatOilNutsSeeds', // 2023 - if (isRedMeatProduct) 'isRedMeatProduct', // 2023 - if (isWater) 'isWater', - ]; - - return 'data(${flags.join(', ')})'; - } -} diff --git a/lib/src/model/product.dart b/lib/src/model/product.dart index e1fb5202ef..87e5b80e81 100644 --- a/lib/src/model/product.dart +++ b/lib/src/model/product.dart @@ -3,7 +3,6 @@ import 'package:json_annotation/json_annotation.dart'; import '../interface/json_object.dart'; import '../utils/json_helper.dart'; import '../utils/language_helper.dart'; -import '../utils/nutriscore_helper.dart'; import '../utils/product_fields.dart'; import 'nutriscore/nutriscore_details.dart'; import 'additives.dart'; @@ -16,8 +15,6 @@ import 'ingredients_analysis_tags.dart'; import 'knowledge_panels.dart'; import 'nutrient_levels.dart'; import 'nutriments.dart'; -import 'nutriscore.dart'; -import 'nutriscore_enums.dart'; import 'owner_field.dart'; import 'product_image.dart'; import 'product_packaging.dart'; @@ -408,9 +405,6 @@ class Product extends JsonObject { ) bool? nutritionData; - @JsonKey(name: 'nutriscore') - NutriScoreDetails? nutriScoreDetails; - /// Size of the product sample for "nutrition data for product as sold". /// /// Typical values: [nutrimentPer100g] or [nutrimentPerServing]. @@ -419,13 +413,8 @@ class Product extends JsonObject { @JsonKey(name: 'nutrition_grade_fr') String? nutriscore; - /// Map of computed Nutri-Scores for different versions (e.g. 2021, 2023). - @JsonKey( - name: 'nutriscore', - fromJson: NutriScoreHelper.fromJson, - toJson: NutriScoreHelper.toJson, - ) - Map? nutriscoreDetails; + @JsonKey(name: 'nutriscore') + NutriScoreDetails? nutriScoreDetails; @JsonKey(name: 'compared_to_category') String? comparedToCategory; @@ -683,7 +672,6 @@ class Product extends JsonObject { this.nutrimentEnergyUnit, this.nutrimentDataPer, this.nutriscore, - this.nutriscoreDetails, this.categories, this.categoriesTags, this.categoriesTagsInLanguages, diff --git a/lib/src/model/product.g.dart b/lib/src/model/product.g.dart index 010adfaba8..dd0e9067fc 100644 --- a/lib/src/model/product.g.dart +++ b/lib/src/model/product.g.dart @@ -57,7 +57,6 @@ Product _$ProductFromJson(Map json) => Product( nutrimentEnergyUnit: json['nutriment_energy_unit'] as String?, nutrimentDataPer: json['nutrition_data_per'] as String?, nutriscore: json['nutrition_grade_fr'] as String?, - nutriscoreDetails: NutriScoreHelper.fromJson(json['nutriscore']), categories: json['categories'] as String?, categoriesTags: (json['categories_tags'] as List?) ?.map((e) => e as String) @@ -285,11 +284,9 @@ Map _$ProductToJson(Product instance) { 'nutrient_levels', NutrientLevels.toJson(instance.nutrientLevels)); writeNotNull('nutriment_energy_unit', instance.nutrimentEnergyUnit); val['nutrition_data'] = JsonHelper.checkboxToJSON(instance.nutritionData); - writeNotNull('nutriscore', instance.nutriScoreDetails); writeNotNull('nutrition_data_per', instance.nutrimentDataPer); writeNotNull('nutrition_grade_fr', instance.nutriscore); - writeNotNull( - 'nutriscore', NutriScoreHelper.toJson(instance.nutriscoreDetails)); + writeNotNull('nutriscore', instance.nutriScoreDetails); writeNotNull('compared_to_category', instance.comparedToCategory); writeNotNull('categories', instance.categories); writeNotNull('categories_tags', instance.categoriesTags); diff --git a/lib/src/utils/nutriscore_helper.dart b/lib/src/utils/nutriscore_helper.dart deleted file mode 100644 index 9b423b14e0..0000000000 --- a/lib/src/utils/nutriscore_helper.dart +++ /dev/null @@ -1,148 +0,0 @@ -import '../model/nutriscore.dart'; -import '../model/nutriscore_category.dart'; -import '../model/nutriscore_enums.dart'; -import '../model/nutriscore_raw.dart'; - -class NutriScoreHelper { - /// Returns Map of [NutriScore]s for each version from a JSON map - static Map? fromJson(dynamic json) { - if (json == null) return null; - - if (json is! Map) { - throw FormatException( - 'Nutri-Score data is not Map, got ${json.runtimeType}'); - } - - final result = {}; - - for (final MapEntry(:key, :value) in json.entries) { - final version = NutriScoreVersion.tryParse(key); - if (version == null || value is! Map) { - continue; - } - - try { - final raw = NutriScoreRaw.fromJson(value); - result[version] = _fromRaw(version, raw); - } catch (_) { - continue; - } - } - return result.isEmpty ? null : result; - } - - /// Converts a Map of [NutriScore]s to a JSON map. - static toJson(Map? nutriscoreDetails) { - if (nutriscoreDetails == null) return null; - - return { - for (final MapEntry(:key, :value) in nutriscoreDetails.entries) - key.name: value.toJson(), - }; - } - - /// Parses the raw grade string from the API into a [NutriScoreStatus] - /// and optional [NutriScoreGrade]. - /// - /// Returns a tuple where the status indicates whether the grade is - /// computed, unknown, or not applicable. - static (NutriScoreStatus, NutriScoreGrade?) _parseGrade(String grade) => - switch (grade.toLowerCase()) { - 'not-applicable' => (NutriScoreStatus.notApplicable, null), - 'a' => (NutriScoreStatus.computed, NutriScoreGrade.A), - 'b' => (NutriScoreStatus.computed, NutriScoreGrade.B), - 'c' => (NutriScoreStatus.computed, NutriScoreGrade.C), - 'd' => (NutriScoreStatus.computed, NutriScoreGrade.D), - 'e' => (NutriScoreStatus.computed, NutriScoreGrade.E), - _ => (NutriScoreStatus.unknown, null), - }; - - /// Parses a [NutriScore] from raw API data using the given [version]. - /// - /// Internally determines the Nutri-Score status, grade, category, and score. - /// Falls back to [NutriScore.invalid] if critical fields are missing. - static NutriScore _fromRaw( - NutriScoreVersion version, - NutriScoreRaw raw, - ) { - final (status, grade) = _parseGrade(raw.grade ?? ''); - final category = NutriScoreCategory.fromRaw(version, raw.data); - final score = raw.score; - final missingData = [ - if (!raw.categoryAvailable) NutriScoreInput.category, - if (!raw.nutrientsAvailable) NutriScoreInput.nutrients, - ]; - - return switch (status) { - NutriScoreStatus.unknown => - NutriScore.unknown(version, category, missingData), - NutriScoreStatus.notApplicable => NutriScore.notApplicable( - version, category, missingData, raw.notApplicableCategory), - NutriScoreStatus.computed - when grade == null || category == null || score == null => - NutriScore.invalid('Missing required fields (grade, category, score)'), - NutriScoreStatus.computed => - NutriScore.computed(version, grade!, category!, score!), - _ => NutriScore.invalid('Unexpected Nutri-Score: ${raw.grade}'), - }; - } -} - -extension _NutriScoreJsonExtension on NutriScore { - /// Converts the [NutriScore] instance to a JSON map. - Map toJson() { - return { - 'grade': _combinedGradeAndStatus, - 'category_available': _isAvailable(NutriScoreInput.category) ? 1 : 0, - 'nutrients_available': _isAvailable(NutriScoreInput.nutrients) ? 1 : 0, - 'data': category?.toJson() ?? {}, - if (score != null) 'score': score, - if (notApplicableCategory != null) - 'not_applicable_category': notApplicableCategory, - }; - } - - bool _isAvailable(NutriScoreInput input) => !missingData.contains(input); - - /// Returns a string representation of the Nutri-Score grade and status. - String get _combinedGradeAndStatus => - grade?.name.toLowerCase() ?? - (status == NutriScoreStatus.notApplicable ? 'not-applicable' : 'unknown'); -} - -extension NutriScoreCategoryJsonExtension on NutriScoreCategory { - /// Converts the [NutriScoreCategory] instance to a JSON map. - Map toJson() { - return switch (version) { - NutriScoreVersion.v2021 => as2021!.toJson(), - NutriScoreVersion.v2023 => as2023!.toJson(), - }; - } -} - -extension NutriScoreCategory2021JsonExtension on NutriScoreCategory2021 { - /// Converts the [NutriScoreCategory2021] instance to a JSON map. - Map toJson() { - return { - 'is_water': this == NutriScoreCategory2021.water ? 1 : 0, - 'is_beverage': this == NutriScoreCategory2021.beverage ? 1 : 0, - 'is_cheese': this == NutriScoreCategory2021.cheese ? 1 : 0, - 'is_fat': this == NutriScoreCategory2021.fat ? 1 : 0, - }; - } -} - -extension NutriScoreCategory2023JsonExtension on NutriScoreCategory2023 { - /// Converts the [NutriScoreCategory2023] instance to a JSON map. - Map toJson() { - return { - 'is_water': this == NutriScoreCategory2023.water ? 1 : 0, - 'is_beverage': this == NutriScoreCategory2023.beverage ? 1 : 0, - 'is_cheese': this == NutriScoreCategory2023.cheese ? 1 : 0, - 'is_red_meat_product': - this == NutriScoreCategory2023.redMeatProduct ? 1 : 0, - 'is_fat_oil_nuts_seeds': - this == NutriScoreCategory2023.fatOilNutsSeeds ? 1 : 0, - }; - } -} diff --git a/test/api_get_product_test.dart b/test/api_get_product_test.dart index ca23ba3f60..92e49745a6 100644 --- a/test/api_get_product_test.dart +++ b/test/api_get_product_test.dart @@ -124,18 +124,6 @@ void main() { expect(result.product, isNotNull); expect(result.product!.barcode, barcode); - final nutriScore = - result.product!.nutriscoreDetails?[NutriScoreVersion.v2023]; - expect(nutriScore, isNotNull); - expect(nutriScore!.status, NutriScoreStatus.notApplicable); - expect(nutriScore.version, NutriScoreVersion.v2023); - expect(nutriScore.grade, isNull); - expect(nutriScore.score, isNull); - expect(nutriScore.category, isNotNull); - expect(nutriScore.category!.as2023, NutriScoreCategory2023.beverage); - expect(nutriScore.missingData, isEmpty); - expect(nutriScore.notApplicableCategory, "en:alcoholic-beverages"); - const Nutrient alcohol = Nutrient.alcohol; expect(result.product!.nutriments, isNotNull); final Nutriments nutriments = result.product!.nutriments!; @@ -170,11 +158,23 @@ void main() { expect(result.barcode, barcode); expect(result.product, isNotNull); + // test Nutri-Score with domain model + final nutriScore = result.product!.nutriScoreDetails?.get2023(); + expect(nutriScore, isNotNull); + expect(nutriScore!.isNotApplicable, isTrue); + expect(nutriScore.grade, isNull); + expect(nutriScore.score, isNull); + expect(nutriScore.category, isNotNull); + expect(nutriScore.category, NutriScoreCategory2023.beverage); + expect(nutriScore.missingData, isEmpty); + expect(nutriScore.notApplicableCategory, "en:alcoholic-beverages"); + + // test Nutri-Score with raw data expect(result.product!.nutriScoreDetails, isNotNull); expect(result.product!.nutriScoreDetails!.nutriScore2021, isNotNull); expect( result.product!.nutriScoreDetails!.nutriScore2021!.grade, - NutriScoreGrade.notApplicable, + "not-applicable", ); expect( result.product!.nutriScoreDetails!.nutriScore2021!.categoryAvailable, @@ -228,7 +228,7 @@ void main() { ); expect( result.product!.nutriScoreDetails!.nutriScore2023!.grade, - NutriScoreGrade.notApplicable, + "not-applicable", ); expect( result.product!.nutriScoreDetails!.nutriScore2023!.score, @@ -284,11 +284,23 @@ void main() { expect(result.barcode, barcode); expect(result.product, isNotNull); + // test Nutri-Score with domain model + final nutriScore = result.product!.nutriScoreDetails?.get2023(); + expect(nutriScore, isNotNull); + expect(nutriScore!.isComputed, isTrue); + expect(nutriScore.grade, NutriScoreGrade.E); + expect(nutriScore.score, 25); + expect(nutriScore.category, isNotNull); + expect(nutriScore.category, NutriScoreCategory2023.general); + expect(nutriScore.missingData, isEmpty); + expect(nutriScore.notApplicableCategory, isNull); + + // test Nutri-Score with raw data expect(result.product!.nutriScoreDetails, isNotNull); expect(result.product!.nutriScoreDetails!.nutriScore2021, isNotNull); expect( result.product!.nutriScoreDetails!.nutriScore2021!.grade, - NutriScoreGrade.e, + "e", ); expect( result.product!.nutriScoreDetails!.nutriScore2021!.categoryAvailable, @@ -342,7 +354,7 @@ void main() { ); expect( result.product!.nutriScoreDetails!.nutriScore2023!.grade, - NutriScoreGrade.e, + "e", ); expect( result.product!.nutriScoreDetails!.nutriScore2023!.score, @@ -433,18 +445,6 @@ void main() { expect(result.product!.nutriscore, 'e'); - final nutriScore = - result.product!.nutriscoreDetails?[NutriScoreVersion.v2023]; - expect(nutriScore, isNotNull); - expect(nutriScore!.status, NutriScoreStatus.computed); - expect(nutriScore.version, NutriScoreVersion.v2023); - expect(nutriScore.grade, NutriScoreGrade.E); - expect(nutriScore.score, 25); - expect(nutriScore.category, isNotNull); - expect(nutriScore.category!.as2023, NutriScoreCategory2023.general); - expect(nutriScore.missingData, isEmpty); - expect(nutriScore.notApplicableCategory, isNull); - expect(result.product!.nutriments, isNotNull); final Nutriments nutriments = result.product!.nutriments!; diff --git a/test/nutriscore_from_json_test.dart b/test/nutriscore_from_json_test.dart index 3ecf614388..c0fb7a9746 100644 --- a/test/nutriscore_from_json_test.dart +++ b/test/nutriscore_from_json_test.dart @@ -3,109 +3,127 @@ import 'package:test/test.dart'; void main() { group('NutriScore.fromJson', () { - test('returns null when input is null', () { - final result = NutriScoreHelper.fromJson(null); - expect(result, isNull); - }); - - test('throws FormatException on non-map input', () { - expect( - () => NutriScoreHelper.fromJson(['not', 'a', 'map']), - throwsA(isA()), - ); + test('returns no Nutri-Scores when input is empty', () { + final result = NutriScoreDetails.fromJson({}); + expect(result, isNotNull); + expect(result.nutriScore2021, isNull); + expect(result.nutriScore2023, isNull); }); - test('ignores entries with unknown versions', () { - final result = NutriScoreHelper.fromJson({ - 'invalid_version': {'grade': 'a'} + test('ignores entries with unknown versions or null', () { + final result = NutriScoreDetails.fromJson({ + 'invalid_version': {'grade': 'a'}, + '2021': null, }); - expect(result, isNull); + expect(result, isNotNull); + expect(result.nutriScore2021, isNull); + expect(result.nutriScore2023, isNull); }); - test('ignores entries with invalid structure', () { - final result = NutriScoreHelper.fromJson({ - '2023': 'not a map', - '2021': null, - }); - expect(result, isNull); + test('throws Error on non-map input', () { + expect( + () => NutriScoreDetails.fromJson( + {'2023': 'not a map'}, + ), + throwsA(isA()), + ); }); test('parses computed NutriScore for version 2023', () { - final result = NutriScoreHelper.fromJson(computed); + final result = NutriScoreDetails.fromJson(computed); expect(result, isNotNull); - expect(result!.length, 1); - expect(result, contains(NutriScoreVersion.v2023)); + expect(result.nutriScore2021, isNull); + expect(result.nutriScore2023, isNotNull); - final nutriScore = result[NutriScoreVersion.v2023]!; - expect(nutriScore.version, NutriScoreVersion.v2023); - expect(nutriScore.status, NutriScoreStatus.computed); + final nutriScore = result.get2023()!; + expect(nutriScore.isComputed, isTrue); expect(nutriScore.missingData, isEmpty); expect(nutriScore.grade, NutriScoreGrade.B); expect(nutriScore.score, 2); - expect(nutriScore.category?.version, NutriScoreVersion.v2023); - expect(nutriScore.category?.as2023, NutriScoreCategory2023.beverage); + expect(nutriScore.category, NutriScoreCategory2023.beverage); }); test('parses unknown NutriScore for version 2023', () { - final result = NutriScoreHelper.fromJson(unknown); + final result = NutriScoreDetails.fromJson(unknown); expect(result, isNotNull); - expect(result!.length, 1); - expect(result, contains(NutriScoreVersion.v2023)); + expect(result.nutriScore2021, isNull); + expect(result.nutriScore2023, isNotNull); - final nutriScore = result[NutriScoreVersion.v2023]!; - expect(nutriScore.version, NutriScoreVersion.v2023); - expect(nutriScore.status, NutriScoreStatus.unknown); + final nutriScore = result.get2023()!; + expect(nutriScore.isUnknown, isTrue); expect(nutriScore.missingData.length, 1); + expect(nutriScore.missingData, contains(NutriScoreInput.nutrients)); expect(nutriScore.grade, isNull); expect(nutriScore.score, isNull); - expect(nutriScore.category?.version, NutriScoreVersion.v2023); - expect(nutriScore.category?.as2023, NutriScoreCategory2023.general); - expect(nutriScore.missingData, contains(NutriScoreInput.nutrients)); + expect(nutriScore.category, NutriScoreCategory2023.general); }); test('parses not applicable NutriScore for version 2023', () { - final result = NutriScoreHelper.fromJson(notApplicable); + final result = NutriScoreDetails.fromJson(notApplicable); expect(result, isNotNull); - expect(result!.length, 1); - expect(result, contains(NutriScoreVersion.v2023)); + expect(result.nutriScore2021, isNull); + expect(result.nutriScore2023, isNotNull); - final nutriScore = result[NutriScoreVersion.v2023]!; - expect(nutriScore.version, NutriScoreVersion.v2023); - expect(nutriScore.status, NutriScoreStatus.notApplicable); - expect(nutriScore.missingData, isEmpty); + final nutriScore = result.get2023()!; + expect(nutriScore.isNotApplicable, isTrue); expect(nutriScore.grade, isNull); expect(nutriScore.score, isNull); - expect(nutriScore.category?.as2023, NutriScoreCategory2023.beverage); + expect(nutriScore.category, NutriScoreCategory2023.beverage); + expect(nutriScore.missingData, isEmpty); + }); + + test('parses mixed data types in categories', () { + final result = NutriScoreDetails.fromJson(mixedDataTypes); + expect(result, isNotNull); + expect(result.nutriScore2021, isNotNull); + expect(result.nutriScore2023, isNull); + + final nutriScore = result.get2021()!; + expect(nutriScore.isComputed, isTrue); + expect(nutriScore.grade, NutriScoreGrade.D); + expect(nutriScore.score, isNotNull); + expect(nutriScore.category, NutriScoreCategory2021.cheese); expect(nutriScore.missingData, isEmpty); }); test('parses inconsistent NutriScore data', () { - final result = NutriScoreHelper.fromJson(inconsistent); + final result = NutriScoreDetails.fromJson(inconsistent); expect(result, isNotNull); - expect(result!.length, 2); - expect(result, contains(NutriScoreVersion.v2021)); + expect(result.nutriScore2021, isNotNull); + expect(result.nutriScore2023, isNotNull); - final nutriScore = result[NutriScoreVersion.v2021]!; - expect(nutriScore.version, NutriScoreVersion.v2021); - expect(nutriScore.status, NutriScoreStatus.notApplicable); + final nutriScore = result.get2021()!; + expect(nutriScore.isNotApplicable, isTrue); expect(nutriScore.grade, isNull); expect(nutriScore.score, isNull); - expect(nutriScore.category?.as2021, NutriScoreCategory2021.water); + expect(nutriScore.category, NutriScoreCategory2021.water); expect(nutriScore.missingData, isEmpty); }); }); test('round-trip toJson/fromJson', () { for (final json in [computed, unknown, notApplicable, inconsistent]) { - final original = NutriScoreHelper.fromJson(json); + final original = NutriScoreDetails.fromJson(json); expect(original, isNotNull); - expect(original, equals(original)); - - final output = NutriScoreHelper.toJson(original!); - final copy = NutriScoreHelper.fromJson(output); + final copy = NutriScoreDetails.fromJson(original.toJson()); expect(copy, isNotNull); - expect(copy, equals(original)); + + final orig2021 = original.get2021(); + final copy2021 = copy.get2021(); + final orig2023 = original.get2023(); + final copy2023 = copy.get2023(); + + expect(copy2021?.category, equals(original.get2021()?.category)); + expect(copy2021?.grade, equals(original.get2021()?.grade)); + expect(copy2021?.score, equals(orig2021?.score)); + expect(copy2021?.missingData, equals(orig2021?.missingData)); + expect(copy2023?.category, equals(orig2023?.category)); + expect(copy2023?.grade, equals(orig2023?.grade)); + expect(copy2023?.score, equals(orig2023?.score)); + expect(copy2023?.missingData, equals(orig2023?.missingData)); + expect(copy2023?.notApplicableCategory, + equals(orig2023?.notApplicableCategory)); } }); } @@ -130,6 +148,7 @@ final unknown = { '2023': { 'grade': 'unknown', 'category_available': 1, + 'nutrients_available': 0, 'data': { 'is_beverage': 0, 'is_cheese': 0, @@ -156,6 +175,22 @@ final notApplicable = { } }; +final mixedDataTypes = { + // e.g. 2336152903783 + '2021': { + 'grade': 'd', + 'score': 15, + 'category_available': 1, + 'nutrients_available': 1, + 'data': { + 'is_beverage': 0, + 'is_cheese': "1", + 'is_fat': 0, + 'is_water': 0, + } + }, +}; + final inconsistent = { '2021': { 'grade': 'not-applicable', @@ -173,7 +208,7 @@ final inconsistent = { '2023': { 'grade': 'unknown', 'category_available': 1, - 'nutrients_available': 1, + 'nutrients_available': 0, 'not_applicable_category': 'en:alcoholic-beverages', 'data': { 'is_red_meat_product': 1, From 89a9358b03cf3cd9aac760920e9849d281110be4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olaf=20G=C3=B6rlitz?= Date: Fri, 16 May 2025 23:59:39 +0200 Subject: [PATCH 06/12] removed dart 3.0 dependency, minor improvements --- lib/src/model/nutriscore/nutriscore.dart | 72 +++++++++++++++--------- pubspec.yaml | 2 +- 2 files changed, 47 insertions(+), 27 deletions(-) diff --git a/lib/src/model/nutriscore/nutriscore.dart b/lib/src/model/nutriscore/nutriscore.dart index 59045c7d73..2f2ed92470 100644 --- a/lib/src/model/nutriscore/nutriscore.dart +++ b/lib/src/model/nutriscore/nutriscore.dart @@ -24,6 +24,9 @@ enum NutriScoreInput { category, nutrients, ingredients } typedef NutriScore2021 = _NutriScore; typedef NutriScore2023 = _NutriScore; +/// Private base class for NutriScore. +/// It is used to define the common properties and methods for both +/// NutriScore 2021 and NutriScore 2023. class _NutriScore { final NutriScoreGrade? grade; final int? score; @@ -32,58 +35,75 @@ class _NutriScore { final T? category; _NutriScore({ - String? grade, + this.grade, this.category, this.score, this.notApplicableCategory, - bool hasCategory = false, - bool hasNutrients = false, - }) : grade = parseGrade(grade ?? ''), - missingData = [ - if (!hasCategory) NutriScoreInput.category, - if (!hasNutrients) NutriScoreInput.nutrients, - ]; + this.missingData = const [], + }) : assert( + (grade != null && score != null && category != null) || + (grade == null && (notApplicableCategory?.isNotEmpty ?? false)) || + (grade == null && missingData.isNotEmpty), + 'Either NutriScore is computed or not applicable or unknown', + ); bool get isComputed => grade != null; - bool get isNotApplicable => grade == null && notApplicableCategory != null; - bool get isUnknown => grade == null && missingData.isNotEmpty; - - static NutriScoreGrade? parseGrade(String grade) { - return switch (grade.toLowerCase()) { - 'a' => NutriScoreGrade.A, - 'b' => NutriScoreGrade.B, - 'c' => NutriScoreGrade.C, - 'd' => NutriScoreGrade.D, - 'e' => NutriScoreGrade.E, - _ => null, - }; + bool get isNotApplicable => notApplicableCategory?.isNotEmpty ?? false; + bool get isUnknown => missingData.isNotEmpty; +} + +NutriScoreGrade? _parseGrade(String? grade) { + switch (grade?.toLowerCase()) { + case 'a': + return NutriScoreGrade.A; + case 'b': + return NutriScoreGrade.B; + case 'c': + return NutriScoreGrade.C; + case 'd': + return NutriScoreGrade.D; + case 'e': + return NutriScoreGrade.E; + default: + return null; } } +List _missingData(bool? hasCategory, bool? hasNutrients) => [ + if (hasCategory != true) NutriScoreInput.category, + if (hasNutrients != true) NutriScoreInput.nutrients, + ]; + extension NutriScoreDetails2021Ext on NutriScoreDetails { + /// Returns the NutriScore 2021 as domain model. NutriScore2021? get2021() { if (nutriScore2021 == null) return null; return NutriScore2021( category: nutriScore2021?.data?.category, - grade: nutriScore2021?.grade, + grade: _parseGrade(nutriScore2021?.grade), score: nutriScore2021?.score, notApplicableCategory: nutriScore2021?.notApplicableCategory, - hasCategory: nutriScore2021?.categoryAvailable == true, - hasNutrients: nutriScore2021?.nutrientsAvailable == true, + missingData: _missingData( + nutriScore2021?.categoryAvailable, + nutriScore2021?.nutrientsAvailable, + ), ); } + /// Returns the NutriScore 2023 as domain model. NutriScore2023? get2023() { if (nutriScore2023 == null) return null; return NutriScore2023( category: nutriScore2023?.data?.category, - grade: nutriScore2023?.grade, + grade: _parseGrade(nutriScore2023?.grade), score: nutriScore2023?.score, notApplicableCategory: nutriScore2023?.notApplicableCategory, - hasCategory: nutriScore2023?.categoryAvailable == true, - hasNutrients: nutriScore2023?.nutrientsAvailable == true, + missingData: _missingData( + nutriScore2023?.categoryAvailable, + nutriScore2023?.nutrientsAvailable, + ), ); } } diff --git a/pubspec.yaml b/pubspec.yaml index 5fa7c5ec7b..b1161dc063 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -5,7 +5,7 @@ version: 3.22.0 homepage: https://github.com/openfoodfacts/openfoodfacts-dart environment: - sdk: '>=3.0.0 <4.0.0' + sdk: '>=2.17.0 <4.0.0' dependencies: json_annotation: ^4.9.0 From 6b3941c2ac112b2eb2f00a6bfcebebe56c904457 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olaf=20G=C3=B6rlitz?= Date: Sat, 24 May 2025 22:22:56 +0200 Subject: [PATCH 07/12] refactoring after code review and improved documentation --- lib/src/model/nutriscore/nutriscore.dart | 203 +++++++------- .../nutriscore/nutriscore_data_2021.dart | 11 + .../nutriscore/nutriscore_data_2023.dart | 13 + .../model/nutriscore/nutriscore_details.dart | 49 +++- .../nutriscore/nutriscore_details.g.dart | 8 +- test/api_get_product_test.dart | 260 +++++------------- test/nutriscore_from_json_test.dart | 71 +++-- 7 files changed, 274 insertions(+), 341 deletions(-) diff --git a/lib/src/model/nutriscore/nutriscore.dart b/lib/src/model/nutriscore/nutriscore.dart index 2f2ed92470..c2aa16909f 100644 --- a/lib/src/model/nutriscore/nutriscore.dart +++ b/lib/src/model/nutriscore/nutriscore.dart @@ -1,6 +1,14 @@ -import 'package:openfoodfacts/openfoodfacts.dart'; +import '../off_tagged.dart'; -enum NutriScoreCategory2021 { +/// Internal marker interface for version-specific Nutri-Score categories. +/// +/// This is used internally by the abstract [NutriScore] base class to enable +/// shared handling of 2021 and 2023 category types. It is intentionally private +/// to prevent accidental external usage or misimplementation. +abstract class _NutriScoreCategory {} + +/// Nutri-Score categories defined for the 2021 specification. +enum NutriScoreCategory2021 implements _NutriScoreCategory { general, cheese, fat, @@ -8,7 +16,8 @@ enum NutriScoreCategory2021 { water, } -enum NutriScoreCategory2023 { +/// Nutri-Score categories defined for the 2023 specification. +enum NutriScoreCategory2023 implements _NutriScoreCategory { general, cheese, redMeatProduct, @@ -17,119 +26,109 @@ enum NutriScoreCategory2023 { water, } -enum NutriScoreGrade { A, B, C, D, E } - -enum NutriScoreInput { category, nutrients, ingredients } - -typedef NutriScore2021 = _NutriScore; -typedef NutriScore2023 = _NutriScore; +/// Nutri-Score letter grades: `A` (best) to `E` (worst). +/// +/// Implements [OffTagged] to support conversion from tags. +enum NutriScoreGrade implements OffTagged { + a, + b, + c, + d, + e; + + @override + String get offTag => name; +} -/// Private base class for NutriScore. -/// It is used to define the common properties and methods for both -/// NutriScore 2021 and NutriScore 2023. -class _NutriScore { +/// Abstract base class for Nutri-Score models (e.g. 2021, 2023). +/// +/// Provides common properties and logic for representing Nutri-Score data +/// in a version-agnostic, structured way. Subclasses enforce version-specific +/// category types. +/// +/// This model ensures: +/// - Grades are normalized from tags (`a`–`e`) +/// - Categories are only available when `categoryAvailable` is `true` +/// - Semantic helpers like `isComputed` and `isApplicable` are provided +abstract class NutriScore { + /// The Nutri-Score grade (`A`–`E`), or `null` if unavailable. final NutriScoreGrade? grade; + + /// The raw numeric score used to derive the grade. final int? score; + + /// The version-specific food category used to evaluate the Nutri-Score. + final _NutriScoreCategory? category; + + /// Specifies the product's category for which the Nutri-Score is not applicable. final String? notApplicableCategory; - final List missingData; - final T? category; - _NutriScore({ - this.grade, - this.category, + /// Indicates whether the category required to compute the Nutri-Score is available. + final bool categoryAvailable; + + /// Indicates whether the nutrients required to compute the Nutri-Score are available. + final bool nutrientsAvailable; + + /// Constructs a [NutriScore] model from raw data and enforces valid semantics. + /// + /// - If `categoryAvailable` is false, `category` will be set to `null`. + /// - Grade tags are parsed to `NutriScoreGrade` (`A`-`E`) via [OffTagged]. + NutriScore({ + String? grade, + _NutriScoreCategory? category, this.score, this.notApplicableCategory, - this.missingData = const [], - }) : assert( - (grade != null && score != null && category != null) || - (grade == null && (notApplicableCategory?.isNotEmpty ?? false)) || - (grade == null && missingData.isNotEmpty), - 'Either NutriScore is computed or not applicable or unknown', - ); + this.categoryAvailable = false, + this.nutrientsAvailable = false, + }) : grade = OffTagged.fromOffTag(grade, NutriScoreGrade.values) + as NutriScoreGrade?, + category = categoryAvailable ? category : null; + /// `true` if Nutri-Score has been computed. bool get isComputed => grade != null; + + /// `true` if the Nutri-Score is not applicable to the product (see [notApplicableCategory]). bool get isNotApplicable => notApplicableCategory?.isNotEmpty ?? false; - bool get isUnknown => missingData.isNotEmpty; -} -NutriScoreGrade? _parseGrade(String? grade) { - switch (grade?.toLowerCase()) { - case 'a': - return NutriScoreGrade.A; - case 'b': - return NutriScoreGrade.B; - case 'c': - return NutriScoreGrade.C; - case 'd': - return NutriScoreGrade.D; - case 'e': - return NutriScoreGrade.E; - default: - return null; - } -} + /// `true` if the Nutri-Score is applicable to the product (but may not be computed due to missing data). + bool get isApplicable => categoryAvailable && notApplicableCategory == null; -List _missingData(bool? hasCategory, bool? hasNutrients) => [ - if (hasCategory != true) NutriScoreInput.category, - if (hasNutrients != true) NutriScoreInput.nutrients, - ]; - -extension NutriScoreDetails2021Ext on NutriScoreDetails { - /// Returns the NutriScore 2021 as domain model. - NutriScore2021? get2021() { - if (nutriScore2021 == null) return null; - - return NutriScore2021( - category: nutriScore2021?.data?.category, - grade: _parseGrade(nutriScore2021?.grade), - score: nutriScore2021?.score, - notApplicableCategory: nutriScore2021?.notApplicableCategory, - missingData: _missingData( - nutriScore2021?.categoryAvailable, - nutriScore2021?.nutrientsAvailable, - ), - ); - } - - /// Returns the NutriScore 2023 as domain model. - NutriScore2023? get2023() { - if (nutriScore2023 == null) return null; - - return NutriScore2023( - category: nutriScore2023?.data?.category, - grade: _parseGrade(nutriScore2023?.grade), - score: nutriScore2023?.score, - notApplicableCategory: nutriScore2023?.notApplicableCategory, - missingData: _missingData( - nutriScore2023?.categoryAvailable, - nutriScore2023?.nutrientsAvailable, - ), - ); - } + /// `true` if any data required to compute the Nutri-Score is missing. + bool get hasMissingData => !categoryAvailable || !nutrientsAvailable; } -/// Extension to infer [NutriScoreCategory2021] from boolean flags. -extension NutriScoreData2021Ext on NutriScoreData2021 { - NutriScoreCategory2021 get category { - // water must be checked first to avoid beverage+water conflict - if (isWater == true) return NutriScoreCategory2021.water; - if (isBeverage == true) return NutriScoreCategory2021.beverage; - if (isFat == true) return NutriScoreCategory2021.fat; - if (isCheese == true) return NutriScoreCategory2021.cheese; - return NutriScoreCategory2021.general; - } +/// Nutri-Score domain model for the 2021 specification. +/// +/// Accepts only [NutriScoreCategory2021] categories. +class NutriScore2021 extends NutriScore { + NutriScore2021({ + NutriScoreCategory2021? category, + super.grade, + super.score, + super.notApplicableCategory, + super.categoryAvailable, + super.nutrientsAvailable, + }) : super(category: category); + + @override + NutriScoreCategory2021? get category => + super.category as NutriScoreCategory2021?; } -/// Extension to infer [NutriScoreCategory2023] from boolean flags. -extension NutriScoreData2023Ext on NutriScoreData2023 { - NutriScoreCategory2023 get category { - // water must be checked first to avoid beverage+water conflict - if (isWater == true) return NutriScoreCategory2023.water; - if (isBeverage == true) return NutriScoreCategory2023.beverage; - if (isFatOilNutsSeeds == true) - return NutriScoreCategory2023.fatOilNutsSeeds; - if (isCheese == true) return NutriScoreCategory2023.cheese; - if (isRedMeatProduct == true) return NutriScoreCategory2023.redMeatProduct; - return NutriScoreCategory2023.general; - } +/// Nutri-Score domain model for the 2023 specification. +/// +/// Accepts only [NutriScoreCategory2023] categories. +class NutriScore2023 extends NutriScore { + NutriScore2023({ + NutriScoreCategory2023? category, + super.grade, + super.score, + super.notApplicableCategory, + super.categoryAvailable, + super.nutrientsAvailable, + }) : super(category: category); + + @override + NutriScoreCategory2023? get category => + super.category as NutriScoreCategory2023?; } diff --git a/lib/src/model/nutriscore/nutriscore_data_2021.dart b/lib/src/model/nutriscore/nutriscore_data_2021.dart index cb74680169..4b01b20c55 100644 --- a/lib/src/model/nutriscore/nutriscore_data_2021.dart +++ b/lib/src/model/nutriscore/nutriscore_data_2021.dart @@ -1,6 +1,7 @@ import 'package:json_annotation/json_annotation.dart'; import '../../interface/json_object.dart'; import '../../utils/json_helper.dart'; +import 'nutriscore.dart'; part 'nutriscore_data_2021.g.dart'; @@ -43,6 +44,16 @@ class NutriScoreData2021 extends JsonObject { NutriScoreData2021(); + /// Infers the [NutriScoreCategory2021] based on boolean flags. + NutriScoreCategory2021 get category { + // water must be checked first to avoid beverage+water conflict + if (isWater == true) return NutriScoreCategory2021.water; + if (isBeverage == true) return NutriScoreCategory2021.beverage; + if (isFat == true) return NutriScoreCategory2021.fat; + if (isCheese == true) return NutriScoreCategory2021.cheese; + return NutriScoreCategory2021.general; + } + factory NutriScoreData2021.fromJson(Map json) => _$NutriScoreData2021FromJson(json); diff --git a/lib/src/model/nutriscore/nutriscore_data_2023.dart b/lib/src/model/nutriscore/nutriscore_data_2023.dart index 3c56d01742..bc50d17923 100644 --- a/lib/src/model/nutriscore/nutriscore_data_2023.dart +++ b/lib/src/model/nutriscore/nutriscore_data_2023.dart @@ -1,6 +1,7 @@ import 'package:json_annotation/json_annotation.dart'; import '../../interface/json_object.dart'; import '../../utils/json_helper.dart'; +import 'nutriscore.dart'; part 'nutriscore_data_2023.g.dart'; @@ -66,6 +67,18 @@ class NutriScoreData2023 extends JsonObject { NutriScoreData2023(); + /// Infers the [NutriScoreCategory2023] based on boolean flags. + NutriScoreCategory2023 get category { + // water must be checked first to avoid beverage+water conflict + if (isWater == true) return NutriScoreCategory2023.water; + if (isBeverage == true) return NutriScoreCategory2023.beverage; + if (isFatOilNutsSeeds == true) + return NutriScoreCategory2023.fatOilNutsSeeds; + if (isCheese == true) return NutriScoreCategory2023.cheese; + if (isRedMeatProduct == true) return NutriScoreCategory2023.redMeatProduct; + return NutriScoreCategory2023.general; + } + factory NutriScoreData2023.fromJson(Map json) => _$NutriScoreData2023FromJson(json); diff --git a/lib/src/model/nutriscore/nutriscore_details.dart b/lib/src/model/nutriscore/nutriscore_details.dart index ba4e0471ce..ceb14641b1 100644 --- a/lib/src/model/nutriscore/nutriscore_details.dart +++ b/lib/src/model/nutriscore/nutriscore_details.dart @@ -2,20 +2,63 @@ import 'package:json_annotation/json_annotation.dart'; import '../../interface/json_object.dart'; import 'nutriscore_detail_2021.dart'; import 'nutriscore_detail_2023.dart'; +import 'nutriscore.dart'; part 'nutriscore_details.g.dart'; -/// NutriScore detailed info. +/// Container for Nutri-Score data from both 2021 and 2023 versions, as received from the API. +/// +/// This class includes: +/// - The raw deserialized data for each version (`rawNutriScore2021`, `rawNutriScore2023`) +/// - Strongly typed domain models (`nutriScore2021`, `nutriScore2023`) derived from the raw data +/// +/// While the raw data reflects the exact API structure, it is recommended to use +/// the domain models in application logic, as they provide a cleaner, validated, +/// and version-specific representation of Nutri-Score values. @JsonSerializable(explicitToJson: true) class NutriScoreDetails extends JsonObject { @JsonKey(name: '2021') - NutriScoreDetail2021? nutriScore2021; + NutriScoreDetail2021? rawNutriScore2021; @JsonKey(name: '2023') - NutriScoreDetail2023? nutriScore2023; + NutriScoreDetail2023? rawNutriScore2023; NutriScoreDetails(); + /// Returns a strongly typed domain model specific to Nutri-Score 2021. + /// + /// Ensures that only valid 2021-specific categories and values are used. + /// Returns `null` if no raw data is available. + NutriScore2021? get nutriScore2021 { + if (rawNutriScore2021 == null) return null; + + return NutriScore2021( + category: rawNutriScore2021?.data?.category, + grade: rawNutriScore2021?.grade, + score: rawNutriScore2021?.score, + notApplicableCategory: rawNutriScore2021?.notApplicableCategory, + categoryAvailable: rawNutriScore2021?.categoryAvailable ?? false, + nutrientsAvailable: rawNutriScore2021?.nutrientsAvailable ?? false, + ); + } + + /// Returns a strongly typed domain model specific to Nutri-Score 2023. + /// + /// Ensures that only valid 2023-specific categories and values are used. + /// Returns `null` if no raw data is available. + NutriScore2023? get nutriScore2023 { + if (rawNutriScore2023 == null) return null; + + return NutriScore2023( + category: rawNutriScore2023?.data?.category, + grade: rawNutriScore2023?.grade, + score: rawNutriScore2023?.score, + notApplicableCategory: rawNutriScore2023?.notApplicableCategory, + categoryAvailable: rawNutriScore2023?.categoryAvailable ?? false, + nutrientsAvailable: rawNutriScore2023?.nutrientsAvailable ?? false, + ); + } + factory NutriScoreDetails.fromJson(Map json) => _$NutriScoreDetailsFromJson(json); diff --git a/lib/src/model/nutriscore/nutriscore_details.g.dart b/lib/src/model/nutriscore/nutriscore_details.g.dart index 6caa85c6ad..10efcc01c4 100644 --- a/lib/src/model/nutriscore/nutriscore_details.g.dart +++ b/lib/src/model/nutriscore/nutriscore_details.g.dart @@ -8,15 +8,15 @@ part of 'nutriscore_details.dart'; NutriScoreDetails _$NutriScoreDetailsFromJson(Map json) => NutriScoreDetails() - ..nutriScore2021 = json['2021'] == null + ..rawNutriScore2021 = json['2021'] == null ? null : NutriScoreDetail2021.fromJson(json['2021'] as Map) - ..nutriScore2023 = json['2023'] == null + ..rawNutriScore2023 = json['2023'] == null ? null : NutriScoreDetail2023.fromJson(json['2023'] as Map); Map _$NutriScoreDetailsToJson(NutriScoreDetails instance) => { - '2021': instance.nutriScore2021?.toJson(), - '2023': instance.nutriScore2023?.toJson(), + '2021': instance.rawNutriScore2021?.toJson(), + '2023': instance.rawNutriScore2023?.toJson(), }; diff --git a/test/api_get_product_test.dart b/test/api_get_product_test.dart index 92e49745a6..5ec667b5ac 100644 --- a/test/api_get_product_test.dart +++ b/test/api_get_product_test.dart @@ -158,113 +158,47 @@ void main() { expect(result.barcode, barcode); expect(result.product, isNotNull); + final nutriScoreDetails = result.product!.nutriScoreDetails; + expect(nutriScoreDetails, isNotNull); + // test Nutri-Score with domain model - final nutriScore = result.product!.nutriScoreDetails?.get2023(); + final nutriScore = nutriScoreDetails!.nutriScore2023; expect(nutriScore, isNotNull); expect(nutriScore!.isNotApplicable, isTrue); expect(nutriScore.grade, isNull); expect(nutriScore.score, isNull); expect(nutriScore.category, isNotNull); expect(nutriScore.category, NutriScoreCategory2023.beverage); - expect(nutriScore.missingData, isEmpty); - expect(nutriScore.notApplicableCategory, "en:alcoholic-beverages"); + expect(nutriScore.hasMissingData, isFalse); + expect(nutriScore.notApplicableCategory, category); // test Nutri-Score with raw data - expect(result.product!.nutriScoreDetails, isNotNull); - expect(result.product!.nutriScoreDetails!.nutriScore2021, isNotNull); - expect( - result.product!.nutriScoreDetails!.nutriScore2021!.grade, - "not-applicable", - ); - expect( - result.product!.nutriScoreDetails!.nutriScore2021!.categoryAvailable, - isTrue, - ); - expect( - result.product!.nutriScoreDetails!.nutriScore2021!.score, - isNull, - ); - expect( - result.product!.nutriScoreDetails!.nutriScore2021!.nutrientsAvailable, - isTrue, - ); - expect( - result.product!.nutriScoreDetails!.nutriScore2021!.nutriScoreComputed, - isFalse, - ); - expect( - result.product!.nutriScoreDetails!.nutriScore2021!.nutriScoreApplicable, - isFalse, - ); - expect( - result.product!.nutriScoreDetails!.nutriScore2021!.data, - isNotNull, - ); - expect( - result.product!.nutriScoreDetails!.nutriScore2021!.data!.isBeverage, - isTrue, - ); - expect( - result.product!.nutriScoreDetails!.nutriScore2021!.data!.isWater, - isFalse, - ); - expect( - result.product!.nutriScoreDetails!.nutriScore2021!.data!.isCheese, - isFalse, - ); - expect( - result.product!.nutriScoreDetails!.nutriScore2021!.data!.isFat, - isFalse, - ); - expect( - result - .product!.nutriScoreDetails?.nutriScore2021?.notApplicableCategory, - category, - ); - - expect( - result.product!.nutriScoreDetails!.nutriScore2023, - isNotNull, - ); - expect( - result.product!.nutriScoreDetails!.nutriScore2023!.grade, - "not-applicable", - ); - expect( - result.product!.nutriScoreDetails!.nutriScore2023!.score, - isNull, - ); - expect( - result.product!.nutriScoreDetails!.nutriScore2023!.data, - isNotNull, - ); - expect( - result - .product!.nutriScoreDetails!.nutriScore2023!.notApplicableCategory, - category, - ); - expect( - result.product!.nutriScoreDetails!.nutriScore2023!.data!.isBeverage, - isTrue, - ); - expect( - result - .product!.nutriScoreDetails!.nutriScore2023!.data!.isRedMeatProduct, - isFalse, - ); - expect( - result.product!.nutriScoreDetails!.nutriScore2023!.data! - .isFatOilNutsSeeds, - isFalse, - ); - expect( - result.product!.nutriScoreDetails!.nutriScore2023!.data!.isWater, - isFalse, - ); - expect( - result.product!.nutriScoreDetails!.nutriScore2023!.data!.isCheese, - isFalse, - ); + final rawNutriScore2021 = nutriScoreDetails.rawNutriScore2021; + expect(rawNutriScore2021, isNotNull); + expect(rawNutriScore2021!.grade, "not-applicable"); + expect(rawNutriScore2021.categoryAvailable, isTrue); + expect(rawNutriScore2021.score, isNull); + expect(rawNutriScore2021.nutrientsAvailable, isTrue); + expect(rawNutriScore2021.nutriScoreComputed, isFalse); + expect(rawNutriScore2021.nutriScoreApplicable, isFalse); + expect(rawNutriScore2021.data, isNotNull); + expect(rawNutriScore2021.data!.isBeverage, isTrue); + expect(rawNutriScore2021.data!.isWater, isFalse); + expect(rawNutriScore2021.data!.isCheese, isFalse); + expect(rawNutriScore2021.data!.isFat, isFalse); + expect(rawNutriScore2021.notApplicableCategory, category); + + final rawNutriScore2023 = nutriScoreDetails.rawNutriScore2023; + expect(rawNutriScore2023, isNotNull); + expect(rawNutriScore2023!.grade, "not-applicable"); + expect(rawNutriScore2023.score, isNull); + expect(rawNutriScore2023.data, isNotNull); + expect(rawNutriScore2023.notApplicableCategory, category); + expect(rawNutriScore2023.data!.isBeverage, isTrue); + expect(rawNutriScore2023.data!.isRedMeatProduct, isFalse); + expect(rawNutriScore2023.data!.isFatOilNutsSeeds, isFalse); + expect(rawNutriScore2023.data!.isWater, isFalse); + expect(rawNutriScore2023.data!.isCheese, isFalse); }); test('check nutriscore data for cookies', () async { @@ -284,113 +218,47 @@ void main() { expect(result.barcode, barcode); expect(result.product, isNotNull); + final nutriScoreDetails = result.product!.nutriScoreDetails; + expect(nutriScoreDetails, isNotNull); + // test Nutri-Score with domain model - final nutriScore = result.product!.nutriScoreDetails?.get2023(); + final nutriScore = nutriScoreDetails!.nutriScore2023; expect(nutriScore, isNotNull); expect(nutriScore!.isComputed, isTrue); - expect(nutriScore.grade, NutriScoreGrade.E); + expect(nutriScore.grade, NutriScoreGrade.e); expect(nutriScore.score, 25); expect(nutriScore.category, isNotNull); expect(nutriScore.category, NutriScoreCategory2023.general); - expect(nutriScore.missingData, isEmpty); + expect(nutriScore.hasMissingData, isFalse); expect(nutriScore.notApplicableCategory, isNull); // test Nutri-Score with raw data - expect(result.product!.nutriScoreDetails, isNotNull); - expect(result.product!.nutriScoreDetails!.nutriScore2021, isNotNull); - expect( - result.product!.nutriScoreDetails!.nutriScore2021!.grade, - "e", - ); - expect( - result.product!.nutriScoreDetails!.nutriScore2021!.categoryAvailable, - isTrue, - ); - expect( - result.product!.nutriScoreDetails!.nutriScore2021!.score, - 23, - ); - expect( - result.product!.nutriScoreDetails!.nutriScore2021!.nutrientsAvailable, - isTrue, - ); - expect( - result.product!.nutriScoreDetails!.nutriScore2021!.nutriScoreComputed, - isTrue, - ); - expect( - result.product!.nutriScoreDetails!.nutriScore2021!.nutriScoreApplicable, - isTrue, - ); - expect( - result.product!.nutriScoreDetails!.nutriScore2021!.data, - isNotNull, - ); - expect( - result.product!.nutriScoreDetails!.nutriScore2021!.data!.isBeverage, - isFalse, - ); - expect( - result.product!.nutriScoreDetails!.nutriScore2021!.data!.isWater, - isFalse, - ); - expect( - result.product!.nutriScoreDetails!.nutriScore2021!.data!.isCheese, - isFalse, - ); - expect( - result.product!.nutriScoreDetails!.nutriScore2021!.data!.isFat, - isFalse, - ); - expect( - result - .product!.nutriScoreDetails?.nutriScore2021?.notApplicableCategory, - isNull, - ); - - expect( - result.product!.nutriScoreDetails!.nutriScore2023, - isNotNull, - ); - expect( - result.product!.nutriScoreDetails!.nutriScore2023!.grade, - "e", - ); - expect( - result.product!.nutriScoreDetails!.nutriScore2023!.score, - 25, - ); - expect( - result.product!.nutriScoreDetails!.nutriScore2023!.data, - isNotNull, - ); - expect( - result - .product!.nutriScoreDetails!.nutriScore2023!.notApplicableCategory, - isNull, - ); - expect( - result.product!.nutriScoreDetails!.nutriScore2023!.data!.isBeverage, - isFalse, - ); - expect( - result - .product!.nutriScoreDetails!.nutriScore2023!.data!.isRedMeatProduct, - isFalse, - ); - expect( - result.product!.nutriScoreDetails!.nutriScore2023!.data! - .isFatOilNutsSeeds, - isFalse, - ); - expect( - result.product!.nutriScoreDetails!.nutriScore2023!.data!.isWater, - isFalse, - ); - expect( - result.product!.nutriScoreDetails!.nutriScore2023!.data!.isCheese, - isFalse, - ); + final rawNutriScore2021 = nutriScoreDetails.rawNutriScore2021; + expect(rawNutriScore2021, isNotNull); + expect(rawNutriScore2021!.grade, "e"); + expect(rawNutriScore2021.categoryAvailable, isTrue); + expect(rawNutriScore2021.score, 23); + expect(rawNutriScore2021.nutrientsAvailable, isTrue); + expect(rawNutriScore2021.nutriScoreComputed, isTrue); + expect(rawNutriScore2021.nutriScoreApplicable, isTrue); + expect(rawNutriScore2021.data, isNotNull); + expect(rawNutriScore2021.data!.isBeverage, isFalse); + expect(rawNutriScore2021.data!.isWater, isFalse); + expect(rawNutriScore2021.data!.isCheese, isFalse); + expect(rawNutriScore2021.data!.isFat, isFalse); + expect(rawNutriScore2021.notApplicableCategory, isNull); + + final rawNutriScore2023 = nutriScoreDetails.rawNutriScore2023; + expect(rawNutriScore2023, isNotNull); + expect(rawNutriScore2023!.grade, "e"); + expect(rawNutriScore2023.score, 25); + expect(rawNutriScore2023.data, isNotNull); + expect(rawNutriScore2023.notApplicableCategory, isNull); + expect(rawNutriScore2023.data!.isBeverage, isFalse); + expect(rawNutriScore2023.data!.isRedMeatProduct, isFalse); + expect(rawNutriScore2023.data!.isFatOilNutsSeeds, isFalse); + expect(rawNutriScore2023.data!.isWater, isFalse); + expect(rawNutriScore2023.data!.isCheese, isFalse); }); test('get product Danish Butter Cookies & Chocolate Chip Cookies', diff --git a/test/nutriscore_from_json_test.dart b/test/nutriscore_from_json_test.dart index c0fb7a9746..c201b0077b 100644 --- a/test/nutriscore_from_json_test.dart +++ b/test/nutriscore_from_json_test.dart @@ -6,8 +6,8 @@ void main() { test('returns no Nutri-Scores when input is empty', () { final result = NutriScoreDetails.fromJson({}); expect(result, isNotNull); - expect(result.nutriScore2021, isNull); - expect(result.nutriScore2023, isNull); + expect(result.rawNutriScore2021, isNull); + expect(result.rawNutriScore2023, isNull); }); test('ignores entries with unknown versions or null', () { @@ -16,8 +16,8 @@ void main() { '2021': null, }); expect(result, isNotNull); - expect(result.nutriScore2021, isNull); - expect(result.nutriScore2023, isNull); + expect(result.rawNutriScore2021, isNull); + expect(result.rawNutriScore2023, isNull); }); test('throws Error on non-map input', () { @@ -32,13 +32,13 @@ void main() { test('parses computed NutriScore for version 2023', () { final result = NutriScoreDetails.fromJson(computed); expect(result, isNotNull); - expect(result.nutriScore2021, isNull); - expect(result.nutriScore2023, isNotNull); + expect(result.rawNutriScore2021, isNull); + expect(result.rawNutriScore2023, isNotNull); - final nutriScore = result.get2023()!; + final NutriScore2023 nutriScore = result.nutriScore2023!; expect(nutriScore.isComputed, isTrue); - expect(nutriScore.missingData, isEmpty); - expect(nutriScore.grade, NutriScoreGrade.B); + expect(nutriScore.hasMissingData, isFalse); + expect(nutriScore.grade, NutriScoreGrade.b); expect(nutriScore.score, 2); expect(nutriScore.category, NutriScoreCategory2023.beverage); }); @@ -46,13 +46,12 @@ void main() { test('parses unknown NutriScore for version 2023', () { final result = NutriScoreDetails.fromJson(unknown); expect(result, isNotNull); - expect(result.nutriScore2021, isNull); - expect(result.nutriScore2023, isNotNull); + expect(result.rawNutriScore2021, isNull); + expect(result.rawNutriScore2023, isNotNull); - final nutriScore = result.get2023()!; - expect(nutriScore.isUnknown, isTrue); - expect(nutriScore.missingData.length, 1); - expect(nutriScore.missingData, contains(NutriScoreInput.nutrients)); + final NutriScore2023 nutriScore = result.nutriScore2023!; + expect(nutriScore.hasMissingData, isTrue); + expect(nutriScore.nutrientsAvailable, isFalse); expect(nutriScore.grade, isNull); expect(nutriScore.score, isNull); expect(nutriScore.category, NutriScoreCategory2023.general); @@ -61,43 +60,43 @@ void main() { test('parses not applicable NutriScore for version 2023', () { final result = NutriScoreDetails.fromJson(notApplicable); expect(result, isNotNull); - expect(result.nutriScore2021, isNull); - expect(result.nutriScore2023, isNotNull); + expect(result.rawNutriScore2021, isNull); + expect(result.rawNutriScore2023, isNotNull); - final nutriScore = result.get2023()!; + final NutriScore2023 nutriScore = result.nutriScore2023!; expect(nutriScore.isNotApplicable, isTrue); expect(nutriScore.grade, isNull); expect(nutriScore.score, isNull); expect(nutriScore.category, NutriScoreCategory2023.beverage); - expect(nutriScore.missingData, isEmpty); + expect(nutriScore.hasMissingData, isFalse); }); test('parses mixed data types in categories', () { final result = NutriScoreDetails.fromJson(mixedDataTypes); expect(result, isNotNull); - expect(result.nutriScore2021, isNotNull); - expect(result.nutriScore2023, isNull); + expect(result.rawNutriScore2021, isNotNull); + expect(result.rawNutriScore2023, isNull); - final nutriScore = result.get2021()!; + final NutriScore2021 nutriScore = result.nutriScore2021!; expect(nutriScore.isComputed, isTrue); - expect(nutriScore.grade, NutriScoreGrade.D); + expect(nutriScore.grade, NutriScoreGrade.d); expect(nutriScore.score, isNotNull); expect(nutriScore.category, NutriScoreCategory2021.cheese); - expect(nutriScore.missingData, isEmpty); + expect(nutriScore.hasMissingData, isFalse); }); test('parses inconsistent NutriScore data', () { final result = NutriScoreDetails.fromJson(inconsistent); expect(result, isNotNull); - expect(result.nutriScore2021, isNotNull); - expect(result.nutriScore2023, isNotNull); + expect(result.rawNutriScore2021, isNotNull); + expect(result.rawNutriScore2023, isNotNull); - final nutriScore = result.get2021()!; + final NutriScore2021 nutriScore = result.nutriScore2021!; expect(nutriScore.isNotApplicable, isTrue); expect(nutriScore.grade, isNull); expect(nutriScore.score, isNull); expect(nutriScore.category, NutriScoreCategory2021.water); - expect(nutriScore.missingData, isEmpty); + expect(nutriScore.hasMissingData, isFalse); }); }); @@ -109,19 +108,19 @@ void main() { final copy = NutriScoreDetails.fromJson(original.toJson()); expect(copy, isNotNull); - final orig2021 = original.get2021(); - final copy2021 = copy.get2021(); - final orig2023 = original.get2023(); - final copy2023 = copy.get2023(); + final orig2021 = original.nutriScore2021; + final copy2021 = copy.nutriScore2021; + final orig2023 = original.nutriScore2023; + final copy2023 = copy.nutriScore2023; - expect(copy2021?.category, equals(original.get2021()?.category)); - expect(copy2021?.grade, equals(original.get2021()?.grade)); + expect(copy2021?.category, equals(orig2021?.category)); + expect(copy2021?.grade, equals(orig2021?.grade)); expect(copy2021?.score, equals(orig2021?.score)); - expect(copy2021?.missingData, equals(orig2021?.missingData)); + expect(copy2021?.hasMissingData, equals(orig2021?.hasMissingData)); expect(copy2023?.category, equals(orig2023?.category)); expect(copy2023?.grade, equals(orig2023?.grade)); expect(copy2023?.score, equals(orig2023?.score)); - expect(copy2023?.missingData, equals(orig2023?.missingData)); + expect(copy2023?.hasMissingData, equals(orig2023?.hasMissingData)); expect(copy2023?.notApplicableCategory, equals(orig2023?.notApplicableCategory)); } From 1cf35980e1c9c8c39e5af6c723017da220952832 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olaf=20G=C3=B6rlitz?= Date: Sun, 25 May 2025 00:05:14 +0200 Subject: [PATCH 08/12] reintroduced state, updated tests --- lib/src/model/nutriscore/nutriscore.dart | 29 ++-- test/api_get_product_test.dart | 9 +- test/nutriscore_from_json_test.dart | 204 ++++++++++++----------- 3 files changed, 128 insertions(+), 114 deletions(-) diff --git a/lib/src/model/nutriscore/nutriscore.dart b/lib/src/model/nutriscore/nutriscore.dart index c2aa16909f..e96df5bb25 100644 --- a/lib/src/model/nutriscore/nutriscore.dart +++ b/lib/src/model/nutriscore/nutriscore.dart @@ -40,6 +40,9 @@ enum NutriScoreGrade implements OffTagged { String get offTag => name; } +/// Represents one of three mutually exclusive Nutri-Score states. +enum NutriScoreStatus { computed, notApplicable, unknown } + /// Abstract base class for Nutri-Score models (e.g. 2021, 2023). /// /// Provides common properties and logic for representing Nutri-Score data @@ -84,17 +87,21 @@ abstract class NutriScore { as NutriScoreGrade?, category = categoryAvailable ? category : null; - /// `true` if Nutri-Score has been computed. - bool get isComputed => grade != null; - - /// `true` if the Nutri-Score is not applicable to the product (see [notApplicableCategory]). - bool get isNotApplicable => notApplicableCategory?.isNotEmpty ?? false; - - /// `true` if the Nutri-Score is applicable to the product (but may not be computed due to missing data). - bool get isApplicable => categoryAvailable && notApplicableCategory == null; - - /// `true` if any data required to compute the Nutri-Score is missing. - bool get hasMissingData => !categoryAvailable || !nutrientsAvailable; + /// Returns the current Nutri-Score status, one of three mutually exclusive states. + /// + /// - `notApplicable`: Nutri-Score is not applicable to the product + /// (see [notApplicableCategory]). + /// - `computed`: Nutri-Score has been computed; [grade] and [score] are available. + /// - `unknown`: Nutri-Score cannot be computed due to missing data + /// (see [categoryAvailable] and [nutrientsAvailable]). + /// + /// Note: In the raw JSON, the `grade` field conflates status and actual grades, + /// using values `'unknown'`, `'not-applicable'`, and `'a'`–`'e'`. + NutriScoreStatus get status { + if (notApplicableCategory != null) return NutriScoreStatus.notApplicable; + if (grade != null) return NutriScoreStatus.computed; + return NutriScoreStatus.unknown; + } } /// Nutri-Score domain model for the 2021 specification. diff --git a/test/api_get_product_test.dart b/test/api_get_product_test.dart index 5ec667b5ac..057f5dadbc 100644 --- a/test/api_get_product_test.dart +++ b/test/api_get_product_test.dart @@ -164,12 +164,12 @@ void main() { // test Nutri-Score with domain model final nutriScore = nutriScoreDetails!.nutriScore2023; expect(nutriScore, isNotNull); - expect(nutriScore!.isNotApplicable, isTrue); + expect(nutriScore!.status, NutriScoreStatus.notApplicable); expect(nutriScore.grade, isNull); expect(nutriScore.score, isNull); expect(nutriScore.category, isNotNull); expect(nutriScore.category, NutriScoreCategory2023.beverage); - expect(nutriScore.hasMissingData, isFalse); + expect(nutriScore.categoryAvailable, isTrue); expect(nutriScore.notApplicableCategory, category); // test Nutri-Score with raw data @@ -224,12 +224,13 @@ void main() { // test Nutri-Score with domain model final nutriScore = nutriScoreDetails!.nutriScore2023; expect(nutriScore, isNotNull); - expect(nutriScore!.isComputed, isTrue); + expect(nutriScore!.status, NutriScoreStatus.computed); expect(nutriScore.grade, NutriScoreGrade.e); expect(nutriScore.score, 25); expect(nutriScore.category, isNotNull); expect(nutriScore.category, NutriScoreCategory2023.general); - expect(nutriScore.hasMissingData, isFalse); + expect(nutriScore.categoryAvailable, isTrue); + expect(nutriScore.nutrientsAvailable, isTrue); expect(nutriScore.notApplicableCategory, isNull); // test Nutri-Score with raw data diff --git a/test/nutriscore_from_json_test.dart b/test/nutriscore_from_json_test.dart index c201b0077b..bd654ce6ac 100644 --- a/test/nutriscore_from_json_test.dart +++ b/test/nutriscore_from_json_test.dart @@ -2,6 +2,94 @@ import 'package:openfoodfacts/openfoodfacts.dart'; import 'package:test/test.dart'; void main() { + final computed = { + '2023': { + 'grade': 'b', + 'score': 2, + 'category_available': 1, + 'nutrients_available': 1, + 'data': { + 'is_beverage': 1, + 'is_cheese': 0, + 'is_fat_oil_nuts_seeds': 0, + 'is_red_meat_product': 0, + 'is_water': 0, + } + } + }; + + final unknown = { + '2023': { + 'grade': 'unknown', + 'category_available': 1, + 'nutrients_available': 0, + 'data': { + 'is_beverage': 0, + 'is_cheese': 0, + 'is_fat_oil_nuts_seeds': 0, + 'is_red_meat_product': 0, + 'is_water': 0, + } + } + }; + + final notApplicable = { + '2023': { + 'grade': 'not-applicable', + 'category_available': 1, + 'nutrients_available': 1, + 'not_applicable_category': 'en:alcoholic-beverages', + 'data': { + 'is_beverage': 1, + 'is_cheese': 0, + 'is_fat_oil_nuts_seeds': 0, + 'is_red_meat_product': 0, + 'is_water': 0, + } + } + }; + + final mixedDataTypes = { + // e.g. 2336152903783 + '2021': { + 'grade': 'd', + 'score': 15, + 'category_available': 1, + 'nutrients_available': 1, + 'data': { + 'is_beverage': 0, + 'is_cheese': "1", + 'is_fat': 0, + 'is_water': 0, + } + }, + }; + + final inconsistent = { + '2021': { + 'grade': 'not-applicable', + 'category_available': 1, + 'nutrients_available': 1, + 'not_applicable_category': 'en:alcoholic-beverages', + 'data': { + 'is_beverage': 1, + 'is_cheese': 0, + 'is_fat': 0, + 'is_red_meat_product': 0, + 'is_water': "1", + } + }, + '2023': { + 'grade': 'unknown', + 'category_available': 1, + 'nutrients_available': 0, + 'not_applicable_category': 'en:alcoholic-beverages', + 'data': { + 'is_red_meat_product': 1, + } + } + }; + group('NutriScore.fromJson', () { test('returns no Nutri-Scores when input is empty', () { final result = NutriScoreDetails.fromJson({}); @@ -36,8 +124,9 @@ void main() { expect(result.rawNutriScore2023, isNotNull); final NutriScore2023 nutriScore = result.nutriScore2023!; - expect(nutriScore.isComputed, isTrue); - expect(nutriScore.hasMissingData, isFalse); + expect(nutriScore.status, NutriScoreStatus.computed); + expect(nutriScore.categoryAvailable, isTrue); + expect(nutriScore.nutrientsAvailable, isTrue); expect(nutriScore.grade, NutriScoreGrade.b); expect(nutriScore.score, 2); expect(nutriScore.category, NutriScoreCategory2023.beverage); @@ -50,7 +139,7 @@ void main() { expect(result.rawNutriScore2023, isNotNull); final NutriScore2023 nutriScore = result.nutriScore2023!; - expect(nutriScore.hasMissingData, isTrue); + expect(nutriScore.status, NutriScoreStatus.unknown); expect(nutriScore.nutrientsAvailable, isFalse); expect(nutriScore.grade, isNull); expect(nutriScore.score, isNull); @@ -64,11 +153,11 @@ void main() { expect(result.rawNutriScore2023, isNotNull); final NutriScore2023 nutriScore = result.nutriScore2023!; - expect(nutriScore.isNotApplicable, isTrue); + expect(nutriScore.status, NutriScoreStatus.notApplicable); expect(nutriScore.grade, isNull); expect(nutriScore.score, isNull); + expect(nutriScore.categoryAvailable, isTrue); expect(nutriScore.category, NutriScoreCategory2023.beverage); - expect(nutriScore.hasMissingData, isFalse); }); test('parses mixed data types in categories', () { @@ -78,11 +167,12 @@ void main() { expect(result.rawNutriScore2023, isNull); final NutriScore2021 nutriScore = result.nutriScore2021!; - expect(nutriScore.isComputed, isTrue); + expect(nutriScore.status, NutriScoreStatus.computed); expect(nutriScore.grade, NutriScoreGrade.d); expect(nutriScore.score, isNotNull); expect(nutriScore.category, NutriScoreCategory2021.cheese); - expect(nutriScore.hasMissingData, isFalse); + expect(nutriScore.categoryAvailable, isTrue); + expect(nutriScore.nutrientsAvailable, isTrue); }); test('parses inconsistent NutriScore data', () { @@ -92,11 +182,11 @@ void main() { expect(result.rawNutriScore2023, isNotNull); final NutriScore2021 nutriScore = result.nutriScore2021!; - expect(nutriScore.isNotApplicable, isTrue); + expect(nutriScore.status, NutriScoreStatus.notApplicable); expect(nutriScore.grade, isNull); expect(nutriScore.score, isNull); expect(nutriScore.category, NutriScoreCategory2021.water); - expect(nutriScore.hasMissingData, isFalse); + expect(nutriScore.categoryAvailable, isTrue); }); }); @@ -116,101 +206,17 @@ void main() { expect(copy2021?.category, equals(orig2021?.category)); expect(copy2021?.grade, equals(orig2021?.grade)); expect(copy2021?.score, equals(orig2021?.score)); - expect(copy2021?.hasMissingData, equals(orig2021?.hasMissingData)); + expect(copy2021?.status, equals(orig2021?.status)); + expect(copy2021?.categoryAvailable, equals(orig2021?.categoryAvailable)); + expect(copy2021?.nutrientsAvailable, equals(orig2021?.nutrientsAvailable)); expect(copy2023?.category, equals(orig2023?.category)); expect(copy2023?.grade, equals(orig2023?.grade)); expect(copy2023?.score, equals(orig2023?.score)); - expect(copy2023?.hasMissingData, equals(orig2023?.hasMissingData)); + expect(copy2023?.status, equals(orig2023?.status)); + expect(copy2023?.categoryAvailable, equals(orig2023?.categoryAvailable)); + expect(copy2023?.nutrientsAvailable, equals(orig2023?.nutrientsAvailable)); expect(copy2023?.notApplicableCategory, equals(orig2023?.notApplicableCategory)); } }); } - -final computed = { - '2023': { - 'grade': 'b', - 'score': 2, - 'category_available': 1, - 'nutrients_available': 1, - 'data': { - 'is_beverage': 1, - 'is_cheese': 0, - 'is_fat_oil_nuts_seeds': 0, - 'is_red_meat_product': 0, - 'is_water': 0, - } - } -}; - -final unknown = { - '2023': { - 'grade': 'unknown', - 'category_available': 1, - 'nutrients_available': 0, - 'data': { - 'is_beverage': 0, - 'is_cheese': 0, - 'is_fat_oil_nuts_seeds': 0, - 'is_red_meat_product': 0, - 'is_water': 0, - } - } -}; - -final notApplicable = { - '2023': { - 'grade': 'not-applicable', - 'category_available': 1, - 'nutrients_available': 1, - 'not_applicable_category': 'en:alcoholic-beverages', - 'data': { - 'is_beverage': 1, - 'is_cheese': 0, - 'is_fat_oil_nuts_seeds': 0, - 'is_red_meat_product': 0, - 'is_water': 0, - } - } -}; - -final mixedDataTypes = { - // e.g. 2336152903783 - '2021': { - 'grade': 'd', - 'score': 15, - 'category_available': 1, - 'nutrients_available': 1, - 'data': { - 'is_beverage': 0, - 'is_cheese': "1", - 'is_fat': 0, - 'is_water': 0, - } - }, -}; - -final inconsistent = { - '2021': { - 'grade': 'not-applicable', - 'category_available': 1, - 'nutrients_available': 1, - 'not_applicable_category': 'en:alcoholic-beverages', - 'data': { - 'is_beverage': 1, - 'is_cheese': 0, - 'is_fat': 0, - 'is_red_meat_product': 0, - 'is_water': "1", - } - }, - '2023': { - 'grade': 'unknown', - 'category_available': 1, - 'nutrients_available': 0, - 'not_applicable_category': 'en:alcoholic-beverages', - 'data': { - 'is_red_meat_product': 1, - } - } -}; From 6a8efad465e42626952855cf9f58737902169a90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olaf=20G=C3=B6rlitz?= Date: Sun, 25 May 2025 00:51:42 +0200 Subject: [PATCH 09/12] formatting issue after renaming --- test/nutriscore_from_json_test.dart | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/test/nutriscore_from_json_test.dart b/test/nutriscore_from_json_test.dart index bd654ce6ac..0794d65c6f 100644 --- a/test/nutriscore_from_json_test.dart +++ b/test/nutriscore_from_json_test.dart @@ -208,15 +208,23 @@ void main() { expect(copy2021?.score, equals(orig2021?.score)); expect(copy2021?.status, equals(orig2021?.status)); expect(copy2021?.categoryAvailable, equals(orig2021?.categoryAvailable)); - expect(copy2021?.nutrientsAvailable, equals(orig2021?.nutrientsAvailable)); + expect( + copy2021?.nutrientsAvailable, + equals(orig2021?.nutrientsAvailable), + ); expect(copy2023?.category, equals(orig2023?.category)); expect(copy2023?.grade, equals(orig2023?.grade)); expect(copy2023?.score, equals(orig2023?.score)); expect(copy2023?.status, equals(orig2023?.status)); expect(copy2023?.categoryAvailable, equals(orig2023?.categoryAvailable)); - expect(copy2023?.nutrientsAvailable, equals(orig2023?.nutrientsAvailable)); - expect(copy2023?.notApplicableCategory, - equals(orig2023?.notApplicableCategory)); + expect( + copy2023?.nutrientsAvailable, + equals(orig2023?.nutrientsAvailable), + ); + expect( + copy2023?.notApplicableCategory, + equals(orig2023?.notApplicableCategory), + ); } }); } From 073b70ad1b88414bd0077aaae4d9d37bdeb38d15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olaf=20G=C3=B6rlitz?= Date: Wed, 2 Jul 2025 23:39:57 +0200 Subject: [PATCH 10/12] removed domain model for Nutri-Score --- lib/openfoodfacts.dart | 1 - lib/src/model/nutriscore/nutriscore.dart | 141 ------------------ .../nutriscore/nutriscore_data_2021.dart | 13 +- .../nutriscore/nutriscore_data_2023.dart | 15 +- .../nutriscore/nutriscore_detail_2021.dart | 2 +- .../nutriscore/nutriscore_detail_2023.dart | 2 +- .../model/nutriscore/nutriscore_details.dart | 47 +----- .../nutriscore/nutriscore_details.g.dart | 8 +- test/api_get_product_test.dart | 127 +++++++--------- test/nutriscore_from_json_test.dart | 135 +++++++++-------- 10 files changed, 138 insertions(+), 353 deletions(-) delete mode 100644 lib/src/model/nutriscore/nutriscore.dart diff --git a/lib/openfoodfacts.dart b/lib/openfoodfacts.dart index 05563eedf4..35a94bc95b 100644 --- a/lib/openfoodfacts.dart +++ b/lib/openfoodfacts.dart @@ -26,7 +26,6 @@ export 'src/model/nutrient_modifier.dart'; export 'src/model/nutrient.dart'; export 'src/model/nutrient_levels.dart'; export 'src/model/nutriments.dart'; -export 'src/model/nutriscore/nutriscore.dart'; export 'src/model/nutriscore/nutriscore_data_2021.dart'; export 'src/model/nutriscore/nutriscore_data_2023.dart'; export 'src/model/nutriscore/nutriscore_detail_2021.dart'; diff --git a/lib/src/model/nutriscore/nutriscore.dart b/lib/src/model/nutriscore/nutriscore.dart deleted file mode 100644 index e96df5bb25..0000000000 --- a/lib/src/model/nutriscore/nutriscore.dart +++ /dev/null @@ -1,141 +0,0 @@ -import '../off_tagged.dart'; - -/// Internal marker interface for version-specific Nutri-Score categories. -/// -/// This is used internally by the abstract [NutriScore] base class to enable -/// shared handling of 2021 and 2023 category types. It is intentionally private -/// to prevent accidental external usage or misimplementation. -abstract class _NutriScoreCategory {} - -/// Nutri-Score categories defined for the 2021 specification. -enum NutriScoreCategory2021 implements _NutriScoreCategory { - general, - cheese, - fat, - beverage, - water, -} - -/// Nutri-Score categories defined for the 2023 specification. -enum NutriScoreCategory2023 implements _NutriScoreCategory { - general, - cheese, - redMeatProduct, - fatOilNutsSeeds, - beverage, - water, -} - -/// Nutri-Score letter grades: `A` (best) to `E` (worst). -/// -/// Implements [OffTagged] to support conversion from tags. -enum NutriScoreGrade implements OffTagged { - a, - b, - c, - d, - e; - - @override - String get offTag => name; -} - -/// Represents one of three mutually exclusive Nutri-Score states. -enum NutriScoreStatus { computed, notApplicable, unknown } - -/// Abstract base class for Nutri-Score models (e.g. 2021, 2023). -/// -/// Provides common properties and logic for representing Nutri-Score data -/// in a version-agnostic, structured way. Subclasses enforce version-specific -/// category types. -/// -/// This model ensures: -/// - Grades are normalized from tags (`a`–`e`) -/// - Categories are only available when `categoryAvailable` is `true` -/// - Semantic helpers like `isComputed` and `isApplicable` are provided -abstract class NutriScore { - /// The Nutri-Score grade (`A`–`E`), or `null` if unavailable. - final NutriScoreGrade? grade; - - /// The raw numeric score used to derive the grade. - final int? score; - - /// The version-specific food category used to evaluate the Nutri-Score. - final _NutriScoreCategory? category; - - /// Specifies the product's category for which the Nutri-Score is not applicable. - final String? notApplicableCategory; - - /// Indicates whether the category required to compute the Nutri-Score is available. - final bool categoryAvailable; - - /// Indicates whether the nutrients required to compute the Nutri-Score are available. - final bool nutrientsAvailable; - - /// Constructs a [NutriScore] model from raw data and enforces valid semantics. - /// - /// - If `categoryAvailable` is false, `category` will be set to `null`. - /// - Grade tags are parsed to `NutriScoreGrade` (`A`-`E`) via [OffTagged]. - NutriScore({ - String? grade, - _NutriScoreCategory? category, - this.score, - this.notApplicableCategory, - this.categoryAvailable = false, - this.nutrientsAvailable = false, - }) : grade = OffTagged.fromOffTag(grade, NutriScoreGrade.values) - as NutriScoreGrade?, - category = categoryAvailable ? category : null; - - /// Returns the current Nutri-Score status, one of three mutually exclusive states. - /// - /// - `notApplicable`: Nutri-Score is not applicable to the product - /// (see [notApplicableCategory]). - /// - `computed`: Nutri-Score has been computed; [grade] and [score] are available. - /// - `unknown`: Nutri-Score cannot be computed due to missing data - /// (see [categoryAvailable] and [nutrientsAvailable]). - /// - /// Note: In the raw JSON, the `grade` field conflates status and actual grades, - /// using values `'unknown'`, `'not-applicable'`, and `'a'`–`'e'`. - NutriScoreStatus get status { - if (notApplicableCategory != null) return NutriScoreStatus.notApplicable; - if (grade != null) return NutriScoreStatus.computed; - return NutriScoreStatus.unknown; - } -} - -/// Nutri-Score domain model for the 2021 specification. -/// -/// Accepts only [NutriScoreCategory2021] categories. -class NutriScore2021 extends NutriScore { - NutriScore2021({ - NutriScoreCategory2021? category, - super.grade, - super.score, - super.notApplicableCategory, - super.categoryAvailable, - super.nutrientsAvailable, - }) : super(category: category); - - @override - NutriScoreCategory2021? get category => - super.category as NutriScoreCategory2021?; -} - -/// Nutri-Score domain model for the 2023 specification. -/// -/// Accepts only [NutriScoreCategory2023] categories. -class NutriScore2023 extends NutriScore { - NutriScore2023({ - NutriScoreCategory2023? category, - super.grade, - super.score, - super.notApplicableCategory, - super.categoryAvailable, - super.nutrientsAvailable, - }) : super(category: category); - - @override - NutriScoreCategory2023? get category => - super.category as NutriScoreCategory2023?; -} diff --git a/lib/src/model/nutriscore/nutriscore_data_2021.dart b/lib/src/model/nutriscore/nutriscore_data_2021.dart index 4b01b20c55..4095ca06f9 100644 --- a/lib/src/model/nutriscore/nutriscore_data_2021.dart +++ b/lib/src/model/nutriscore/nutriscore_data_2021.dart @@ -1,11 +1,10 @@ import 'package:json_annotation/json_annotation.dart'; import '../../interface/json_object.dart'; import '../../utils/json_helper.dart'; -import 'nutriscore.dart'; part 'nutriscore_data_2021.g.dart'; -/// Detailed data of NutriScore version 2021. +/// Detailed data of Nutri-Score version 2021. @JsonSerializable() class NutriScoreData2021 extends JsonObject { @JsonKey( @@ -44,16 +43,6 @@ class NutriScoreData2021 extends JsonObject { NutriScoreData2021(); - /// Infers the [NutriScoreCategory2021] based on boolean flags. - NutriScoreCategory2021 get category { - // water must be checked first to avoid beverage+water conflict - if (isWater == true) return NutriScoreCategory2021.water; - if (isBeverage == true) return NutriScoreCategory2021.beverage; - if (isFat == true) return NutriScoreCategory2021.fat; - if (isCheese == true) return NutriScoreCategory2021.cheese; - return NutriScoreCategory2021.general; - } - factory NutriScoreData2021.fromJson(Map json) => _$NutriScoreData2021FromJson(json); diff --git a/lib/src/model/nutriscore/nutriscore_data_2023.dart b/lib/src/model/nutriscore/nutriscore_data_2023.dart index bc50d17923..3f1f9e0fa8 100644 --- a/lib/src/model/nutriscore/nutriscore_data_2023.dart +++ b/lib/src/model/nutriscore/nutriscore_data_2023.dart @@ -1,11 +1,10 @@ import 'package:json_annotation/json_annotation.dart'; import '../../interface/json_object.dart'; import '../../utils/json_helper.dart'; -import 'nutriscore.dart'; part 'nutriscore_data_2023.g.dart'; -/// Detailed data of NutriScore version 2023. +/// Detailed data of Nutri-Score version 2023. @JsonSerializable() class NutriScoreData2023 extends JsonObject { @JsonKey( @@ -67,18 +66,6 @@ class NutriScoreData2023 extends JsonObject { NutriScoreData2023(); - /// Infers the [NutriScoreCategory2023] based on boolean flags. - NutriScoreCategory2023 get category { - // water must be checked first to avoid beverage+water conflict - if (isWater == true) return NutriScoreCategory2023.water; - if (isBeverage == true) return NutriScoreCategory2023.beverage; - if (isFatOilNutsSeeds == true) - return NutriScoreCategory2023.fatOilNutsSeeds; - if (isCheese == true) return NutriScoreCategory2023.cheese; - if (isRedMeatProduct == true) return NutriScoreCategory2023.redMeatProduct; - return NutriScoreCategory2023.general; - } - factory NutriScoreData2023.fromJson(Map json) => _$NutriScoreData2023FromJson(json); diff --git a/lib/src/model/nutriscore/nutriscore_detail_2021.dart b/lib/src/model/nutriscore/nutriscore_detail_2021.dart index 2e5c43da52..b5f140af60 100644 --- a/lib/src/model/nutriscore/nutriscore_detail_2021.dart +++ b/lib/src/model/nutriscore/nutriscore_detail_2021.dart @@ -5,7 +5,7 @@ import 'nutriscore_data_2021.dart'; part 'nutriscore_detail_2021.g.dart'; -/// Data of NutriScore version 2021. +/// Data of Nutri-Score version 2021. @JsonSerializable(explicitToJson: true) class NutriScoreDetail2021 extends JsonObject { @JsonKey() diff --git a/lib/src/model/nutriscore/nutriscore_detail_2023.dart b/lib/src/model/nutriscore/nutriscore_detail_2023.dart index ab3eb27e7f..9539ced608 100644 --- a/lib/src/model/nutriscore/nutriscore_detail_2023.dart +++ b/lib/src/model/nutriscore/nutriscore_detail_2023.dart @@ -5,7 +5,7 @@ import 'nutriscore_data_2023.dart'; part 'nutriscore_detail_2023.g.dart'; -/// Data of NutriScore version 2023. +/// Data of Nutri-Score version 2023. @JsonSerializable(explicitToJson: true) class NutriScoreDetail2023 extends JsonObject { @JsonKey() diff --git a/lib/src/model/nutriscore/nutriscore_details.dart b/lib/src/model/nutriscore/nutriscore_details.dart index ceb14641b1..84b4dd4e72 100644 --- a/lib/src/model/nutriscore/nutriscore_details.dart +++ b/lib/src/model/nutriscore/nutriscore_details.dart @@ -2,63 +2,20 @@ import 'package:json_annotation/json_annotation.dart'; import '../../interface/json_object.dart'; import 'nutriscore_detail_2021.dart'; import 'nutriscore_detail_2023.dart'; -import 'nutriscore.dart'; part 'nutriscore_details.g.dart'; /// Container for Nutri-Score data from both 2021 and 2023 versions, as received from the API. -/// -/// This class includes: -/// - The raw deserialized data for each version (`rawNutriScore2021`, `rawNutriScore2023`) -/// - Strongly typed domain models (`nutriScore2021`, `nutriScore2023`) derived from the raw data -/// -/// While the raw data reflects the exact API structure, it is recommended to use -/// the domain models in application logic, as they provide a cleaner, validated, -/// and version-specific representation of Nutri-Score values. @JsonSerializable(explicitToJson: true) class NutriScoreDetails extends JsonObject { @JsonKey(name: '2021') - NutriScoreDetail2021? rawNutriScore2021; + NutriScoreDetail2021? v2021; @JsonKey(name: '2023') - NutriScoreDetail2023? rawNutriScore2023; + NutriScoreDetail2023? v2023; NutriScoreDetails(); - /// Returns a strongly typed domain model specific to Nutri-Score 2021. - /// - /// Ensures that only valid 2021-specific categories and values are used. - /// Returns `null` if no raw data is available. - NutriScore2021? get nutriScore2021 { - if (rawNutriScore2021 == null) return null; - - return NutriScore2021( - category: rawNutriScore2021?.data?.category, - grade: rawNutriScore2021?.grade, - score: rawNutriScore2021?.score, - notApplicableCategory: rawNutriScore2021?.notApplicableCategory, - categoryAvailable: rawNutriScore2021?.categoryAvailable ?? false, - nutrientsAvailable: rawNutriScore2021?.nutrientsAvailable ?? false, - ); - } - - /// Returns a strongly typed domain model specific to Nutri-Score 2023. - /// - /// Ensures that only valid 2023-specific categories and values are used. - /// Returns `null` if no raw data is available. - NutriScore2023? get nutriScore2023 { - if (rawNutriScore2023 == null) return null; - - return NutriScore2023( - category: rawNutriScore2023?.data?.category, - grade: rawNutriScore2023?.grade, - score: rawNutriScore2023?.score, - notApplicableCategory: rawNutriScore2023?.notApplicableCategory, - categoryAvailable: rawNutriScore2023?.categoryAvailable ?? false, - nutrientsAvailable: rawNutriScore2023?.nutrientsAvailable ?? false, - ); - } - factory NutriScoreDetails.fromJson(Map json) => _$NutriScoreDetailsFromJson(json); diff --git a/lib/src/model/nutriscore/nutriscore_details.g.dart b/lib/src/model/nutriscore/nutriscore_details.g.dart index 10efcc01c4..d21e431b65 100644 --- a/lib/src/model/nutriscore/nutriscore_details.g.dart +++ b/lib/src/model/nutriscore/nutriscore_details.g.dart @@ -8,15 +8,15 @@ part of 'nutriscore_details.dart'; NutriScoreDetails _$NutriScoreDetailsFromJson(Map json) => NutriScoreDetails() - ..rawNutriScore2021 = json['2021'] == null + ..v2021 = json['2021'] == null ? null : NutriScoreDetail2021.fromJson(json['2021'] as Map) - ..rawNutriScore2023 = json['2023'] == null + ..v2023 = json['2023'] == null ? null : NutriScoreDetail2023.fromJson(json['2023'] as Map); Map _$NutriScoreDetailsToJson(NutriScoreDetails instance) => { - '2021': instance.rawNutriScore2021?.toJson(), - '2023': instance.rawNutriScore2023?.toJson(), + '2021': instance.v2021?.toJson(), + '2023': instance.v2023?.toJson(), }; diff --git a/test/api_get_product_test.dart b/test/api_get_product_test.dart index 057f5dadbc..6a3c5cc884 100644 --- a/test/api_get_product_test.dart +++ b/test/api_get_product_test.dart @@ -161,44 +161,33 @@ void main() { final nutriScoreDetails = result.product!.nutriScoreDetails; expect(nutriScoreDetails, isNotNull); - // test Nutri-Score with domain model - final nutriScore = nutriScoreDetails!.nutriScore2023; - expect(nutriScore, isNotNull); - expect(nutriScore!.status, NutriScoreStatus.notApplicable); - expect(nutriScore.grade, isNull); - expect(nutriScore.score, isNull); - expect(nutriScore.category, isNotNull); - expect(nutriScore.category, NutriScoreCategory2023.beverage); - expect(nutriScore.categoryAvailable, isTrue); - expect(nutriScore.notApplicableCategory, category); - // test Nutri-Score with raw data - final rawNutriScore2021 = nutriScoreDetails.rawNutriScore2021; - expect(rawNutriScore2021, isNotNull); - expect(rawNutriScore2021!.grade, "not-applicable"); - expect(rawNutriScore2021.categoryAvailable, isTrue); - expect(rawNutriScore2021.score, isNull); - expect(rawNutriScore2021.nutrientsAvailable, isTrue); - expect(rawNutriScore2021.nutriScoreComputed, isFalse); - expect(rawNutriScore2021.nutriScoreApplicable, isFalse); - expect(rawNutriScore2021.data, isNotNull); - expect(rawNutriScore2021.data!.isBeverage, isTrue); - expect(rawNutriScore2021.data!.isWater, isFalse); - expect(rawNutriScore2021.data!.isCheese, isFalse); - expect(rawNutriScore2021.data!.isFat, isFalse); - expect(rawNutriScore2021.notApplicableCategory, category); - - final rawNutriScore2023 = nutriScoreDetails.rawNutriScore2023; - expect(rawNutriScore2023, isNotNull); - expect(rawNutriScore2023!.grade, "not-applicable"); - expect(rawNutriScore2023.score, isNull); - expect(rawNutriScore2023.data, isNotNull); - expect(rawNutriScore2023.notApplicableCategory, category); - expect(rawNutriScore2023.data!.isBeverage, isTrue); - expect(rawNutriScore2023.data!.isRedMeatProduct, isFalse); - expect(rawNutriScore2023.data!.isFatOilNutsSeeds, isFalse); - expect(rawNutriScore2023.data!.isWater, isFalse); - expect(rawNutriScore2023.data!.isCheese, isFalse); + final nutriScore2021 = nutriScoreDetails?.v2021; + expect(nutriScore2021, isNotNull); + expect(nutriScore2021!.grade, "not-applicable"); + expect(nutriScore2021.categoryAvailable, isTrue); + expect(nutriScore2021.score, isNull); + expect(nutriScore2021.nutrientsAvailable, isTrue); + expect(nutriScore2021.nutriScoreComputed, isFalse); + expect(nutriScore2021.nutriScoreApplicable, isFalse); + expect(nutriScore2021.data, isNotNull); + expect(nutriScore2021.data!.isBeverage, isTrue); + expect(nutriScore2021.data!.isWater, isFalse); + expect(nutriScore2021.data!.isCheese, isFalse); + expect(nutriScore2021.data!.isFat, isFalse); + expect(nutriScore2021.notApplicableCategory, category); + + final nutriScore2023 = nutriScoreDetails?.v2023; + expect(nutriScore2023, isNotNull); + expect(nutriScore2023!.grade, "not-applicable"); + expect(nutriScore2023.score, isNull); + expect(nutriScore2023.data, isNotNull); + expect(nutriScore2023.notApplicableCategory, category); + expect(nutriScore2023.data!.isBeverage, isTrue); + expect(nutriScore2023.data!.isRedMeatProduct, isFalse); + expect(nutriScore2023.data!.isFatOilNutsSeeds, isFalse); + expect(nutriScore2023.data!.isWater, isFalse); + expect(nutriScore2023.data!.isCheese, isFalse); }); test('check nutriscore data for cookies', () async { @@ -221,45 +210,33 @@ void main() { final nutriScoreDetails = result.product!.nutriScoreDetails; expect(nutriScoreDetails, isNotNull); - // test Nutri-Score with domain model - final nutriScore = nutriScoreDetails!.nutriScore2023; - expect(nutriScore, isNotNull); - expect(nutriScore!.status, NutriScoreStatus.computed); - expect(nutriScore.grade, NutriScoreGrade.e); - expect(nutriScore.score, 25); - expect(nutriScore.category, isNotNull); - expect(nutriScore.category, NutriScoreCategory2023.general); - expect(nutriScore.categoryAvailable, isTrue); - expect(nutriScore.nutrientsAvailable, isTrue); - expect(nutriScore.notApplicableCategory, isNull); - // test Nutri-Score with raw data - final rawNutriScore2021 = nutriScoreDetails.rawNutriScore2021; - expect(rawNutriScore2021, isNotNull); - expect(rawNutriScore2021!.grade, "e"); - expect(rawNutriScore2021.categoryAvailable, isTrue); - expect(rawNutriScore2021.score, 23); - expect(rawNutriScore2021.nutrientsAvailable, isTrue); - expect(rawNutriScore2021.nutriScoreComputed, isTrue); - expect(rawNutriScore2021.nutriScoreApplicable, isTrue); - expect(rawNutriScore2021.data, isNotNull); - expect(rawNutriScore2021.data!.isBeverage, isFalse); - expect(rawNutriScore2021.data!.isWater, isFalse); - expect(rawNutriScore2021.data!.isCheese, isFalse); - expect(rawNutriScore2021.data!.isFat, isFalse); - expect(rawNutriScore2021.notApplicableCategory, isNull); - - final rawNutriScore2023 = nutriScoreDetails.rawNutriScore2023; - expect(rawNutriScore2023, isNotNull); - expect(rawNutriScore2023!.grade, "e"); - expect(rawNutriScore2023.score, 25); - expect(rawNutriScore2023.data, isNotNull); - expect(rawNutriScore2023.notApplicableCategory, isNull); - expect(rawNutriScore2023.data!.isBeverage, isFalse); - expect(rawNutriScore2023.data!.isRedMeatProduct, isFalse); - expect(rawNutriScore2023.data!.isFatOilNutsSeeds, isFalse); - expect(rawNutriScore2023.data!.isWater, isFalse); - expect(rawNutriScore2023.data!.isCheese, isFalse); + final nutriScore2021 = nutriScoreDetails?.v2021; + expect(nutriScore2021, isNotNull); + expect(nutriScore2021!.grade, "e"); + expect(nutriScore2021.categoryAvailable, isTrue); + expect(nutriScore2021.score, 23); + expect(nutriScore2021.nutrientsAvailable, isTrue); + expect(nutriScore2021.nutriScoreComputed, isTrue); + expect(nutriScore2021.nutriScoreApplicable, isTrue); + expect(nutriScore2021.data, isNotNull); + expect(nutriScore2021.data!.isBeverage, isFalse); + expect(nutriScore2021.data!.isWater, isFalse); + expect(nutriScore2021.data!.isCheese, isFalse); + expect(nutriScore2021.data!.isFat, isFalse); + expect(nutriScore2021.notApplicableCategory, isNull); + + final nutriScore2023 = nutriScoreDetails?.v2023; + expect(nutriScore2023, isNotNull); + expect(nutriScore2023!.grade, "e"); + expect(nutriScore2023.score, 25); + expect(nutriScore2023.data, isNotNull); + expect(nutriScore2023.notApplicableCategory, isNull); + expect(nutriScore2023.data!.isBeverage, isFalse); + expect(nutriScore2023.data!.isRedMeatProduct, isFalse); + expect(nutriScore2023.data!.isFatOilNutsSeeds, isFalse); + expect(nutriScore2023.data!.isWater, isFalse); + expect(nutriScore2023.data!.isCheese, isFalse); }); test('get product Danish Butter Cookies & Chocolate Chip Cookies', diff --git a/test/nutriscore_from_json_test.dart b/test/nutriscore_from_json_test.dart index 0794d65c6f..99ca4ccf2a 100644 --- a/test/nutriscore_from_json_test.dart +++ b/test/nutriscore_from_json_test.dart @@ -94,8 +94,8 @@ void main() { test('returns no Nutri-Scores when input is empty', () { final result = NutriScoreDetails.fromJson({}); expect(result, isNotNull); - expect(result.rawNutriScore2021, isNull); - expect(result.rawNutriScore2023, isNull); + expect(result.v2021, isNull); + expect(result.v2023, isNull); }); test('ignores entries with unknown versions or null', () { @@ -104,8 +104,8 @@ void main() { '2021': null, }); expect(result, isNotNull); - expect(result.rawNutriScore2021, isNull); - expect(result.rawNutriScore2023, isNull); + expect(result.v2021, isNull); + expect(result.v2023, isNull); }); test('throws Error on non-map input', () { @@ -120,73 +120,94 @@ void main() { test('parses computed NutriScore for version 2023', () { final result = NutriScoreDetails.fromJson(computed); expect(result, isNotNull); - expect(result.rawNutriScore2021, isNull); - expect(result.rawNutriScore2023, isNotNull); - - final NutriScore2023 nutriScore = result.nutriScore2023!; - expect(nutriScore.status, NutriScoreStatus.computed); - expect(nutriScore.categoryAvailable, isTrue); - expect(nutriScore.nutrientsAvailable, isTrue); - expect(nutriScore.grade, NutriScoreGrade.b); - expect(nutriScore.score, 2); - expect(nutriScore.category, NutriScoreCategory2023.beverage); + expect(result.v2021, isNull); + expect(result.v2023, isNotNull); + + final nutriScore2023 = result.v2023; + expect(nutriScore2023, isNotNull); + expect(nutriScore2023!.grade, 'b'); + expect(nutriScore2023.score, 2); + expect(nutriScore2023.categoryAvailable, isTrue); + expect(nutriScore2023.nutrientsAvailable, isTrue); + expect(nutriScore2023.data?.isBeverage, isTrue); + expect(nutriScore2023.data?.isWater, isFalse); + expect(nutriScore2023.data?.isCheese, isFalse); + expect(nutriScore2023.data?.isFatOilNutsSeeds, isFalse); + expect(nutriScore2023.data?.isRedMeatProduct, isFalse); }); test('parses unknown NutriScore for version 2023', () { final result = NutriScoreDetails.fromJson(unknown); expect(result, isNotNull); - expect(result.rawNutriScore2021, isNull); - expect(result.rawNutriScore2023, isNotNull); - - final NutriScore2023 nutriScore = result.nutriScore2023!; - expect(nutriScore.status, NutriScoreStatus.unknown); - expect(nutriScore.nutrientsAvailable, isFalse); - expect(nutriScore.grade, isNull); - expect(nutriScore.score, isNull); - expect(nutriScore.category, NutriScoreCategory2023.general); + expect(result.v2021, isNull); + expect(result.v2023, isNotNull); + + final nutriScore2023 = result.v2023; + expect(nutriScore2023, isNotNull); + expect(nutriScore2023!.grade, 'unknown'); + expect(nutriScore2023.score, isNull); + expect(nutriScore2023.categoryAvailable, isTrue); + expect(nutriScore2023.nutrientsAvailable, isFalse); + expect(nutriScore2023.data?.isBeverage, isFalse); + expect(nutriScore2023.data?.isWater, isFalse); + expect(nutriScore2023.data?.isCheese, isFalse); + expect(nutriScore2023.data?.isFatOilNutsSeeds, isFalse); + expect(nutriScore2023.data?.isRedMeatProduct, isFalse); }); test('parses not applicable NutriScore for version 2023', () { final result = NutriScoreDetails.fromJson(notApplicable); expect(result, isNotNull); - expect(result.rawNutriScore2021, isNull); - expect(result.rawNutriScore2023, isNotNull); - - final NutriScore2023 nutriScore = result.nutriScore2023!; - expect(nutriScore.status, NutriScoreStatus.notApplicable); - expect(nutriScore.grade, isNull); - expect(nutriScore.score, isNull); - expect(nutriScore.categoryAvailable, isTrue); - expect(nutriScore.category, NutriScoreCategory2023.beverage); + expect(result.v2021, isNull); + expect(result.v2023, isNotNull); + + final nutriScore2023 = result.v2023; + expect(nutriScore2023, isNotNull); + expect(nutriScore2023!.grade, 'not-applicable'); + expect(nutriScore2023.score, isNull); + expect(nutriScore2023.categoryAvailable, isTrue); + expect(nutriScore2023.nutrientsAvailable, isTrue); + expect(nutriScore2023.data?.isBeverage, isTrue); + expect(nutriScore2023.data?.isWater, isFalse); + expect(nutriScore2023.data?.isCheese, isFalse); + expect(nutriScore2023.data?.isFatOilNutsSeeds, isFalse); + expect(nutriScore2023.data?.isRedMeatProduct, isFalse); }); test('parses mixed data types in categories', () { final result = NutriScoreDetails.fromJson(mixedDataTypes); expect(result, isNotNull); - expect(result.rawNutriScore2021, isNotNull); - expect(result.rawNutriScore2023, isNull); - - final NutriScore2021 nutriScore = result.nutriScore2021!; - expect(nutriScore.status, NutriScoreStatus.computed); - expect(nutriScore.grade, NutriScoreGrade.d); - expect(nutriScore.score, isNotNull); - expect(nutriScore.category, NutriScoreCategory2021.cheese); - expect(nutriScore.categoryAvailable, isTrue); - expect(nutriScore.nutrientsAvailable, isTrue); + expect(result.v2021, isNotNull); + expect(result.v2023, isNull); + + final nutriScore2021 = result.v2021; + expect(nutriScore2021, isNotNull); + expect(nutriScore2021!.grade, 'd'); + expect(nutriScore2021.score, isNotNull); + expect(nutriScore2021.categoryAvailable, isTrue); + expect(nutriScore2021.nutrientsAvailable, isTrue); + expect(nutriScore2021.data?.isBeverage, isFalse); + expect(nutriScore2021.data?.isWater, isFalse); + expect(nutriScore2021.data?.isCheese, isTrue); + expect(nutriScore2021.data?.isFat, isFalse); }); test('parses inconsistent NutriScore data', () { final result = NutriScoreDetails.fromJson(inconsistent); expect(result, isNotNull); - expect(result.rawNutriScore2021, isNotNull); - expect(result.rawNutriScore2023, isNotNull); - - final NutriScore2021 nutriScore = result.nutriScore2021!; - expect(nutriScore.status, NutriScoreStatus.notApplicable); - expect(nutriScore.grade, isNull); - expect(nutriScore.score, isNull); - expect(nutriScore.category, NutriScoreCategory2021.water); - expect(nutriScore.categoryAvailable, isTrue); + expect(result.v2021, isNotNull); + expect(result.v2023, isNotNull); + + final nutriScore2021 = result.v2021; + expect(nutriScore2021, isNotNull); + expect(nutriScore2021!.grade, 'not-applicable'); + expect(nutriScore2021.score, isNull); + expect(nutriScore2021.categoryAvailable, isTrue); + expect(nutriScore2021.nutrientsAvailable, isTrue); + expect(nutriScore2021.data?.isBeverage, isTrue); + expect(nutriScore2021.data?.isWater, isTrue); + expect(nutriScore2021.data?.isCheese, isFalse); + expect(nutriScore2021.data?.isFat, isFalse); }); }); @@ -198,24 +219,20 @@ void main() { final copy = NutriScoreDetails.fromJson(original.toJson()); expect(copy, isNotNull); - final orig2021 = original.nutriScore2021; - final copy2021 = copy.nutriScore2021; - final orig2023 = original.nutriScore2023; - final copy2023 = copy.nutriScore2023; + final orig2021 = original.v2021; + final copy2021 = copy.v2021; + final orig2023 = original.v2023; + final copy2023 = copy.v2023; - expect(copy2021?.category, equals(orig2021?.category)); expect(copy2021?.grade, equals(orig2021?.grade)); expect(copy2021?.score, equals(orig2021?.score)); - expect(copy2021?.status, equals(orig2021?.status)); expect(copy2021?.categoryAvailable, equals(orig2021?.categoryAvailable)); expect( copy2021?.nutrientsAvailable, equals(orig2021?.nutrientsAvailable), ); - expect(copy2023?.category, equals(orig2023?.category)); expect(copy2023?.grade, equals(orig2023?.grade)); expect(copy2023?.score, equals(orig2023?.score)); - expect(copy2023?.status, equals(orig2023?.status)); expect(copy2023?.categoryAvailable, equals(orig2023?.categoryAvailable)); expect( copy2023?.nutrientsAvailable, From 06a56c8fe534d5f1e65aa732986dd7c95d5141e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olaf=20G=C3=B6rlitz?= Date: Fri, 4 Jul 2025 17:00:54 +0200 Subject: [PATCH 11/12] removed Nutri-Score json tests --- test/nutriscore_from_json_test.dart | 247 ---------------------------- 1 file changed, 247 deletions(-) delete mode 100644 test/nutriscore_from_json_test.dart diff --git a/test/nutriscore_from_json_test.dart b/test/nutriscore_from_json_test.dart deleted file mode 100644 index 99ca4ccf2a..0000000000 --- a/test/nutriscore_from_json_test.dart +++ /dev/null @@ -1,247 +0,0 @@ -import 'package:openfoodfacts/openfoodfacts.dart'; -import 'package:test/test.dart'; - -void main() { - final computed = { - '2023': { - 'grade': 'b', - 'score': 2, - 'category_available': 1, - 'nutrients_available': 1, - 'data': { - 'is_beverage': 1, - 'is_cheese': 0, - 'is_fat_oil_nuts_seeds': 0, - 'is_red_meat_product': 0, - 'is_water': 0, - } - } - }; - - final unknown = { - '2023': { - 'grade': 'unknown', - 'category_available': 1, - 'nutrients_available': 0, - 'data': { - 'is_beverage': 0, - 'is_cheese': 0, - 'is_fat_oil_nuts_seeds': 0, - 'is_red_meat_product': 0, - 'is_water': 0, - } - } - }; - - final notApplicable = { - '2023': { - 'grade': 'not-applicable', - 'category_available': 1, - 'nutrients_available': 1, - 'not_applicable_category': 'en:alcoholic-beverages', - 'data': { - 'is_beverage': 1, - 'is_cheese': 0, - 'is_fat_oil_nuts_seeds': 0, - 'is_red_meat_product': 0, - 'is_water': 0, - } - } - }; - - final mixedDataTypes = { - // e.g. 2336152903783 - '2021': { - 'grade': 'd', - 'score': 15, - 'category_available': 1, - 'nutrients_available': 1, - 'data': { - 'is_beverage': 0, - 'is_cheese': "1", - 'is_fat': 0, - 'is_water': 0, - } - }, - }; - - final inconsistent = { - '2021': { - 'grade': 'not-applicable', - 'category_available': 1, - 'nutrients_available': 1, - 'not_applicable_category': 'en:alcoholic-beverages', - 'data': { - 'is_beverage': 1, - 'is_cheese': 0, - 'is_fat': 0, - 'is_red_meat_product': 0, - 'is_water': "1", - } - }, - '2023': { - 'grade': 'unknown', - 'category_available': 1, - 'nutrients_available': 0, - 'not_applicable_category': 'en:alcoholic-beverages', - 'data': { - 'is_red_meat_product': 1, - } - } - }; - - group('NutriScore.fromJson', () { - test('returns no Nutri-Scores when input is empty', () { - final result = NutriScoreDetails.fromJson({}); - expect(result, isNotNull); - expect(result.v2021, isNull); - expect(result.v2023, isNull); - }); - - test('ignores entries with unknown versions or null', () { - final result = NutriScoreDetails.fromJson({ - 'invalid_version': {'grade': 'a'}, - '2021': null, - }); - expect(result, isNotNull); - expect(result.v2021, isNull); - expect(result.v2023, isNull); - }); - - test('throws Error on non-map input', () { - expect( - () => NutriScoreDetails.fromJson( - {'2023': 'not a map'}, - ), - throwsA(isA()), - ); - }); - - test('parses computed NutriScore for version 2023', () { - final result = NutriScoreDetails.fromJson(computed); - expect(result, isNotNull); - expect(result.v2021, isNull); - expect(result.v2023, isNotNull); - - final nutriScore2023 = result.v2023; - expect(nutriScore2023, isNotNull); - expect(nutriScore2023!.grade, 'b'); - expect(nutriScore2023.score, 2); - expect(nutriScore2023.categoryAvailable, isTrue); - expect(nutriScore2023.nutrientsAvailable, isTrue); - expect(nutriScore2023.data?.isBeverage, isTrue); - expect(nutriScore2023.data?.isWater, isFalse); - expect(nutriScore2023.data?.isCheese, isFalse); - expect(nutriScore2023.data?.isFatOilNutsSeeds, isFalse); - expect(nutriScore2023.data?.isRedMeatProduct, isFalse); - }); - - test('parses unknown NutriScore for version 2023', () { - final result = NutriScoreDetails.fromJson(unknown); - expect(result, isNotNull); - expect(result.v2021, isNull); - expect(result.v2023, isNotNull); - - final nutriScore2023 = result.v2023; - expect(nutriScore2023, isNotNull); - expect(nutriScore2023!.grade, 'unknown'); - expect(nutriScore2023.score, isNull); - expect(nutriScore2023.categoryAvailable, isTrue); - expect(nutriScore2023.nutrientsAvailable, isFalse); - expect(nutriScore2023.data?.isBeverage, isFalse); - expect(nutriScore2023.data?.isWater, isFalse); - expect(nutriScore2023.data?.isCheese, isFalse); - expect(nutriScore2023.data?.isFatOilNutsSeeds, isFalse); - expect(nutriScore2023.data?.isRedMeatProduct, isFalse); - }); - - test('parses not applicable NutriScore for version 2023', () { - final result = NutriScoreDetails.fromJson(notApplicable); - expect(result, isNotNull); - expect(result.v2021, isNull); - expect(result.v2023, isNotNull); - - final nutriScore2023 = result.v2023; - expect(nutriScore2023, isNotNull); - expect(nutriScore2023!.grade, 'not-applicable'); - expect(nutriScore2023.score, isNull); - expect(nutriScore2023.categoryAvailable, isTrue); - expect(nutriScore2023.nutrientsAvailable, isTrue); - expect(nutriScore2023.data?.isBeverage, isTrue); - expect(nutriScore2023.data?.isWater, isFalse); - expect(nutriScore2023.data?.isCheese, isFalse); - expect(nutriScore2023.data?.isFatOilNutsSeeds, isFalse); - expect(nutriScore2023.data?.isRedMeatProduct, isFalse); - }); - - test('parses mixed data types in categories', () { - final result = NutriScoreDetails.fromJson(mixedDataTypes); - expect(result, isNotNull); - expect(result.v2021, isNotNull); - expect(result.v2023, isNull); - - final nutriScore2021 = result.v2021; - expect(nutriScore2021, isNotNull); - expect(nutriScore2021!.grade, 'd'); - expect(nutriScore2021.score, isNotNull); - expect(nutriScore2021.categoryAvailable, isTrue); - expect(nutriScore2021.nutrientsAvailable, isTrue); - expect(nutriScore2021.data?.isBeverage, isFalse); - expect(nutriScore2021.data?.isWater, isFalse); - expect(nutriScore2021.data?.isCheese, isTrue); - expect(nutriScore2021.data?.isFat, isFalse); - }); - - test('parses inconsistent NutriScore data', () { - final result = NutriScoreDetails.fromJson(inconsistent); - expect(result, isNotNull); - expect(result.v2021, isNotNull); - expect(result.v2023, isNotNull); - - final nutriScore2021 = result.v2021; - expect(nutriScore2021, isNotNull); - expect(nutriScore2021!.grade, 'not-applicable'); - expect(nutriScore2021.score, isNull); - expect(nutriScore2021.categoryAvailable, isTrue); - expect(nutriScore2021.nutrientsAvailable, isTrue); - expect(nutriScore2021.data?.isBeverage, isTrue); - expect(nutriScore2021.data?.isWater, isTrue); - expect(nutriScore2021.data?.isCheese, isFalse); - expect(nutriScore2021.data?.isFat, isFalse); - }); - }); - - test('round-trip toJson/fromJson', () { - for (final json in [computed, unknown, notApplicable, inconsistent]) { - final original = NutriScoreDetails.fromJson(json); - expect(original, isNotNull); - - final copy = NutriScoreDetails.fromJson(original.toJson()); - expect(copy, isNotNull); - - final orig2021 = original.v2021; - final copy2021 = copy.v2021; - final orig2023 = original.v2023; - final copy2023 = copy.v2023; - - expect(copy2021?.grade, equals(orig2021?.grade)); - expect(copy2021?.score, equals(orig2021?.score)); - expect(copy2021?.categoryAvailable, equals(orig2021?.categoryAvailable)); - expect( - copy2021?.nutrientsAvailable, - equals(orig2021?.nutrientsAvailable), - ); - expect(copy2023?.grade, equals(orig2023?.grade)); - expect(copy2023?.score, equals(orig2023?.score)); - expect(copy2023?.categoryAvailable, equals(orig2023?.categoryAvailable)); - expect( - copy2023?.nutrientsAvailable, - equals(orig2023?.nutrientsAvailable), - ); - expect( - copy2023?.notApplicableCategory, - equals(orig2023?.notApplicableCategory), - ); - } - }); -} From 7860eab2fcd3abb9146e6e21d343363f27112352 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olaf=20G=C3=B6rlitz?= Date: Sat, 11 Oct 2025 22:56:13 +0200 Subject: [PATCH 12/12] test toJson and restoring nutriscore details --- .../model/nutriscore/nutriscore_details.dart | 2 + lib/src/model/product.dart | 5 +- lib/src/model/product.g.dart | 4 +- test/api_get_product_test.dart | 64 +++++++++++++++++++ 4 files changed, 73 insertions(+), 2 deletions(-) diff --git a/lib/src/model/nutriscore/nutriscore_details.dart b/lib/src/model/nutriscore/nutriscore_details.dart index 84b4dd4e72..dd51d7cc65 100644 --- a/lib/src/model/nutriscore/nutriscore_details.dart +++ b/lib/src/model/nutriscore/nutriscore_details.dart @@ -21,4 +21,6 @@ class NutriScoreDetails extends JsonObject { @override Map toJson() => _$NutriScoreDetailsToJson(this); + + static Map? toJsonMap(NutriScoreDetails? v) => v?.toJson(); } diff --git a/lib/src/model/product.dart b/lib/src/model/product.dart index b51158708f..cdb5995628 100644 --- a/lib/src/model/product.dart +++ b/lib/src/model/product.dart @@ -417,7 +417,10 @@ class Product extends JsonObject { @JsonKey(name: 'nutrition_grade_fr') String? nutriscore; - @JsonKey(name: 'nutriscore') + @JsonKey( + name: 'nutriscore', + toJson: NutriScoreDetails.toJsonMap, + ) NutriScoreDetails? nutriScoreDetails; @JsonKey(name: 'compared_to_category') diff --git a/lib/src/model/product.g.dart b/lib/src/model/product.g.dart index 4b91b557e4..aa42805515 100644 --- a/lib/src/model/product.g.dart +++ b/lib/src/model/product.g.dart @@ -303,7 +303,9 @@ Map _$ProductToJson(Product instance) => { if (instance.nutrimentDataPer case final value?) 'nutrition_data_per': value, if (instance.nutriscore case final value?) 'nutrition_grade_fr': value, - if (instance.nutriScoreDetails case final value?) 'nutriscore': value, + if (NutriScoreDetails.toJsonMap(instance.nutriScoreDetails) + case final value?) + 'nutriscore': value, if (instance.comparedToCategory case final value?) 'compared_to_category': value, if (instance.categories case final value?) 'categories': value, diff --git a/test/api_get_product_test.dart b/test/api_get_product_test.dart index 669e99ea7e..c0ea652ae5 100644 --- a/test/api_get_product_test.dart +++ b/test/api_get_product_test.dart @@ -188,6 +188,38 @@ void main() { expect(nutriScore2023.data!.isFatOilNutsSeeds, isFalse); expect(nutriScore2023.data!.isWater, isFalse); expect(nutriScore2023.data!.isCheese, isFalse); + + // test serialization + final productJson = result.product!.toJson(); + final productRestored = Product.fromJson(productJson); + expect(productRestored.nutriScoreDetails, isNotNull); + + final restored2021 = productRestored.nutriScoreDetails!.v2021; + expect(restored2021, isNotNull); + expect(restored2021!.grade, "not-applicable"); + expect(restored2021.categoryAvailable, isTrue); + expect(restored2021.score, isNull); + expect(restored2021.nutrientsAvailable, isTrue); + expect(restored2021.nutriScoreComputed, isFalse); + expect(restored2021.nutriScoreApplicable, isFalse); + expect(restored2021.data, isNotNull); + expect(restored2021.data!.isBeverage, isTrue); + expect(restored2021.data!.isWater, isFalse); + expect(restored2021.data!.isCheese, isFalse); + expect(restored2021.data!.isFat, isFalse); + expect(restored2021.notApplicableCategory, category); + + final restored2023 = productRestored.nutriScoreDetails!.v2023; + expect(restored2023, isNotNull); + expect(restored2023!.grade, "not-applicable"); + expect(restored2023.score, isNull); + expect(restored2023.data, isNotNull); + expect(restored2023.notApplicableCategory, category); + expect(restored2023.data!.isBeverage, isTrue); + expect(restored2023.data!.isRedMeatProduct, isFalse); + expect(restored2023.data!.isFatOilNutsSeeds, isFalse); + expect(restored2023.data!.isWater, isFalse); + expect(restored2023.data!.isCheese, isFalse); }); test('check nutriscore data for cookies', () async { @@ -237,6 +269,38 @@ void main() { expect(nutriScore2023.data!.isFatOilNutsSeeds, isFalse); expect(nutriScore2023.data!.isWater, isFalse); expect(nutriScore2023.data!.isCheese, isFalse); + + // test serialization + final productJson = result.product!.toJson(); + final productRestored = Product.fromJson(productJson); + expect(productRestored.nutriScoreDetails, isNotNull); + + final restored2021 = productRestored.nutriScoreDetails!.v2021; + expect(restored2021, isNotNull); + expect(restored2021!.grade, "e"); + expect(restored2021.categoryAvailable, isTrue); + expect(restored2021.score, 23); + expect(restored2021.nutrientsAvailable, isTrue); + expect(restored2021.nutriScoreComputed, isTrue); + expect(restored2021.nutriScoreApplicable, isTrue); + expect(restored2021.data, isNotNull); + expect(restored2021.data!.isBeverage, isFalse); + expect(restored2021.data!.isWater, isFalse); + expect(restored2021.data!.isCheese, isFalse); + expect(restored2021.data!.isFat, isFalse); + expect(restored2021.notApplicableCategory, isNull); + + final restored2023 = productRestored.nutriScoreDetails!.v2023; + expect(restored2023, isNotNull); + expect(restored2023!.grade, "e"); + expect(restored2023.score, 25); + expect(restored2023.data, isNotNull); + expect(restored2023.notApplicableCategory, isNull); + expect(restored2023.data!.isBeverage, isFalse); + expect(restored2023.data!.isRedMeatProduct, isFalse); + expect(restored2023.data!.isFatOilNutsSeeds, isFalse); + expect(restored2023.data!.isWater, isFalse); + expect(restored2023.data!.isCheese, isFalse); }); test('get product Danish Butter Cookies & Chocolate Chip Cookies',