diff --git a/.github/workflows/auto-assign-pr.yml b/.github/workflows/auto-assign-pr.yml index a1d9aeb433..299f76cded 100644 --- a/.github/workflows/auto-assign-pr.yml +++ b/.github/workflows/auto-assign-pr.yml @@ -12,4 +12,4 @@ jobs: assign-author: runs-on: ubuntu-latest steps: - - uses: toshimaru/auto-author-assign@v1.5.1 + - uses: toshimaru/auto-author-assign@v1.6.1 diff --git a/example/pubspec.yaml b/example/pubspec.yaml index f4c7b5278e..a4d502bd2c 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -13,4 +13,4 @@ dependencies: dev_dependencies: test: any - lints: ^1.0.1 + lints: ^2.0.0 diff --git a/lib/model/IngredientsAnalysisTags.dart b/lib/model/IngredientsAnalysisTags.dart index f27506eba9..066c7f3bb0 100644 --- a/lib/model/IngredientsAnalysisTags.dart +++ b/lib/model/IngredientsAnalysisTags.dart @@ -5,6 +5,16 @@ enum VeganStatus { VEGAN_STATUS_UNKNOWN, } +extension VeganStatusExtension on VeganStatus { + static const Map _tags = { + VeganStatus.VEGAN: 'en:vegan', + VeganStatus.NON_VEGAN: 'en:non-vegan', + VeganStatus.MAYBE_VEGAN: 'en:maybe-vegan', + VeganStatus.VEGAN_STATUS_UNKNOWN: 'en:vegan-status-unknown', + }; + String get tag => _tags[this]!; +} + enum VegetarianStatus { VEGETARIAN, NON_VEGETARIAN, @@ -12,6 +22,16 @@ enum VegetarianStatus { VEGETARIAN_STATUS_UNKNOWN, } +extension VegetarianStatusExtension on VegetarianStatus { + static const Map _tags = { + VegetarianStatus.VEGETARIAN: 'en:vegetarian', + VegetarianStatus.NON_VEGETARIAN: 'en:non-vegetarian', + VegetarianStatus.MAYBE_VEGETARIAN: 'en:maybe-vegetarian', + VegetarianStatus.VEGETARIAN_STATUS_UNKNOWN: 'en:vegetarian-status-unknown', + }; + String get tag => _tags[this]!; +} + enum PalmOilFreeStatus { PALM_OIL_FREE, PALM_OIL, @@ -19,6 +39,16 @@ enum PalmOilFreeStatus { PALM_OIL_CONTENT_UNKNOWN, } +extension PalmOilFreeStatusExtension on PalmOilFreeStatus { + static const Map _tags = { + PalmOilFreeStatus.PALM_OIL_FREE: 'en:palm-oil-free', + PalmOilFreeStatus.PALM_OIL: 'en:palm-oil', + PalmOilFreeStatus.MAY_CONTAIN_PALM_OIL: 'en:may-contain-palm-oil', + PalmOilFreeStatus.PALM_OIL_CONTENT_UNKNOWN: 'en:palm-oil-content-unknown', + }; + String get tag => _tags[this]!; +} + class IngredientsAnalysisTags { IngredientsAnalysisTags(List data) { veganStatus = _getVeganStatus(data); @@ -26,76 +56,31 @@ class IngredientsAnalysisTags { palmOilFreeStatus = _getPalmOilFreeStatus(data); } - static const List _VEGAN_TAGS = [ - 'en:vegan', - 'en:non-vegan', - 'en:maybe-vegan', - 'en:vegan-status-unknown', - ]; - - static const List _VEGAN_STATUSES = [ - VeganStatus.VEGAN, - VeganStatus.NON_VEGAN, - VeganStatus.MAYBE_VEGAN, - VeganStatus.VEGAN_STATUS_UNKNOWN, - ]; - - static const List _VEGETARIAN_TAGS = [ - 'en:vegetarian', - 'en:non-vegetarian', - 'en:maybe-vegetarian', - 'en:vegetarian-status-unknown', - ]; - - static const List _VEGETARIAN_STATUSES = [ - VegetarianStatus.VEGETARIAN, - VegetarianStatus.NON_VEGETARIAN, - VegetarianStatus.MAYBE_VEGETARIAN, - VegetarianStatus.VEGETARIAN_STATUS_UNKNOWN, - ]; - - static const List _PALM_OIL_FREE_TAGS = [ - 'en:palm-oil-free', - 'en:palm-oil', - 'en:may-contain-palm-oil', - 'en:palm-oil-content-unknown', - ]; - - static const List _PALM_OIL_FREE_STATUSES = [ - PalmOilFreeStatus.PALM_OIL_FREE, - PalmOilFreeStatus.PALM_OIL, - PalmOilFreeStatus.MAY_CONTAIN_PALM_OIL, - PalmOilFreeStatus.PALM_OIL_CONTENT_UNKNOWN, - ]; - static VeganStatus? _getVeganStatus(List data) { - VeganStatus? result; - _VEGAN_TAGS.asMap().forEach((key, value) { - if (data.contains(value)) { - result = _VEGAN_STATUSES[key]; + for (final VeganStatus status in VeganStatus.values) { + if (data.contains(status.tag)) { + return status; } - }); - return result; + } + return null; } static VegetarianStatus? _getVegetarianStatus(List data) { - VegetarianStatus? result; - _VEGETARIAN_TAGS.asMap().forEach((key, value) { - if (data.contains(value)) { - result = _VEGETARIAN_STATUSES[key]; + for (final VegetarianStatus status in VegetarianStatus.values) { + if (data.contains(status.tag)) { + return status; } - }); - return result; + } + return null; } static PalmOilFreeStatus? _getPalmOilFreeStatus(List data) { - PalmOilFreeStatus? result; - _PALM_OIL_FREE_TAGS.asMap().forEach((key, value) { - if (data.contains(value)) { - result = _PALM_OIL_FREE_STATUSES[key]; + for (final PalmOilFreeStatus status in PalmOilFreeStatus.values) { + if (data.contains(status.tag)) { + return status; } - }); - return result; + } + return null; } VeganStatus? veganStatus; @@ -113,23 +98,15 @@ class IngredientsAnalysisTags { return result; } - _VEGAN_STATUSES.asMap().forEach((key, value) { - if (ingredientsAnalysisTags.veganStatus == value) { - result.add(_VEGAN_TAGS[key]); - } - }); - - _VEGETARIAN_STATUSES.asMap().forEach((key, value) { - if (ingredientsAnalysisTags.vegetarianStatus == value) { - result.add(_VEGETARIAN_TAGS[key]); - } - }); - - _PALM_OIL_FREE_STATUSES.asMap().forEach((key, value) { - if (ingredientsAnalysisTags.palmOilFreeStatus == value) { - result.add(_PALM_OIL_FREE_TAGS[key]); - } - }); + if (ingredientsAnalysisTags.veganStatus != null) { + result.add(ingredientsAnalysisTags.veganStatus!.tag); + } + if (ingredientsAnalysisTags.vegetarianStatus != null) { + result.add(ingredientsAnalysisTags.vegetarianStatus!.tag); + } + if (ingredientsAnalysisTags.palmOilFreeStatus != null) { + result.add(ingredientsAnalysisTags.palmOilFreeStatus!.tag); + } return result; } diff --git a/lib/model/parameter/IngredientsAnalysisParameter.dart b/lib/model/parameter/IngredientsAnalysisParameter.dart new file mode 100644 index 0000000000..d222d76e19 --- /dev/null +++ b/lib/model/parameter/IngredientsAnalysisParameter.dart @@ -0,0 +1,36 @@ +import 'package:openfoodfacts/interface/Parameter.dart'; +import 'package:openfoodfacts/model/IngredientsAnalysisTags.dart'; + +/// Ingredients Analysis search API parameter. +class IngredientsAnalysisParameter extends Parameter { + @override + String getName() => 'ingredients_analysis_tags'; + + @override + String getValue() { + final List result = []; + if (veganStatus != null) { + result.add(veganStatus!.tag); + } + if (vegetarianStatus != null) { + result.add(vegetarianStatus!.tag); + } + if (palmOilFreeStatus != null) { + result.add(palmOilFreeStatus!.tag); + } + if (result.isEmpty) { + return ''; + } + return result.join(','); + } + + final VeganStatus? veganStatus; + final VegetarianStatus? vegetarianStatus; + final PalmOilFreeStatus? palmOilFreeStatus; + + const IngredientsAnalysisParameter({ + this.veganStatus, + this.vegetarianStatus, + this.palmOilFreeStatus, + }); +} diff --git a/lib/model/parameter/SortBy.dart b/lib/model/parameter/SortBy.dart index 584c4ee6cc..4a4da45e25 100644 --- a/lib/model/parameter/SortBy.dart +++ b/lib/model/parameter/SortBy.dart @@ -8,6 +8,8 @@ class SortBy extends Parameter { SortOption.EDIT: 'last_modified_t', SortOption.POPULARITY: 'unique_scans_n', SortOption.NOTHING: 'nothing', + SortOption.ECOSCORE: 'ecoscore_score', + SortOption.NUTRISCORE: 'nutriscore_score', }; @override @@ -28,4 +30,6 @@ enum SortOption { CREATED, EDIT, NOTHING, + ECOSCORE, + NUTRISCORE, } diff --git a/lib/openfoodfacts.dart b/lib/openfoodfacts.dart index da474a1739..9c089f61c4 100644 --- a/lib/openfoodfacts.dart +++ b/lib/openfoodfacts.dart @@ -45,7 +45,6 @@ import 'utils/HttpHelper.dart'; import 'utils/LanguageHelper.dart'; import 'utils/ProductHelper.dart'; import 'utils/ProductQueryConfigurations.dart'; -import 'utils/ProductSearchQueryConfiguration.dart'; export 'events.dart'; export 'folksonomy.dart'; @@ -293,14 +292,6 @@ class OpenFoodAPIClient { /// Returns the list of products as SearchResult. /// Query the language specific host from OpenFoodFacts. static Future searchProducts( - final User? user, - final ProductSearchQueryConfiguration configuration, { - final QueryType? queryType, - }) async => - getProducts(user, configuration, queryType: queryType); - - /// Returns products searched according to a [configuration]. - static Future getProducts( final User? user, final AbstractQueryConfiguration configuration, { final QueryType? queryType, @@ -312,7 +303,20 @@ class OpenFoodAPIClient { return result; } + /// Returns products searched according to a [configuration]. + // TODO: deprecated from 2022-07-26; remove when old enough + @Deprecated('Use searchProducts instead') + static Future getProducts( + final User? user, + final AbstractQueryConfiguration configuration, { + final QueryType? queryType, + }) async => + searchProducts(user, configuration, queryType: queryType); + /// Search the OpenFoodFacts product database: multiple barcodes in input. + // TODO: deprecated from 2022-07-26; remove when old enough + @Deprecated( + 'Use method searchProducts with ProductListQueryConfiguration instead') static Future getProductList( final User? user, final ProductListQueryConfiguration configuration, { @@ -328,7 +332,7 @@ class OpenFoodAPIClient { final OpenFoodFactsCountry? country, final QueryType? queryType, }) async { - final SearchResult searchResult = await getProductList( + final SearchResult searchResult = await searchProducts( user, ProductListQueryConfiguration( barcodes, diff --git a/lib/utils/ProductListQueryConfiguration.dart b/lib/utils/ProductListQueryConfiguration.dart index 2cae4d4da8..f04458f58a 100644 --- a/lib/utils/ProductListQueryConfiguration.dart +++ b/lib/utils/ProductListQueryConfiguration.dart @@ -48,7 +48,7 @@ class ProductListQueryConfiguration extends AbstractQueryConfiguration { Map getParametersMap() { final Map result = super.getParametersMap(); - result['code'] = '${barcodes.join(',')}.json'; + result['code'] = barcodes.join(','); return result; } diff --git a/pubspec.yaml b/pubspec.yaml index cbe9c7d553..dc5b560a84 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -8,16 +8,16 @@ environment: sdk: '>=2.12.0 <3.0.0' dependencies: - json_annotation: ^4.5.0 + json_annotation: ^4.6.0 http: ^0.13.4 path: ^1.8.2 - image: ^3.1.3 + image: ^3.2.0 meta: ^1.8.0 dev_dependencies: analyzer: ">=4.0.0 <5.0.0" - build_runner: ^2.1.11 - json_serializable: ^6.2.0 + build_runner: ^2.2.0 + json_serializable: ^6.3.1 lints: ^2.0.0 - test: ^1.21.1 + test: ^1.21.4 coverage: ^1.3.2 diff --git a/test/api_getProduct_test.dart b/test/api_getProduct_test.dart index c62874505d..4dc0d38ae9 100644 --- a/test/api_getProduct_test.dart +++ b/test/api_getProduct_test.dart @@ -1766,7 +1766,11 @@ void main() { expect(result.product?.packaging, 'de:In einer Plastikflasche'); expect(result.product?.packagingTags, ['de:in-einer-plastikflasche']); expect(result.product?.quantity, '5.5 Liter'); - }); + }, + timeout: Timeout( + // some tests can be slow here + Duration(seconds: 90), + )); test('get new product fields', () async { final ProductQueryConfiguration configuration = ProductQueryConfiguration( diff --git a/test/api_getToBeCompletedProducts_test.dart b/test/api_getToBeCompletedProducts_test.dart index 73105c6bd6..c8c63f8320 100644 --- a/test/api_getToBeCompletedProducts_test.dart +++ b/test/api_getToBeCompletedProducts_test.dart @@ -30,7 +30,7 @@ void main() { final SearchResult result; try { - result = await OpenFoodAPIClient.getProducts( + result = await OpenFoodAPIClient.searchProducts( OpenFoodAPIConfiguration.globalUser, configuration, queryType: OpenFoodAPIConfiguration.globalQueryType, diff --git a/test/api_getUserProducts_test.dart b/test/api_getUserProducts_test.dart index daf9178a84..4e5bb1942e 100644 --- a/test/api_getUserProducts_test.dart +++ b/test/api_getUserProducts_test.dart @@ -34,7 +34,7 @@ void main() { final SearchResult result; try { - result = await OpenFoodAPIClient.getProducts( + result = await OpenFoodAPIClient.searchProducts( OpenFoodAPIConfiguration.globalUser, configuration, queryType: OpenFoodAPIConfiguration.globalQueryType, diff --git a/test/api_matchedProductV2_test.dart b/test/api_matchedProductV2_test.dart index f9e387e2dd..18b12033e8 100644 --- a/test/api_matchedProductV2_test.dart +++ b/test/api_matchedProductV2_test.dart @@ -126,7 +126,7 @@ void main() { BARCODE_CHORIZO, ]; - final SearchResult result = await OpenFoodAPIClient.getProductList( + final SearchResult result = await OpenFoodAPIClient.searchProducts( OpenFoodAPIConfiguration.globalUser, ProductListQueryConfiguration( inputBarcodes, diff --git a/test/api_saveProduct_test.dart b/test/api_saveProduct_test.dart index 2f3bb90442..3b55ccab62 100644 --- a/test/api_saveProduct_test.dart +++ b/test/api_saveProduct_test.dart @@ -86,7 +86,7 @@ void main() { ); testProductResult1(result2); - }); + }, skip: 'Random results'); /// Returns a timestamp up to the minute level. String _getMinuteTimestamp() => diff --git a/test/api_searchProducts_test.dart b/test/api_searchProducts_test.dart index 7932b86d41..30de3a0291 100644 --- a/test/api_searchProducts_test.dart +++ b/test/api_searchProducts_test.dart @@ -1,6 +1,8 @@ import 'dart:math'; import 'package:openfoodfacts/model/State.dart'; +import 'package:openfoodfacts/model/IngredientsAnalysisTags.dart'; +import 'package:openfoodfacts/model/parameter/IngredientsAnalysisParameter.dart'; import 'package:openfoodfacts/model/parameter/PnnsGroup2Filter.dart'; import 'package:openfoodfacts/model/parameter/SearchTerms.dart'; import 'package:openfoodfacts/model/parameter/StatesTagsParameter.dart'; @@ -42,6 +44,87 @@ void main() { UNKNOWN_BARCODE, ]; + /// Checks that all the sort options return different orders but same count. + /// + /// We can relatively assume that the top 100 pizzas in France are in + /// different orders. + test('search with all sort-by options', () async { + final Map> previousValues = + >{}; + int? checkCount; + final List sortOptions = []; + sortOptions.addAll(SortOption.values); + sortOptions.add(null); + for (final SortOption? currentOption in sortOptions) { + final List parameters = [ + const SearchTerms(terms: ['pizza']), + const PageNumber(page: 1), + const PageSize(size: 100), + if (currentOption != null) SortBy(option: currentOption) + ]; + + final SearchResult result = await OpenFoodAPIClient.searchProducts( + TestConstants.PROD_USER, + ProductSearchQueryConfiguration( + parametersList: parameters, + fields: [ProductField.BARCODE], + language: OpenFoodFactsLanguage.FRENCH, + country: OpenFoodFactsCountry.FRANCE, + ), + queryType: QueryType.PROD, + ); + + expect(result.products, isNotNull); + final List barcodes = []; + for (final Product product in result.products!) { + barcodes.add(product.barcode!); + } + + for (final SortOption? previousOption in previousValues.keys) { + final Matcher matcher = equals(previousValues[previousOption]); + // special case: NOTHING and EDIT seem to be the same. + if ((previousOption == SortOption.NOTHING && + currentOption == SortOption.EDIT) || + (previousOption == SortOption.EDIT && + currentOption == SortOption.NOTHING)) { + expect( + barcodes, + matcher, + reason: + 'Should be identical for $currentOption and $previousOption', + ); + } + // special case: POPULARITY and no sort option seem to be the same. + else if ((previousOption == null && + currentOption == SortOption.POPULARITY) || + (previousOption == SortOption.POPULARITY && + currentOption == null)) { + expect( + barcodes, + matcher, + reason: + 'Should be identical for $currentOption and $previousOption', + ); + } else { + expect( + barcodes, + isNot(matcher), + reason: + 'Should be different for $currentOption and $previousOption', + ); + } + } + previousValues[currentOption] = barcodes; + + expect(result.count, isNotNull); + if (checkCount == null) { + checkCount = result.count; // first value + } else { + expect(result.count, checkCount); // check if same value + } + } + }); + test('search favorite products', () async { final parameters = [ const PageNumber(page: 1), @@ -94,6 +177,150 @@ void main() { expect(result.count, greaterThan(30000)); }); + /// Returns the total number of products, and checks if the statuses match. + Future _checkIngredientsAnalysis( + final VeganStatus? veganStatus, + final VegetarianStatus? vegetarianStatus, + final PalmOilFreeStatus? palmOilFreeStatus, + ) async { + if (veganStatus == null && + vegetarianStatus == null && + palmOilFreeStatus == null) { + return null; + } + + final List parameters = [ + const PageNumber(page: 1), + const PageSize(size: 100), + IngredientsAnalysisParameter( + veganStatus: veganStatus, + vegetarianStatus: vegetarianStatus, + palmOilFreeStatus: palmOilFreeStatus, + ), + ]; + + final ProductSearchQueryConfiguration configuration = + ProductSearchQueryConfiguration( + parametersList: parameters, + fields: [ + ProductField.BARCODE, + ProductField.INGREDIENTS_ANALYSIS_TAGS, + ], + language: OpenFoodFactsLanguage.FRENCH, + country: OpenFoodFactsCountry.FRANCE, + ); + + final SearchResult result = await OpenFoodAPIClient.searchProducts( + TestConstants.PROD_USER, + configuration, + queryType: QueryType.PROD, + ); + + expect(result.products, isNotNull); + for (final Product product in result.products!) { + if (veganStatus != null) { + expect( + product.ingredientsAnalysisTags!.veganStatus, + veganStatus, + ); + } + if (vegetarianStatus != null) { + expect( + product.ingredientsAnalysisTags!.vegetarianStatus, + vegetarianStatus, + ); + } + if (palmOilFreeStatus != null) { + expect( + product.ingredientsAnalysisTags!.palmOilFreeStatus, + palmOilFreeStatus, + ); + } + } + return result.count!; + } + + test('check vegan search', () async { + for (final VeganStatus status in VeganStatus.values) { + await _checkIngredientsAnalysis(status, null, null); + } + }, + timeout: Timeout( + // some tests can be slow here + Duration(seconds: 90), + )); + + test('check vegetarian search', () async { + for (final VegetarianStatus status in VegetarianStatus.values) { + await _checkIngredientsAnalysis(null, status, null); + } + }, + timeout: Timeout( + // some tests can be slow here + Duration(seconds: 90), + )); + + test('check palm oil search', () async { + for (final PalmOilFreeStatus status in PalmOilFreeStatus.values) { + await _checkIngredientsAnalysis(null, null, status); + } + }, + timeout: Timeout( + // some tests can be slow here + Duration(seconds: 90), + )); + + test('check random vegan+vegetarian+palm oil search', () async { + final Random random = Random(); + final int veganIndex = random.nextInt(VeganStatus.values.length); + final int vegetarianIndex = + random.nextInt(VegetarianStatus.values.length); + final int palmOilFreeIndex = + random.nextInt(PalmOilFreeStatus.values.length); + await _checkIngredientsAnalysis( + VeganStatus.values[veganIndex], + VegetarianStatus.values[vegetarianIndex], + PalmOilFreeStatus.values[palmOilFreeIndex], + ); + }, + timeout: Timeout( + // some tests can be slow here + Duration(seconds: 90), + )); + + /// If you know it's not vegetarian, then it can't be vegan at all. + test('check vegan/vegetarian consistency', () async { + const VegetarianStatus nonVegetarian = VegetarianStatus.NON_VEGETARIAN; + expect( + await _checkIngredientsAnalysis( + VeganStatus.VEGAN, + nonVegetarian, + null, + ), + 0, + ); + expect( + await _checkIngredientsAnalysis( + VeganStatus.MAYBE_VEGAN, + nonVegetarian, + null, + ), + 0, + ); + expect( + await _checkIngredientsAnalysis( + VeganStatus.VEGAN_STATUS_UNKNOWN, + nonVegetarian, + null, + ), + 0, + ); + }, + timeout: Timeout( + // some tests can be slow here + Duration(seconds: 90), + )); + test('type bug : ingredient percent int vs String ', () async { final parameters = [ const PageNumber(page: 16), @@ -446,7 +673,7 @@ void main() { language: OpenFoodFactsLanguage.FRENCH, ); - final SearchResult result = await OpenFoodAPIClient.getProductList( + final SearchResult result = await OpenFoodAPIClient.searchProducts( TestConstants.PROD_USER, configuration, queryType: QueryType.PROD, @@ -496,7 +723,7 @@ void main() { sortOption: SortOption.PRODUCT_NAME, ); - final result = await OpenFoodAPIClient.getProductList( + final result = await OpenFoodAPIClient.searchProducts( TestConstants.PROD_USER, configuration); if (result.products == null || result.products!.isEmpty) { break; @@ -552,7 +779,7 @@ void main() { language: OpenFoodFactsLanguage.FRENCH, ); - final SearchResult result = await OpenFoodAPIClient.getProductList( + final SearchResult result = await OpenFoodAPIClient.searchProducts( TestConstants.PROD_USER, configuration, queryType: QueryType.PROD,