Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: 620 - autocomplete for all taxonomy names and fuzziness levels #835

Merged
merged 2 commits into from
Nov 27, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions lib/src/open_food_search_api_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<AutocompleteSearchResult> autocomplete({
required String query,
required final List<TaxonomyName> taxonomyNames,
Expand Down
8 changes: 6 additions & 2 deletions lib/src/search/fuzziness_level.dart
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
32 changes: 30 additions & 2 deletions lib/src/search/taxonomy_name.dart
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
144 changes: 131 additions & 13 deletions test/api_search_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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>[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);
Expand All @@ -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>[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<String, Fuzziness> inputs = <String, Fuzziness>{
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>[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<void> 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>[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);
},
);
},
);
}