diff --git a/lib/src/open_food_search_api_client.dart b/lib/src/open_food_search_api_client.dart index 0ee6d1163d..2e68a10e7a 100644 --- a/lib/src/open_food_search_api_client.dart +++ b/lib/src/open_food_search_api_client.dart @@ -23,6 +23,9 @@ class OpenFoodSearchAPIClient { static String _getHost(final UriProductHelper uriHelper) => uriHelper.getHost(_subdomain); + /// Returns a list of suggestions. + /// + /// /!\ For brands, language must be English. static Future autocomplete({ required String query, required final List taxonomyNames, diff --git a/lib/src/search/fuzziness_level.dart b/lib/src/search/fuzziness_level.dart index ae2456ae87..6852170a79 100644 --- a/lib/src/search/fuzziness_level.dart +++ b/lib/src/search/fuzziness_level.dart @@ -1,9 +1,13 @@ import 'package:openfoodfacts/src/model/off_tagged.dart'; /// Fuzziness Level for Elastic Search API. +/// +/// Levenshtein distance (= number of edits). +/// cf. https://www.elastic.co/guide/en/elasticsearch/reference/current/common-options.html#fuzziness enum Fuzziness implements OffTagged { - // TODO(monsieurtanuki): introduce other values when available, like 1 and 2. - none(offTag: '0'); + none(offTag: '0'), + one(offTag: '1'), + two(offTag: '2'); const Fuzziness({ required this.offTag, diff --git a/lib/src/search/taxonomy_name.dart b/lib/src/search/taxonomy_name.dart index 5dc36c0ee9..8d0882dce0 100644 --- a/lib/src/search/taxonomy_name.dart +++ b/lib/src/search/taxonomy_name.dart @@ -1,9 +1,37 @@ import '../model/off_tagged.dart'; /// Taxonomy Name for Elastic Search API. +/// +/// cf. https://github.com/openfoodfacts/search-a-licious/blob/main/data/config/openfoodfacts.yml enum TaxonomyName implements OffTagged { - // TODO(monsieurtanuki): add other values when available. - category(offTag: 'category'); + category(offTag: 'category'), + label(offTag: 'label'), + additive(offTag: 'additive'), + allergen(offTag: 'allergen'), + aminoAcid(offTag: 'amino_acid'), + country(offTag: 'country'), + dataQuality(offTag: 'data_quality'), + foodGroup(offTag: 'food_group'), + improvement(offTag: 'improvement'), + ingredient(offTag: 'ingredient'), + ingredientAnalysis(offTag: 'ingredients_analysis'), + ingredientProcessing(offTag: 'ingredients_processing'), + language(offTag: 'language'), + mineral(offTag: 'mineral'), + misc(offTag: 'misc'), + novaGroup(offTag: 'nova_group'), + nucleotide(offTag: 'nucleotide'), + nutrient(offTag: 'nutrient'), + origin(offTag: 'origin'), + otherNutritionalSubstance(offTag: 'other_nutritional_substance'), + packagingMaterial(offTag: 'packaging_material'), + packagingRecycling(offTag: 'packaging_recycling'), + packagingShape(offTag: 'packaging_shape'), + periodsAfterOpening(offTag: 'periods_after_opening'), + preservation(offTag: 'preservation'), + state(offTag: 'state'), + vitamin(offTag: 'vitamin'), + brand(offTag: 'brand'); const TaxonomyName({ required this.offTag, diff --git a/test/api_search_test.dart b/test/api_search_test.dart index 12efe34c13..06e340a9b4 100644 --- a/test/api_search_test.dart +++ b/test/api_search_test.dart @@ -12,25 +12,30 @@ void main() { group( '$OpenFoodSearchAPIClient autocomplete', () { - const int size = 5; - const TaxonomyName taxonomyName = TaxonomyName.category; + const int maxSize = 5; const OpenFoodFactsLanguage language = OpenFoodFactsLanguage.FRENCH; + void basicTest(final AutocompleteSearchResult result) { + expect(result.took, greaterThanOrEqualTo(0)); + expect(result.timedOut, false); + expect(result.options, isNotNull); + expect(result.options!.length, lessThanOrEqualTo(maxSize)); + } + test( - 'category with existing products', + 'category with existing matches', () async { + const TaxonomyName taxonomyName = TaxonomyName.category; final AutocompleteSearchResult result = await OpenFoodSearchAPIClient.autocomplete( query: 'pizza', taxonomyNames: [taxonomyName], language: language, uriHelper: uriHelper, - size: size, + size: maxSize, ); - expect(result.took, greaterThanOrEqualTo(0)); - expect(result.timedOut, false); - expect(result.options, isNotNull); - expect(result.options!.length, size); + basicTest(result); + expect(result.options, hasLength(maxSize)); for (final AutocompleteSingleResult item in result.options!) { expect(item.id, contains(':')); expect(item.taxonomyName, taxonomyName); @@ -39,22 +44,135 @@ void main() { ); test( - 'category with non existing products', + 'category with non existing matches', () async { + const TaxonomyName taxonomyName = TaxonomyName.category; final AutocompleteSearchResult result = await OpenFoodSearchAPIClient.autocomplete( query: 'pifsehjfsjkvnskjvbehjszza', taxonomyNames: [taxonomyName], language: language, uriHelper: uriHelper, - size: size, + size: maxSize, ); - expect(result.took, greaterThanOrEqualTo(0)); - expect(result.timedOut, false); - expect(result.options, isNotNull); + basicTest(result); expect(result.options, isEmpty); }, ); + + test( + 'all fuzziness levels', + () async { + const TaxonomyName taxonomyName = TaxonomyName.country; + const String expectedValue = 'France'; + const Map inputs = { + expectedValue: Fuzziness.none, + 'Franse': Fuzziness.one, + 'Frense': Fuzziness.two, + }; + for (final String inputValue in inputs.keys) { + final AutocompleteSearchResult result = + await OpenFoodSearchAPIClient.autocomplete( + // possibly with a typo + query: inputValue, + taxonomyNames: [taxonomyName], + language: language, + uriHelper: uriHelper, + size: maxSize, + // supposed to fix the typo (if relevant) + fuzziness: inputs[inputValue]!, + ); + basicTest(result); + expect(result.options, isNotEmpty); + bool found = false; + for (final AutocompleteSingleResult item in result.options!) { + expect(item.id, contains(':')); + expect(item.taxonomyName, taxonomyName); + if (item.text == expectedValue) { + found = true; + } + } + expect(found, isTrue); + } + }, + ); + + Future simpleTest( + final TaxonomyName taxonomyName, + final String query, + final String expectedValue, { + final OpenFoodFactsLanguage language = OpenFoodFactsLanguage.FRENCH, + }) async { + final AutocompleteSearchResult result = + await OpenFoodSearchAPIClient.autocomplete( + query: query, + taxonomyNames: [taxonomyName], + language: language, + uriHelper: uriHelper, + size: maxSize, + fuzziness: Fuzziness.none, + ); + basicTest(result); + expect(result.options, isNotEmpty); + bool found = false; + for (final AutocompleteSingleResult item in result.options!) { + expect(item.id, contains(':')); + expect(item.taxonomyName, taxonomyName); + if (item.text == expectedValue) { + found = true; + } + } + expect(found, isTrue); + } + + test( + 'all taxonomy names', + () async { + await simpleTest(TaxonomyName.category, 'sky', 'Skyr nature'); + await simpleTest(TaxonomyName.label, 'fsc', 'FSC Mix'); + await simpleTest(TaxonomyName.additive, 'E10', 'E104'); + await simpleTest(TaxonomyName.allergen, 'mouta', 'moutarde'); + await simpleTest(TaxonomyName.aminoAcid, 'L-argin', 'L-arginine'); + await simpleTest(TaxonomyName.country, 'fra', 'France'); + await simpleTest( + TaxonomyName.dataQuality, + 'Valeur nutritionnelle 3800', + 'Valeur nutritionnelle supérieure à 3800 - Energie'); + await simpleTest( + TaxonomyName.foodGroup, 'fromage per', 'Fromage persillé'); + await simpleTest(TaxonomyName.improvement, 'Nutrition - Hau', + 'Nutrition - Haut taux de sel pour la catégorie'); + await simpleTest( + TaxonomyName.ingredient, 'fromage bla', 'Fromage blanc'); + await simpleTest( + TaxonomyName.ingredientAnalysis, 'végé', 'Végétarien'); + await simpleTest( + TaxonomyName.ingredientProcessing, 'enri', 'enrichi'); + await simpleTest(TaxonomyName.language, 'fran', 'français'); + await simpleTest(TaxonomyName.mineral, 'zi', 'Zinc'); + await simpleTest( + TaxonomyName.misc, 'nutriscore', 'NutriScore - Calculé'); + await simpleTest( + TaxonomyName.novaGroup, 'aliments', 'Aliments transformés'); + await simpleTest(TaxonomyName.nucleotide, 'adénosine mon', + 'Adénosine monophosphate'); + await simpleTest(TaxonomyName.nutrient, 'prot', 'Protéine'); + await simpleTest(TaxonomyName.origin, 'prove', 'Provence'); + await simpleTest( + TaxonomyName.otherNutritionalSubstance, 'chol', 'Choline'); + await simpleTest(TaxonomyName.packagingMaterial, 'verr', 'Verre'); + await simpleTest( + TaxonomyName.packagingRecycling, 'compost', 'Compostable'); + await simpleTest(TaxonomyName.packagingShape, 'boute', 'Bouteille'); + await simpleTest( + TaxonomyName.periodsAfterOpening, '2 jours', '21 jours'); + await simpleTest(TaxonomyName.preservation, 'fra', 'Frais'); + await simpleTest(TaxonomyName.state, 'emball', 'Emballage complété'); + await simpleTest(TaxonomyName.vitamin, 'b', 'b12'); + await simpleTest(TaxonomyName.brand, 'carref', 'Carrefour', + language: OpenFoodFactsLanguage.ENGLISH); + }, + ); }, ); }