diff --git a/lib/src/personalized_search/available_attribute_groups.dart b/lib/src/personalized_search/available_attribute_groups.dart index f12772327d..fd1bd7b08d 100644 --- a/lib/src/personalized_search/available_attribute_groups.dart +++ b/lib/src/personalized_search/available_attribute_groups.dart @@ -1,5 +1,6 @@ import '../model/attribute_group.dart'; import '../utils/http_helper.dart'; +import '../utils/language_helper.dart'; /// Referential of attribute groups, with loader. class AvailableAttributeGroups { @@ -25,6 +26,12 @@ class AvailableAttributeGroups { /// Where a localized JSON file can be found. /// [languageCode] is a 2-letter language code. + // TODO: deprecated from 2023-08-12; remove when old enough + @Deprecated('Use getLocalizedUrl instead') static String getUrl(final String languageCode) => 'https://world.openfoodfacts.org/api/v2/attribute_groups?lc=$languageCode'; + + /// Where a localized JSON file can be found. + static String getLocalizedUrl(final OpenFoodFactsLanguage language) => + 'https://world.openfoodfacts.org/api/v2/attribute_groups?lc=${language.code}'; } diff --git a/lib/src/personalized_search/available_preference_importances.dart b/lib/src/personalized_search/available_preference_importances.dart index 14707163bc..3a99504121 100644 --- a/lib/src/personalized_search/available_preference_importances.dart +++ b/lib/src/personalized_search/available_preference_importances.dart @@ -1,5 +1,6 @@ import 'preference_importance.dart'; import '../utils/http_helper.dart'; +import '../utils/language_helper.dart'; /// Referential of preference importance, with loader. class AvailablePreferenceImportances { @@ -43,9 +44,15 @@ class AvailablePreferenceImportances { /// Where a localized JSON file can be found. /// [languageCode] is a 2-letter language code. + // TODO: deprecated from 2023-08-12; remove when old enough + @Deprecated('Use getLocalizedUrl instead') static String getUrl(final String languageCode) => 'https://world.openfoodfacts.org/api/v2/preferences?lc=$languageCode'; + /// Where a localized JSON file can be found. + static String getLocalizedUrl(final OpenFoodFactsLanguage language) => + 'https://world.openfoodfacts.org/api/v2/preferences?lc=${language.code}'; + /// Returns the index of an importance. /// /// From 0: not important. diff --git a/lib/src/personalized_search/matched_product_v2.dart b/lib/src/personalized_search/matched_product_v2.dart index 3ee927c167..c1240d0a91 100644 --- a/lib/src/personalized_search/matched_product_v2.dart +++ b/lib/src/personalized_search/matched_product_v2.dart @@ -32,12 +32,26 @@ enum MatchedProductStatusV2 { /// Score of a product according to preferences. /// -/// For performance reasons we store just the barcode, not the product. +/// For performance (memory) reasons we store just the barcode, not the product. +/// For performance (memory) reasons we store explanations only if needed. +/// Typical usage of explanations: +/// * if status is [MatchedProductStatusV2.UNKNOWN_MATCH] +/// * first check - it's because there were unknown mandatory attributes, +/// listed in [unknownMandatoryAttributes] (check if not null). +/// * or it's because there were too many unknown attributes, listed in +/// [unknownAttributes] (check if not null). +/// * or it's because there is no data in the product (if both +/// [unknownMandatoryAttributes] and [unknownAttributes] are null). +/// * if status is [MatchedProductStatusV2.MAY_NOT_MATCH] +/// * the problematic attributes are listed in [mayNotMatchAttributes]. +/// * if status is [MatchedProductStatusV2.DOES_NOT_MATCH] +/// * the problematic attributes are listed in [doesNotMatchAttributes]. class MatchedScoreV2 { MatchedScoreV2( final Product product, - final ProductPreferencesManager productPreferencesManager, - ) : barcode = product.barcode! { + final ProductPreferencesManager productPreferencesManager, { + final bool withExplanations = false, + }) : barcode = product.barcode! { _score = 0; _debug = ''; @@ -76,8 +90,16 @@ class MatchedScoreV2 { if (attribute.status == Attribute.STATUS_UNKNOWN) { sumOfFactorsForUnknownAttributes += factor; + if (withExplanations) { + _unknownAttributes ??= []; + _unknownAttributes!.add(attribute); + } if (importanceId == PreferenceImportance.ID_MANDATORY) { isUnknown = true; + if (withExplanations) { + _unknownMandatoryAttributes ??= []; + _unknownMandatoryAttributes!.add(attribute); + } } } else { _score += match * factor; @@ -87,10 +109,18 @@ class MatchedScoreV2 { if (match <= 10) { // Mandatory attribute with a very bad score (e.g. contains an allergen) -> status: does not match doesNotMatch = true; + if (withExplanations) { + _doesNotMatchAttributes ??= []; + _doesNotMatchAttributes!.add(attribute); + } } // Mandatory attribute with a bad score (e.g. may contain traces of an allergen) -> status: may not match else if (match <= 50) { mayNotMatch = true; + if (withExplanations) { + _mayNotMatchAttributes ??= []; + _mayNotMatchAttributes!.add(attribute); + } } } } @@ -133,6 +163,10 @@ class MatchedScoreV2 { late MatchedProductStatusV2 _status; String _debug = ''; int _initialOrder = 0; + List? _unknownAttributes; + List? _unknownMandatoryAttributes; + List? _doesNotMatchAttributes; + List? _mayNotMatchAttributes; double get score => _score; @@ -140,6 +174,31 @@ class MatchedScoreV2 { String get debug => _debug; + /// List of attributes that potentially provoked an "unknown match". + /// + /// Will be null if "withExplanations" is false, or if there were no related + /// attributes. + List? get unknownAttributes => _unknownAttributes; + + /// List of mandatory attributes that provoked an "unknown match". + /// + /// Will be null if "withExplanations" is false, or if there were no related + /// attributes. + List? get unknownMandatoryAttributes => + _unknownMandatoryAttributes; + + /// List of attributes that provoked a "does not match". + /// + /// Will be null if "withExplanations" is false, or if there were no related + /// attributes. + List? get doesNotMatchAttributes => _doesNotMatchAttributes; + + /// List of attributes that provoked a "may not match". + /// + /// Will be null if "withExplanations" is false, or if there were no related + /// attributes. + List? get mayNotMatchAttributes => _mayNotMatchAttributes; + /// Weights for score static const Map _preferencesFactors = { PreferenceImportance.ID_MANDATORY: 2, diff --git a/test/api_matched_product_v1_test.dart b/test/api_matched_product_v1_test.dart index f66597bc82..13d4815751 100644 --- a/test/api_matched_product_v1_test.dart +++ b/test/api_matched_product_v1_test.dart @@ -27,11 +27,10 @@ void main() { ), ); const OpenFoodFactsLanguage language = OpenFoodFactsLanguage.ENGLISH; - final String languageCode = language.code; final String importanceUrl = - AvailablePreferenceImportances.getUrl(languageCode); + AvailablePreferenceImportances.getLocalizedUrl(language); final String attributeGroupUrl = - AvailableAttributeGroups.getUrl(languageCode); + AvailableAttributeGroups.getLocalizedUrl(language); http.Response response; response = await http.get(Uri.parse(importanceUrl)); expect(response.statusCode, HTTP_OK); diff --git a/test/api_matched_product_v2_test.dart b/test/api_matched_product_v2_test.dart index 268184a8de..2b51712f07 100644 --- a/test/api_matched_product_v2_test.dart +++ b/test/api_matched_product_v2_test.dart @@ -14,12 +14,18 @@ class _Score { void main() { const int HTTP_OK = 200; - const OpenFoodFactsLanguage language = OpenFoodFactsLanguage.FRENCH; + late OpenFoodFactsLanguage language; OpenFoodAPIConfiguration.userAgent = TestConstants.TEST_USER_AGENT; OpenFoodAPIConfiguration.globalQueryType = QueryType.PROD; OpenFoodAPIConfiguration.globalCountry = OpenFoodFactsCountry.FRANCE; OpenFoodAPIConfiguration.globalUser = TestConstants.PROD_USER; - OpenFoodAPIConfiguration.globalLanguages = [language]; + + void setLanguage(final OpenFoodFactsLanguage newLanguage) { + language = newLanguage; + OpenFoodAPIConfiguration.globalLanguages = [ + language + ]; + } const String BARCODE_KNACKI = '7613035937420'; const String BARCODE_CORDONBLEU = '4000405005026'; @@ -113,11 +119,10 @@ void main() { PreferenceImportance.ID_NOT_IMPORTANT, ), ); - final String languageCode = language.code; final String importanceUrl = - AvailablePreferenceImportances.getUrl(languageCode); + AvailablePreferenceImportances.getLocalizedUrl(language); final String attributeGroupUrl = - AvailableAttributeGroups.getUrl(languageCode); + AvailableAttributeGroups.getLocalizedUrl(language); http.Response response; response = await http.get(Uri.parse(importanceUrl)); expect(response.statusCode, HTTP_OK); @@ -140,6 +145,8 @@ void main() { /// Tests around Matched Product v2. group('$OpenFoodAPIClient matched product v2', () { test('matched product', () async { + setLanguage(OpenFoodFactsLanguage.FRENCH); + final ProductPreferencesManager manager = await getManager(); final List products = await downloadProducts(); @@ -157,10 +164,17 @@ void main() { final _Score score = expectedScores[barcode]!; expect(matched.status, score.status); expect(matched.score, score.score); + // we didn't ask explicitly for explanations + expect(matched.mayNotMatchAttributes, isNull); + expect(matched.doesNotMatchAttributes, isNull); + expect(matched.unknownMandatoryAttributes, isNull); + expect(matched.unknownAttributes, isNull); } }); test('matched score', () async { + setLanguage(OpenFoodFactsLanguage.FRENCH); + final ProductPreferencesManager manager = await getManager(); final List products = await downloadProducts(); @@ -180,7 +194,84 @@ void main() { final _Score score = expectedScores[barcode]!; expect(matched.status, score.status); expect(matched.score, score.score); + // we didn't ask explicitly for explanations + expect(matched.mayNotMatchAttributes, isNull); + expect(matched.doesNotMatchAttributes, isNull); + expect(matched.unknownMandatoryAttributes, isNull); + expect(matched.unknownAttributes, isNull); } }); + + Future checkExplanations( + final OpenFoodFactsLanguage language, + final String unknownMatchLabel, + final String doesNotMatchLabel, + ) async { + setLanguage(language); + + final ProductPreferencesManager manager = await getManager(); + + final List products = await downloadProducts(); + + final List actuals = []; + for (final Product product in products) { + actuals.add( + MatchedScoreV2( + product, + manager, + // explicitly asking for explanations + withExplanations: true, + ), + ); + } + + for (final MatchedScoreV2 matched in actuals) { + switch (matched.status) { + case MatchedProductStatusV2.UNKNOWN_MATCH: + expect(matched.unknownMandatoryAttributes, hasLength(1)); + expect( + matched.unknownMandatoryAttributes!.first.title, + unknownMatchLabel, + ); + expect(matched.unknownAttributes, hasLength(1)); + expect( + matched.unknownAttributes!.first.title, + unknownMatchLabel, + ); + break; + case MatchedProductStatusV2.DOES_NOT_MATCH: + expect(matched.doesNotMatchAttributes, hasLength(1)); + expect( + matched.doesNotMatchAttributes!.first.title, + doesNotMatchLabel, + ); + break; + case MatchedProductStatusV2.VERY_GOOD_MATCH: + break; + case MatchedProductStatusV2.GOOD_MATCH: + case MatchedProductStatusV2.POOR_MATCH: + case MatchedProductStatusV2.MAY_NOT_MATCH: + fail('Unexpected status: ${matched.status}'); + } + } + } + + test( + 'score explanations FR', + () async => checkExplanations( + OpenFoodFactsLanguage.FRENCH, + 'Caractère végétarien inconnu', + 'Non végétarien', + ), + ); + + test( + 'score explanations EN', + () async => checkExplanations( + OpenFoodFactsLanguage.ENGLISH, + 'Vegetarian status unknown', + 'Non-vegetarian', + ), + ); }); } diff --git a/test/api_product_preferences_test.dart b/test/api_product_preferences_test.dart index da0b933e7f..b62e026ded 100644 --- a/test/api_product_preferences_test.dart +++ b/test/api_product_preferences_test.dart @@ -32,11 +32,10 @@ void main() { notify: () => refreshCounter++, ), ); - final String languageCode = language.code; final String importanceUrl = - AvailablePreferenceImportances.getUrl(languageCode); + AvailablePreferenceImportances.getLocalizedUrl(language); final String attributeGroupUrl = - AvailableAttributeGroups.getUrl(languageCode); + AvailableAttributeGroups.getLocalizedUrl(language); http.Response response; response = await http.get(Uri.parse(importanceUrl)); expect(response.statusCode, HTTP_OK);