diff --git a/lib/ProductOpener/APIProductServices.pm b/lib/ProductOpener/APIProductServices.pm
index 5e237833b239b..0c9bf634b553d 100644
--- a/lib/ProductOpener/APIProductServices.pm
+++ b/lib/ProductOpener/APIProductServices.pm
@@ -116,6 +116,8 @@ my %service_functions = (
extend_ingredients => \&ProductOpener::Ingredients::extend_ingredients_service,
estimate_ingredients_percent => \&ProductOpener::Ingredients::estimate_ingredients_percent_service,
analyze_ingredients => \&ProductOpener::Ingredients::analyze_ingredients_service,
+ determine_food_contact_of_packaging_components =>
+ \&ProductOpener::PackagingFoodContact::determine_food_contact_of_packaging_components_service,
);
sub check_product_services_api_input ($request_ref) {
diff --git a/lib/ProductOpener/Display.pm b/lib/ProductOpener/Display.pm
index 146eabab55709..184d832e6a3b5 100644
--- a/lib/ProductOpener/Display.pm
+++ b/lib/ProductOpener/Display.pm
@@ -174,6 +174,7 @@ use ProductOpener::Cache qw/$max_memcached_object_size $memd generate_cache_key/
use ProductOpener::Permissions qw/has_permission/;
use ProductOpener::ProductsFeatures qw(feature_enabled);
use ProductOpener::RequestStats qw(:all);
+use ProductOpener::PackagingFoodContact qw/determine_food_contact_of_packaging_components_service/;
use Encode;
use URI::Escape::XS;
@@ -7978,6 +7979,13 @@ JS
$product_ref->{environmental_score_data});
}
+ # 2025/01 - For moderators, determine which packaging components are in contact with food, so that we can display them
+ # This is for initial development of the feature, once finalized, we could compute and store this data in the product
+
+ if ($User{moderator}) {
+ ProductOpener::PackagingFoodContact::determine_food_contact_of_packaging_components_service($product_ref);
+ }
+
# Activate knowledge panels for all users
initialize_knowledge_panels_options($knowledge_panels_options_ref, $request_ref);
diff --git a/lib/ProductOpener/PackagingFoodContact.pm b/lib/ProductOpener/PackagingFoodContact.pm
new file mode 100644
index 0000000000000..155fdbbed4d16
--- /dev/null
+++ b/lib/ProductOpener/PackagingFoodContact.pm
@@ -0,0 +1,271 @@
+# This file is part of Product Opener.
+#
+# Product Opener
+# Copyright (C) 2011-2023 Association Open Food Facts
+# Contact: contact@openfoodfacts.org
+# Address: 21 rue des Iles, 94100 Saint-Maur des Fossés, France
+#
+# Product Opener is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+=encoding UTF-8
+
+=head1 NAME
+
+ProductOpener::PackagingFoodContact
+
+=head1 SYNOPSIS
+
+
+=head1 DESCRIPTION
+
+
+=cut
+
+package ProductOpener::PackagingFoodContact;
+
+use ProductOpener::PerlStandards;
+use Exporter qw< import >;
+
+use Log::Any qw($log);
+
+BEGIN {
+ use vars qw(@ISA @EXPORT_OK %EXPORT_TAGS);
+ @EXPORT_OK = qw(
+ &determine_food_contact_of_packaging_components_service
+ &determine_food_contact_of_packaging_components
+ ); # symbols to export on request
+ %EXPORT_TAGS = (all => [@EXPORT_OK]);
+}
+
+use vars @EXPORT_OK;
+
+use ProductOpener::Config qw/:all/;
+use ProductOpener::API qw/add_warning/;
+use ProductOpener::Packaging qw/%packaging_taxonomies/;
+use ProductOpener::Tags qw/:all/;
+
+use Data::DeepAccess qw(deep_get deep_val);
+use List::Util qw(first);
+
+=head1 FUNCTIONS
+
+=head2 determine_food_contact_of_packaging_components_service ( $product_ref, $updated_product_fields_ref, $errors_ref )
+
+Determine if packaging components are in contact with the food.
+
+This function is a product service that can be run through ProductOpener::ApiProductServices
+
+=head3 Arguments
+
+=head4 $product_ref
+
+product object reference
+
+=head4 $updated_product_fields_ref
+
+reference to a hash of product fields that have been created or updated
+
+=head4 $errors_ref
+
+reference to an array of error messages
+
+=cut
+
+sub determine_food_contact_of_packaging_components_service (
+ $product_ref,
+ $updated_product_fields_ref = {},
+ $errors_ref = []
+ )
+{
+
+ # Check if we have packaging data in the packagings structure
+ my $packagings_ref = $product_ref->{packagings};
+
+ if (not defined $packagings_ref) {
+ push @{$errors_ref},
+ {
+ message => {id => "missing_field"},
+ field => {
+ id => "packagings",
+ impact => {id => "skipped_service"},
+ service => {id => "determine_food_contact_of_packaging_components"}
+ }
+ };
+ return;
+ }
+
+ # indicate that the service is updating the "packagings" structure
+ $updated_product_fields_ref->{packagings} = 1;
+
+ determine_food_contact_of_packaging_components($packagings_ref);
+
+ return;
+}
+
+=head2 get_matching_and_non_matching_packaging_components ($packagings_ref, $conditions_ref)
+
+Find packaging components that match specific conditions (e.g material, shape, recycling).
+Conditions are matched using the taxonomy, a parent in the condition matches more specific children.
+(e.g. "en:plastic" matches "en:pet" and "en:hdpe")
+
+=head3 Parameters
+
+=head4 $packaging_ref packaging data
+
+=head4 $conditions_ref conditions
+
+Hash reference with conditions like material, shape, recycling as keys, and a value or an array of values as values.
+
+=head3 Return values
+
+Array with:
+- a reference to an array of packaging components that match the conditions,
+- a reference to an array of packaging components that do not match the conditions.
+
+=cut
+
+sub get_matching_and_non_matching_packaging_components ($packagings_ref, $conditions_ref) {
+
+ my @matching_packagings = ();
+ my @non_matching_packagings = ();
+
+ foreach my $packaging_ref (@$packagings_ref) {
+
+ my $matched = 1;
+
+ foreach my $property (keys %$conditions_ref) {
+
+ if (defined $packaging_ref->{$property}) {
+
+ # Check if the component value is a child of one of the condition values
+ my @values
+ = ref $conditions_ref->{$property} eq 'ARRAY'
+ ? @{$conditions_ref->{$property}}
+ : ($conditions_ref->{$property});
+ my $matched_value = 0;
+
+ foreach my $value (@values) {
+ if (is_a($packaging_taxonomies{$property}, $value, $packaging_ref->{$property})) {
+ $matched_value = 1;
+ last;
+ }
+ }
+ if (not $matched_value) {
+ $matched = 0;
+ last;
+ }
+ }
+ else {
+ $matched = 0;
+ last;
+ }
+ }
+
+ if ($matched) {
+ push @matching_packagings, $packaging_ref;
+ }
+ else {
+ push @non_matching_packagings, $packaging_ref;
+ }
+ }
+
+ return \@matching_packagings, \@non_matching_packagings;
+}
+
+sub set_food_contact_property_of_packaging_components ($packagings_ref, $food_contact) {
+
+ foreach my $packaging_ref (@$packagings_ref) {
+
+ $packaging_ref->{food_contact} = $food_contact;
+ }
+
+ return;
+}
+
+=head2 determine_food_contact_of_packaging_components ($packagings_ref)
+
+Determine if packaging components are in contact with the food.
+
+=head3 Parameters
+
+=head4 $packagings_ref packaging data
+
+=cut
+
+sub determine_food_contact_of_packaging_components ($packagings_ref) {
+
+ # Cans: only the can itself is in contact with the food
+ my ($cans_ref, $non_cans_ref)
+ = get_matching_and_non_matching_packaging_components($packagings_ref, {shape => "en:can"});
+ if (@$cans_ref) {
+ set_food_contact_property_of_packaging_components($cans_ref, 1);
+ set_food_contact_property_of_packaging_components($non_cans_ref, 0);
+ return;
+ }
+
+ # Bottles, pots, jars: in contact with the food
+ my ($bottles_ref, $non_bottles_ref)
+ = get_matching_and_non_matching_packaging_components($packagings_ref,
+ {shape => ["en:bottle", "en:pot", "en:jar"]});
+ if (@$bottles_ref) {
+ set_food_contact_property_of_packaging_components($bottles_ref, 1);
+ set_food_contact_property_of_packaging_components($non_bottles_ref, 0);
+
+ # If there is a seal, it is in contact with the food
+ my ($seals_ref, $non_seals_ref)
+ = get_matching_and_non_matching_packaging_components($non_bottles_ref, {shape => "en:seal"});
+ if (@$seals_ref) {
+ set_food_contact_property_of_packaging_components($seals_ref, 1);
+ }
+ # Otherwise, if there is a lid or a cap, it is in contact wit the food
+ else {
+ my ($lids_ref, $non_lids_ref)
+ = get_matching_and_non_matching_packaging_components($non_bottles_ref, {shape => ["en:lid", "en:cap"]});
+ if (@$lids_ref) {
+ set_food_contact_property_of_packaging_components($lids_ref, 1);
+ }
+ }
+ return;
+ }
+
+ # Trays: in contact with food, and the film is in contact with the food
+ my ($trays_ref, $non_trays_ref)
+ = get_matching_and_non_matching_packaging_components($packagings_ref, {shape => "en:tray"});
+ if (@$trays_ref) {
+ set_food_contact_property_of_packaging_components($trays_ref, 1);
+ set_food_contact_property_of_packaging_components($non_trays_ref, 0);
+
+ my ($films_ref, $non_films_ref)
+ = get_matching_and_non_matching_packaging_components($non_trays_ref, {shape => "en:film"});
+ if (@$films_ref) {
+ set_food_contact_property_of_packaging_components($films_ref, 1);
+ }
+ return;
+ }
+
+ # Individual packaging components (dose, bag): in contact with the food
+ my ($individuals_ref, $non_individuals_ref)
+ = get_matching_and_non_matching_packaging_components($packagings_ref,
+ {shape => ["en:individual-dose", "en:individual-bag"]});
+ if (@$individuals_ref) {
+ set_food_contact_property_of_packaging_components($individuals_ref, 1);
+ set_food_contact_property_of_packaging_components($non_individuals_ref, 0);
+ return;
+ }
+
+ return;
+}
+
+1;
+
diff --git a/po/common/common.pot b/po/common/common.pot
index 65d9785c44c34..9a3e749a73f0d 100644
--- a/po/common/common.pot
+++ b/po/common/common.pot
@@ -7369,3 +7369,7 @@ msgstr "Limiting ultra-processed foods reduces the risk of noncommunicable chron
msgctxt "recommendation_limit_ultra_processed_foods_text"
msgid "Several studies have found that a lower consumption of ultra-processed foods is associated with a reduced risk of noncommunicable chronic diseases, such as obesity, hypertension and diabetes."
msgstr "Several studies have found that a lower consumption of ultra-processed foods is associated with a reduced risk of noncommunicable chronic diseases, such as obesity, hypertension and diabetes."
+
+msgctxt "in_contact_with_food"
+msgid "In contact with food"
+msgstr "In contact with food"
diff --git a/po/common/en.po b/po/common/en.po
index cd921dd5e6a5e..ca82939b4500a 100644
--- a/po/common/en.po
+++ b/po/common/en.po
@@ -7358,3 +7358,7 @@ msgstr "Limiting ultra-processed foods reduces the risk of noncommunicable chron
msgctxt "recommendation_limit_ultra_processed_foods_text"
msgid "Several studies have found that a lower consumption of ultra-processed foods is associated with a reduced risk of noncommunicable chronic diseases, such as obesity, hypertension and diabetes."
msgstr "Several studies have found that a lower consumption of ultra-processed foods is associated with a reduced risk of noncommunicable chronic diseases, such as obesity, hypertension and diabetes."
+
+msgctxt "in_contact_with_food"
+msgid "In contact with food"
+msgstr "In contact with food"
diff --git a/po/common/fr.po b/po/common/fr.po
index d0a8c67bb92f2..3a35aea0ac827 100644
--- a/po/common/fr.po
+++ b/po/common/fr.po
@@ -7317,3 +7317,7 @@ msgstr "Limiter les aliments ultra-transformés réduit le risque de maladies ch
msgctxt "recommendation_limit_ultra_processed_foods_text"
msgid "Several studies have found that a lower consumption of ultra-processed foods is associated with a reduced risk of noncommunicable chronic diseases, such as obesity, hypertension and diabetes."
msgstr "Plusieurs études ont montré qu'une consommation plus faible d'aliments ultra-transformés est associée à un risque diminué de maladies chroniques non transmissibles, telles que l'obésité, l'hypertension et le diabète."
+
+msgctxt "in_contact_with_food"
+msgid "In contact with food"
+msgstr "En contact avec l'aliment"
diff --git a/templates/api/knowledge-panels/environment/packaging_components.tt.json b/templates/api/knowledge-panels/environment/packaging_components.tt.json
index f491cae82f403..bfe9315e9466d 100644
--- a/templates/api/knowledge-panels/environment/packaging_components.tt.json
+++ b/templates/api/knowledge-panels/environment/packaging_components.tt.json
@@ -53,6 +53,9 @@
[% IF packaging.material %]
([% display_taxonomy_tag_name('packaging_materials',packaging.material) %][% IF packaging.weight_specified %][% sep %]: [% packaging.weight_specified %] g[% ELSIF packaging.weight_measured %][% sep %]: [% packaging.weight_measured %] g[% END %])
[% END %]
+ [% IF packaging.food_contact %]
+ - [% edq(lang('in_contact_with_food')) %]
+ [% END %]
[% END %]
[% END %]
diff --git a/tests/integration/api_v3_product_services.t b/tests/integration/api_v3_product_services.t
index eae5f9792a7e2..0b742195a1244 100644
--- a/tests/integration/api_v3_product_services.t
+++ b/tests/integration/api_v3_product_services.t
@@ -173,6 +173,35 @@ JSON
"ingredients_text_fr": "sucre, huile de palme, eau, quelque chose d'inconnu"
}
}
+JSON
+ },
+
+ # determine_food_contact_of_packaging_components
+ {
+ test_case => 'determine-food-contact-of-packaging-components-service',
+ method => 'POST',
+ path => '/api/v3/product_services',
+ body => <\n Box\n \n \n \n (Wood)\n \n
\n \n \n \n \n \n 3 x \n \n Lid\n \n \n \n (Steel)\n \n
\n \n \n \n \n ",
+ "html" : "\n \n \n 1 x \n \n Box\n \n \n \n (Wood)\n \n \n
\n \n \n \n \n \n 3 x \n \n Lid\n \n \n \n (Steel)\n \n \n
\n \n \n \n \n ",
"icon_alt" : "Recycle",
"icon_color_from_evaluation" : true,
"icon_url" : "http://static.openfoodfacts.localhost/images/icons/dist/recycle-variant.svg",
@@ -2453,7 +2453,7 @@
"element_type" : "text",
"text_element" : {
"evaluation" : "bad",
- "html" : "\n \n \n \n \n \n \n \n \n 1 x \n \n Film\n \n \n \n (Plastic)\n \n
\n \n \n ",
+ "html" : "\n \n \n \n \n \n \n \n \n 1 x \n \n Film\n \n \n \n (Plastic)\n \n \n
\n \n \n ",
"icon_alt" : "Discard",
"icon_color_from_evaluation" : true,
"icon_url" : "http://static.openfoodfacts.localhost/images/icons/dist/delete.svg",
@@ -2464,7 +2464,7 @@
"element_type" : "text",
"text_element" : {
"evaluation" : "neutral",
- "html" : "\n \n \n \n \n 6 x \n \n Bottle\n 25cl \n \n \n (Glass)\n \n
\n \n \n \n \n \n \n ",
+ "html" : "\n \n \n \n \n 6 x \n \n Bottle\n 25cl \n \n \n (Glass)\n \n \n
\n \n \n \n \n \n \n ",
"icon_alt" : "Unknown",
"icon_color_from_evaluation" : true,
"icon_url" : "http://static.openfoodfacts.localhost/images/icons/dist/help.svg",
diff --git a/tests/integration/expected_test_results/api_v2_product_read/get-fields-attribute-groups-all-knowledge-panels.json b/tests/integration/expected_test_results/api_v2_product_read/get-fields-attribute-groups-all-knowledge-panels.json
index be6ff7158b515..30c9009a30b64 100644
--- a/tests/integration/expected_test_results/api_v2_product_read/get-fields-attribute-groups-all-knowledge-panels.json
+++ b/tests/integration/expected_test_results/api_v2_product_read/get-fields-attribute-groups-all-knowledge-panels.json
@@ -3096,7 +3096,7 @@
"element_type" : "text",
"text_element" : {
"evaluation" : "good",
- "html" : "\n \n \n 1 x \n \n Box\n \n \n \n (Wood)\n \n
\n \n \n \n \n \n 3 x \n \n Lid\n \n \n \n (Steel)\n \n
\n \n \n \n \n ",
+ "html" : "\n \n \n 1 x \n \n Box\n \n \n \n (Wood)\n \n \n
\n \n \n \n \n \n 3 x \n \n Lid\n \n \n \n (Steel)\n \n \n
\n \n \n \n \n ",
"icon_alt" : "Recycle",
"icon_color_from_evaluation" : true,
"icon_url" : "http://static.openfoodfacts.localhost/images/icons/dist/recycle-variant.svg",
@@ -3107,7 +3107,7 @@
"element_type" : "text",
"text_element" : {
"evaluation" : "bad",
- "html" : "\n \n \n \n \n \n \n \n \n 1 x \n \n Film\n \n \n \n (Plastic)\n \n
\n \n \n ",
+ "html" : "\n \n \n \n \n \n \n \n \n 1 x \n \n Film\n \n \n \n (Plastic)\n \n \n
\n \n \n ",
"icon_alt" : "Discard",
"icon_color_from_evaluation" : true,
"icon_url" : "http://static.openfoodfacts.localhost/images/icons/dist/delete.svg",
@@ -3118,7 +3118,7 @@
"element_type" : "text",
"text_element" : {
"evaluation" : "neutral",
- "html" : "\n \n \n \n \n 6 x \n \n Bottle\n 25cl \n \n \n (Glass)\n \n
\n \n \n \n \n \n \n ",
+ "html" : "\n \n \n \n \n 6 x \n \n Bottle\n 25cl \n \n \n (Glass)\n \n \n
\n \n \n \n \n \n \n ",
"icon_alt" : "Unknown",
"icon_color_from_evaluation" : true,
"icon_url" : "http://static.openfoodfacts.localhost/images/icons/dist/help.svg",
diff --git a/tests/integration/expected_test_results/api_v2_product_read/get-fields-knowledge-panels-knowledge-panels_included-health_card-environment_card-knowledge_panels_excluded-health_card.json b/tests/integration/expected_test_results/api_v2_product_read/get-fields-knowledge-panels-knowledge-panels_included-health_card-environment_card-knowledge_panels_excluded-health_card.json
index a6802afbeb851..a89b615a3acf0 100644
--- a/tests/integration/expected_test_results/api_v2_product_read/get-fields-knowledge-panels-knowledge-panels_included-health_card-environment_card-knowledge_panels_excluded-health_card.json
+++ b/tests/integration/expected_test_results/api_v2_product_read/get-fields-knowledge-panels-knowledge-panels_included-health_card-environment_card-knowledge_panels_excluded-health_card.json
@@ -604,7 +604,7 @@
"element_type" : "text",
"text_element" : {
"evaluation" : "good",
- "html" : "\n \n \n 1 x \n \n Box\n \n \n \n (Wood)\n \n
\n \n \n \n \n \n 3 x \n \n Lid\n \n \n \n (Steel)\n \n
\n \n \n \n \n ",
+ "html" : "\n \n \n 1 x \n \n Box\n \n \n \n (Wood)\n \n \n
\n \n \n \n \n \n 3 x \n \n Lid\n \n \n \n (Steel)\n \n \n
\n \n \n \n \n ",
"icon_alt" : "Recycle",
"icon_color_from_evaluation" : true,
"icon_url" : "http://static.openfoodfacts.localhost/images/icons/dist/recycle-variant.svg",
@@ -615,7 +615,7 @@
"element_type" : "text",
"text_element" : {
"evaluation" : "bad",
- "html" : "\n \n \n \n \n \n \n \n \n 1 x \n \n Film\n \n \n \n (Plastic)\n \n
\n \n \n ",
+ "html" : "\n \n \n \n \n \n \n \n \n 1 x \n \n Film\n \n \n \n (Plastic)\n \n \n
\n \n \n ",
"icon_alt" : "Discard",
"icon_color_from_evaluation" : true,
"icon_url" : "http://static.openfoodfacts.localhost/images/icons/dist/delete.svg",
@@ -626,7 +626,7 @@
"element_type" : "text",
"text_element" : {
"evaluation" : "neutral",
- "html" : "\n \n \n \n \n 6 x \n \n Bottle\n 25cl \n \n \n (Glass)\n \n
\n \n \n \n \n \n \n ",
+ "html" : "\n \n \n \n \n 6 x \n \n Bottle\n 25cl \n \n \n (Glass)\n \n \n
\n \n \n \n \n \n \n ",
"icon_alt" : "Unknown",
"icon_color_from_evaluation" : true,
"icon_url" : "http://static.openfoodfacts.localhost/images/icons/dist/help.svg",
diff --git a/tests/integration/expected_test_results/api_v2_product_read/get-fields-knowledge-panels-knowledge-panels_included-health_card-environment_card.json b/tests/integration/expected_test_results/api_v2_product_read/get-fields-knowledge-panels-knowledge-panels_included-health_card-environment_card.json
index 504e0d66d323b..50f76992c4e91 100644
--- a/tests/integration/expected_test_results/api_v2_product_read/get-fields-knowledge-panels-knowledge-panels_included-health_card-environment_card.json
+++ b/tests/integration/expected_test_results/api_v2_product_read/get-fields-knowledge-panels-knowledge-panels_included-health_card-environment_card.json
@@ -1616,7 +1616,7 @@
"element_type" : "text",
"text_element" : {
"evaluation" : "good",
- "html" : "\n \n \n 1 x \n \n Box\n \n \n \n (Wood)\n \n
\n \n \n \n \n \n 3 x \n \n Lid\n \n \n \n (Steel)\n \n
\n \n \n \n \n ",
+ "html" : "\n \n \n 1 x \n \n Box\n \n \n \n (Wood)\n \n \n
\n \n \n \n \n \n 3 x \n \n Lid\n \n \n \n (Steel)\n \n \n
\n \n \n \n \n ",
"icon_alt" : "Recycle",
"icon_color_from_evaluation" : true,
"icon_url" : "http://static.openfoodfacts.localhost/images/icons/dist/recycle-variant.svg",
@@ -1627,7 +1627,7 @@
"element_type" : "text",
"text_element" : {
"evaluation" : "bad",
- "html" : "\n \n \n \n \n \n \n \n \n 1 x \n \n Film\n \n \n \n (Plastic)\n \n
\n \n \n ",
+ "html" : "\n \n \n \n \n \n \n \n \n 1 x \n \n Film\n \n \n \n (Plastic)\n \n \n
\n \n \n ",
"icon_alt" : "Discard",
"icon_color_from_evaluation" : true,
"icon_url" : "http://static.openfoodfacts.localhost/images/icons/dist/delete.svg",
@@ -1638,7 +1638,7 @@
"element_type" : "text",
"text_element" : {
"evaluation" : "neutral",
- "html" : "\n \n \n \n \n 6 x \n \n Bottle\n 25cl \n \n \n (Glass)\n \n
\n \n \n \n \n \n \n ",
+ "html" : "\n \n \n \n \n 6 x \n \n Bottle\n 25cl \n \n \n (Glass)\n \n \n
\n \n \n \n \n \n \n ",
"icon_alt" : "Unknown",
"icon_color_from_evaluation" : true,
"icon_url" : "http://static.openfoodfacts.localhost/images/icons/dist/help.svg",
diff --git a/tests/integration/expected_test_results/api_v2_product_read/get-knowledge-panels-fr.json b/tests/integration/expected_test_results/api_v2_product_read/get-knowledge-panels-fr.json
index bc03dd1180998..eb8e2f063e107 100644
--- a/tests/integration/expected_test_results/api_v2_product_read/get-knowledge-panels-fr.json
+++ b/tests/integration/expected_test_results/api_v2_product_read/get-knowledge-panels-fr.json
@@ -1668,7 +1668,7 @@
"element_type" : "text",
"text_element" : {
"evaluation" : "good",
- "html" : "\n \n \n 1 x \n \n Boîte\n \n \n \n (Bois)\n \n
\n \n \n \n \n \n 3 x \n \n Couvercle\n \n \n \n (Acier)\n \n
\n \n \n \n \n ",
+ "html" : "\n \n \n 1 x \n \n Boîte\n \n \n \n (Bois)\n \n \n
\n \n \n \n \n \n 3 x \n \n Couvercle\n \n \n \n (Acier)\n \n \n
\n \n \n \n \n ",
"icon_alt" : "Recycler",
"icon_color_from_evaluation" : true,
"icon_url" : "http://static.openfoodfacts.localhost/images/icons/dist/recycle-variant.svg",
@@ -1679,7 +1679,7 @@
"element_type" : "text",
"text_element" : {
"evaluation" : "bad",
- "html" : "\n \n \n \n \n \n \n \n \n 1 x \n \n Film\n \n \n \n (Plastique)\n \n
\n \n \n ",
+ "html" : "\n \n \n \n \n \n \n \n \n 1 x \n \n Film\n \n \n \n (Plastique)\n \n \n
\n \n \n ",
"icon_alt" : "Jeter",
"icon_color_from_evaluation" : true,
"icon_url" : "http://static.openfoodfacts.localhost/images/icons/dist/delete.svg",
@@ -1690,7 +1690,7 @@
"element_type" : "text",
"text_element" : {
"evaluation" : "neutral",
- "html" : "\n \n \n \n \n 6 x \n \n Bouteille\n 25cl \n \n \n (Verre)\n \n
\n \n \n \n \n \n \n ",
+ "html" : "\n \n \n \n \n 6 x \n \n Bouteille\n 25cl \n \n \n (Verre)\n \n \n
\n \n \n \n \n \n \n ",
"icon_alt" : "Inconnu",
"icon_color_from_evaluation" : true,
"icon_url" : "http://static.openfoodfacts.localhost/images/icons/dist/help.svg",
diff --git a/tests/integration/expected_test_results/api_v2_product_read/get-knowledge-panels.json b/tests/integration/expected_test_results/api_v2_product_read/get-knowledge-panels.json
index 6ab0f9c079b57..54e20a386b30f 100644
--- a/tests/integration/expected_test_results/api_v2_product_read/get-knowledge-panels.json
+++ b/tests/integration/expected_test_results/api_v2_product_read/get-knowledge-panels.json
@@ -1661,7 +1661,7 @@
"element_type" : "text",
"text_element" : {
"evaluation" : "good",
- "html" : "\n \n \n 1 x \n \n Box\n \n \n \n (Wood)\n \n
\n \n \n \n \n \n 3 x \n \n Lid\n \n \n \n (Steel)\n \n
\n \n \n \n \n ",
+ "html" : "\n \n \n 1 x \n \n Box\n \n \n \n (Wood)\n \n \n
\n \n \n \n \n \n 3 x \n \n Lid\n \n \n \n (Steel)\n \n \n
\n \n \n \n \n ",
"icon_alt" : "Recycle",
"icon_color_from_evaluation" : true,
"icon_url" : "http://static.openfoodfacts.localhost/images/icons/dist/recycle-variant.svg",
@@ -1672,7 +1672,7 @@
"element_type" : "text",
"text_element" : {
"evaluation" : "bad",
- "html" : "\n \n \n \n \n \n \n \n \n 1 x \n \n Film\n \n \n \n (Plastic)\n \n
\n \n \n ",
+ "html" : "\n \n \n \n \n \n \n \n \n 1 x \n \n Film\n \n \n \n (Plastic)\n \n \n
\n \n \n ",
"icon_alt" : "Discard",
"icon_color_from_evaluation" : true,
"icon_url" : "http://static.openfoodfacts.localhost/images/icons/dist/delete.svg",
@@ -1683,7 +1683,7 @@
"element_type" : "text",
"text_element" : {
"evaluation" : "neutral",
- "html" : "\n \n \n \n \n 6 x \n \n Bottle\n 25cl \n \n \n (Glass)\n \n
\n \n \n \n \n \n \n ",
+ "html" : "\n \n \n \n \n 6 x \n \n Bottle\n 25cl \n \n \n (Glass)\n \n \n
\n \n \n \n \n \n \n ",
"icon_alt" : "Unknown",
"icon_color_from_evaluation" : true,
"icon_url" : "http://static.openfoodfacts.localhost/images/icons/dist/help.svg",
diff --git a/tests/integration/expected_test_results/api_v3_product_read/get-fields-all-knowledge-panels.json b/tests/integration/expected_test_results/api_v3_product_read/get-fields-all-knowledge-panels.json
index 099f187a16bbc..50131e18877a8 100644
--- a/tests/integration/expected_test_results/api_v3_product_read/get-fields-all-knowledge-panels.json
+++ b/tests/integration/expected_test_results/api_v3_product_read/get-fields-all-knowledge-panels.json
@@ -2066,7 +2066,7 @@
"element_type" : "text",
"text_element" : {
"evaluation" : "good",
- "html" : "\n \n \n 1 x \n \n Box\n \n \n \n (Wood)\n \n
\n \n \n \n \n \n 3 x \n \n Lid\n \n \n \n (Steel)\n \n
\n \n \n \n \n ",
+ "html" : "\n \n \n 1 x \n \n Box\n \n \n \n (Wood)\n \n \n
\n \n \n \n \n \n 3 x \n \n Lid\n \n \n \n (Steel)\n \n \n
\n \n \n \n \n ",
"icon_alt" : "Recycle",
"icon_color_from_evaluation" : true,
"icon_url" : "http://static.openfoodfacts.localhost/images/icons/dist/recycle-variant.svg",
@@ -2077,7 +2077,7 @@
"element_type" : "text",
"text_element" : {
"evaluation" : "bad",
- "html" : "\n \n \n \n \n \n \n \n \n 1 x \n \n Film\n \n \n \n (Plastic)\n \n
\n \n \n ",
+ "html" : "\n \n \n \n \n \n \n \n \n 1 x \n \n Film\n \n \n \n (Plastic)\n \n \n
\n \n \n ",
"icon_alt" : "Discard",
"icon_color_from_evaluation" : true,
"icon_url" : "http://static.openfoodfacts.localhost/images/icons/dist/delete.svg",
@@ -2088,7 +2088,7 @@
"element_type" : "text",
"text_element" : {
"evaluation" : "neutral",
- "html" : "\n \n \n \n \n 6 x \n \n Bottle\n 25cl \n \n \n (Glass)\n \n
\n \n \n \n \n \n \n ",
+ "html" : "\n \n \n \n \n 6 x \n \n Bottle\n 25cl \n \n \n (Glass)\n \n \n
\n \n \n \n \n \n \n ",
"icon_alt" : "Unknown",
"icon_color_from_evaluation" : true,
"icon_url" : "http://static.openfoodfacts.localhost/images/icons/dist/help.svg",
diff --git a/tests/integration/expected_test_results/api_v3_product_read/get-fields-attribute-groups-all-knowledge-panels.json b/tests/integration/expected_test_results/api_v3_product_read/get-fields-attribute-groups-all-knowledge-panels.json
index 27a2317beb3a4..eb49a710d9ae2 100644
--- a/tests/integration/expected_test_results/api_v3_product_read/get-fields-attribute-groups-all-knowledge-panels.json
+++ b/tests/integration/expected_test_results/api_v3_product_read/get-fields-attribute-groups-all-knowledge-panels.json
@@ -2712,7 +2712,7 @@
"element_type" : "text",
"text_element" : {
"evaluation" : "good",
- "html" : "\n \n \n 1 x \n \n Box\n \n \n \n (Wood)\n \n
\n \n \n \n \n \n 3 x \n \n Lid\n \n \n \n (Steel)\n \n
\n \n \n \n \n ",
+ "html" : "\n \n \n 1 x \n \n Box\n \n \n \n (Wood)\n \n \n
\n \n \n \n \n \n 3 x \n \n Lid\n \n \n \n (Steel)\n \n \n
\n \n \n \n \n ",
"icon_alt" : "Recycle",
"icon_color_from_evaluation" : true,
"icon_url" : "http://static.openfoodfacts.localhost/images/icons/dist/recycle-variant.svg",
@@ -2723,7 +2723,7 @@
"element_type" : "text",
"text_element" : {
"evaluation" : "bad",
- "html" : "\n \n \n \n \n \n \n \n \n 1 x \n \n Film\n \n \n \n (Plastic)\n \n
\n \n \n ",
+ "html" : "\n \n \n \n \n \n \n \n \n 1 x \n \n Film\n \n \n \n (Plastic)\n \n \n
\n \n \n ",
"icon_alt" : "Discard",
"icon_color_from_evaluation" : true,
"icon_url" : "http://static.openfoodfacts.localhost/images/icons/dist/delete.svg",
@@ -2734,7 +2734,7 @@
"element_type" : "text",
"text_element" : {
"evaluation" : "neutral",
- "html" : "\n \n \n \n \n 6 x \n \n Bottle\n 25cl \n \n \n (Glass)\n \n
\n \n \n \n \n \n \n ",
+ "html" : "\n \n \n \n \n 6 x \n \n Bottle\n 25cl \n \n \n (Glass)\n \n \n
\n \n \n \n \n \n \n ",
"icon_alt" : "Unknown",
"icon_color_from_evaluation" : true,
"icon_url" : "http://static.openfoodfacts.localhost/images/icons/dist/help.svg",
diff --git a/tests/integration/expected_test_results/api_v3_product_read/get-fields-knowledge-panels-knowledge-panels_included-health_card-environment_card-knowledge_panels_excluded-health_card.json b/tests/integration/expected_test_results/api_v3_product_read/get-fields-knowledge-panels-knowledge-panels_included-health_card-environment_card-knowledge_panels_excluded-health_card.json
index efd3f3499f037..4f2d7f7c762f2 100644
--- a/tests/integration/expected_test_results/api_v3_product_read/get-fields-knowledge-panels-knowledge-panels_included-health_card-environment_card-knowledge_panels_excluded-health_card.json
+++ b/tests/integration/expected_test_results/api_v3_product_read/get-fields-knowledge-panels-knowledge-panels_included-health_card-environment_card-knowledge_panels_excluded-health_card.json
@@ -605,7 +605,7 @@
"element_type" : "text",
"text_element" : {
"evaluation" : "good",
- "html" : "\n \n \n 1 x \n \n Box\n \n \n \n (Wood)\n \n
\n \n \n \n \n \n 3 x \n \n Lid\n \n \n \n (Steel)\n \n
\n \n \n \n \n ",
+ "html" : "\n \n \n 1 x \n \n Box\n \n \n \n (Wood)\n \n \n
\n \n \n \n \n \n 3 x \n \n Lid\n \n \n \n (Steel)\n \n \n
\n \n \n \n \n ",
"icon_alt" : "Recycle",
"icon_color_from_evaluation" : true,
"icon_url" : "http://static.openfoodfacts.localhost/images/icons/dist/recycle-variant.svg",
@@ -616,7 +616,7 @@
"element_type" : "text",
"text_element" : {
"evaluation" : "bad",
- "html" : "\n \n \n \n \n \n \n \n \n 1 x \n \n Film\n \n \n \n (Plastic)\n \n
\n \n \n ",
+ "html" : "\n \n \n \n \n \n \n \n \n 1 x \n \n Film\n \n \n \n (Plastic)\n \n \n
\n \n \n ",
"icon_alt" : "Discard",
"icon_color_from_evaluation" : true,
"icon_url" : "http://static.openfoodfacts.localhost/images/icons/dist/delete.svg",
@@ -627,7 +627,7 @@
"element_type" : "text",
"text_element" : {
"evaluation" : "neutral",
- "html" : "\n \n \n \n \n 6 x \n \n Bottle\n 25cl \n \n \n (Glass)\n \n
\n \n \n \n \n \n \n ",
+ "html" : "\n \n \n \n \n 6 x \n \n Bottle\n 25cl \n \n \n (Glass)\n \n \n
\n \n \n \n \n \n \n ",
"icon_alt" : "Unknown",
"icon_color_from_evaluation" : true,
"icon_url" : "http://static.openfoodfacts.localhost/images/icons/dist/help.svg",
diff --git a/tests/integration/expected_test_results/api_v3_product_read/get-fields-knowledge-panels-knowledge-panels_included-health_card-environment_card.json b/tests/integration/expected_test_results/api_v3_product_read/get-fields-knowledge-panels-knowledge-panels_included-health_card-environment_card.json
index 67a2cf08faa18..cf4c1570bd0ec 100644
--- a/tests/integration/expected_test_results/api_v3_product_read/get-fields-knowledge-panels-knowledge-panels_included-health_card-environment_card.json
+++ b/tests/integration/expected_test_results/api_v3_product_read/get-fields-knowledge-panels-knowledge-panels_included-health_card-environment_card.json
@@ -1247,7 +1247,7 @@
"element_type" : "text",
"text_element" : {
"evaluation" : "good",
- "html" : "\n \n \n 1 x \n \n Box\n \n \n \n (Wood)\n \n
\n \n \n \n \n \n 3 x \n \n Lid\n \n \n \n (Steel)\n \n
\n \n \n \n \n ",
+ "html" : "\n \n \n 1 x \n \n Box\n \n \n \n (Wood)\n \n \n
\n \n \n \n \n \n 3 x \n \n Lid\n \n \n \n (Steel)\n \n \n
\n \n \n \n \n ",
"icon_alt" : "Recycle",
"icon_color_from_evaluation" : true,
"icon_url" : "http://static.openfoodfacts.localhost/images/icons/dist/recycle-variant.svg",
@@ -1258,7 +1258,7 @@
"element_type" : "text",
"text_element" : {
"evaluation" : "bad",
- "html" : "\n \n \n \n \n \n \n \n \n 1 x \n \n Film\n \n \n \n (Plastic)\n \n
\n \n \n ",
+ "html" : "\n \n \n \n \n \n \n \n \n 1 x \n \n Film\n \n \n \n (Plastic)\n \n \n
\n \n \n ",
"icon_alt" : "Discard",
"icon_color_from_evaluation" : true,
"icon_url" : "http://static.openfoodfacts.localhost/images/icons/dist/delete.svg",
@@ -1269,7 +1269,7 @@
"element_type" : "text",
"text_element" : {
"evaluation" : "neutral",
- "html" : "\n \n \n \n \n 6 x \n \n Bottle\n 25cl \n \n \n (Glass)\n \n
\n \n \n \n \n \n \n ",
+ "html" : "\n \n \n \n \n 6 x \n \n Bottle\n 25cl \n \n \n (Glass)\n \n \n
\n \n \n \n \n \n \n ",
"icon_alt" : "Unknown",
"icon_color_from_evaluation" : true,
"icon_url" : "http://static.openfoodfacts.localhost/images/icons/dist/help.svg",
diff --git a/tests/integration/expected_test_results/api_v3_product_read/get-knowledge-panels-fr.json b/tests/integration/expected_test_results/api_v3_product_read/get-knowledge-panels-fr.json
index 9458f10bc0477..ce5d7e688e8c1 100644
--- a/tests/integration/expected_test_results/api_v3_product_read/get-knowledge-panels-fr.json
+++ b/tests/integration/expected_test_results/api_v3_product_read/get-knowledge-panels-fr.json
@@ -1299,7 +1299,7 @@
"element_type" : "text",
"text_element" : {
"evaluation" : "good",
- "html" : "\n \n \n 1 x \n \n Boîte\n \n \n \n (Bois)\n \n
\n \n \n \n \n \n 3 x \n \n Couvercle\n \n \n \n (Acier)\n \n
\n \n \n \n \n ",
+ "html" : "\n \n \n 1 x \n \n Boîte\n \n \n \n (Bois)\n \n \n
\n \n \n \n \n \n 3 x \n \n Couvercle\n \n \n \n (Acier)\n \n \n
\n \n \n \n \n ",
"icon_alt" : "Recycler",
"icon_color_from_evaluation" : true,
"icon_url" : "http://static.openfoodfacts.localhost/images/icons/dist/recycle-variant.svg",
@@ -1310,7 +1310,7 @@
"element_type" : "text",
"text_element" : {
"evaluation" : "bad",
- "html" : "\n \n \n \n \n \n \n \n \n 1 x \n \n Film\n \n \n \n (Plastique)\n \n
\n \n \n ",
+ "html" : "\n \n \n \n \n \n \n \n \n 1 x \n \n Film\n \n \n \n (Plastique)\n \n \n
\n \n \n ",
"icon_alt" : "Jeter",
"icon_color_from_evaluation" : true,
"icon_url" : "http://static.openfoodfacts.localhost/images/icons/dist/delete.svg",
@@ -1321,7 +1321,7 @@
"element_type" : "text",
"text_element" : {
"evaluation" : "neutral",
- "html" : "\n \n \n \n \n 6 x \n \n Bouteille\n 25cl \n \n \n (Verre)\n \n
\n \n \n \n \n \n \n ",
+ "html" : "\n \n \n \n \n 6 x \n \n Bouteille\n 25cl \n \n \n (Verre)\n \n \n
\n \n \n \n \n \n \n ",
"icon_alt" : "Inconnu",
"icon_color_from_evaluation" : true,
"icon_url" : "http://static.openfoodfacts.localhost/images/icons/dist/help.svg",
diff --git a/tests/integration/expected_test_results/api_v3_product_read/get-knowledge-panels.json b/tests/integration/expected_test_results/api_v3_product_read/get-knowledge-panels.json
index 3bde53628d86c..189f2c1181961 100644
--- a/tests/integration/expected_test_results/api_v3_product_read/get-knowledge-panels.json
+++ b/tests/integration/expected_test_results/api_v3_product_read/get-knowledge-panels.json
@@ -1292,7 +1292,7 @@
"element_type" : "text",
"text_element" : {
"evaluation" : "good",
- "html" : "\n \n \n 1 x \n \n Box\n \n \n \n (Wood)\n \n
\n \n \n \n \n \n 3 x \n \n Lid\n \n \n \n (Steel)\n \n
\n \n \n \n \n ",
+ "html" : "\n \n \n 1 x \n \n Box\n \n \n \n (Wood)\n \n \n
\n \n \n \n \n \n 3 x \n \n Lid\n \n \n \n (Steel)\n \n \n
\n \n \n \n \n ",
"icon_alt" : "Recycle",
"icon_color_from_evaluation" : true,
"icon_url" : "http://static.openfoodfacts.localhost/images/icons/dist/recycle-variant.svg",
@@ -1303,7 +1303,7 @@
"element_type" : "text",
"text_element" : {
"evaluation" : "bad",
- "html" : "\n \n \n \n \n \n \n \n \n 1 x \n \n Film\n \n \n \n (Plastic)\n \n
\n \n \n ",
+ "html" : "\n \n \n \n \n \n \n \n \n 1 x \n \n Film\n \n \n \n (Plastic)\n \n \n
\n \n \n ",
"icon_alt" : "Discard",
"icon_color_from_evaluation" : true,
"icon_url" : "http://static.openfoodfacts.localhost/images/icons/dist/delete.svg",
@@ -1314,7 +1314,7 @@
"element_type" : "text",
"text_element" : {
"evaluation" : "neutral",
- "html" : "\n \n \n \n \n 6 x \n \n Bottle\n 25cl \n \n \n (Glass)\n \n
\n \n \n \n \n \n \n ",
+ "html" : "\n \n \n \n \n 6 x \n \n Bottle\n 25cl \n \n \n (Glass)\n \n \n
\n \n \n \n \n \n \n ",
"icon_alt" : "Unknown",
"icon_color_from_evaluation" : true,
"icon_url" : "http://static.openfoodfacts.localhost/images/icons/dist/help.svg",
diff --git a/tests/integration/expected_test_results/api_v3_product_services/determine-food-contact-of-packaging-components-service.json b/tests/integration/expected_test_results/api_v3_product_services/determine-food-contact-of-packaging-components-service.json
new file mode 100644
index 0000000000000..cf58f22d011ba
--- /dev/null
+++ b/tests/integration/expected_test_results/api_v3_product_services/determine-food-contact-of-packaging-components-service.json
@@ -0,0 +1,42 @@
+{
+ "errors" : [],
+ "fields" : [
+ "updated"
+ ],
+ "product" : {
+ "packagings" : [
+ {
+ "food_contact" : 1,
+ "material" : {
+ "id" : "en:plastic"
+ },
+ "shape" : {
+ "id" : "en:tray"
+ }
+ },
+ {
+ "food_contact" : 1,
+ "material" : {
+ "id" : "en:plastic"
+ },
+ "shape" : {
+ "id" : "en:film"
+ }
+ },
+ {
+ "food_contact" : 0,
+ "material" : {
+ "id" : "en:paper"
+ },
+ "shape" : {
+ "id" : "en:label"
+ }
+ }
+ ]
+ },
+ "services" : [
+ "determine_food_contact_of_packaging_components"
+ ],
+ "status" : "success",
+ "warnings" : []
+}
diff --git a/tests/integration/expected_test_results/api_v3_product_write/patch-request-fields-updated-attribute-groups-knowledge-panels.json b/tests/integration/expected_test_results/api_v3_product_write/patch-request-fields-updated-attribute-groups-knowledge-panels.json
index 44d339af1c933..78752fc8332d8 100644
--- a/tests/integration/expected_test_results/api_v3_product_write/patch-request-fields-updated-attribute-groups-knowledge-panels.json
+++ b/tests/integration/expected_test_results/api_v3_product_write/patch-request-fields-updated-attribute-groups-knowledge-panels.json
@@ -837,7 +837,7 @@
"element_type" : "text",
"text_element" : {
"evaluation" : "neutral",
- "html" : "\n \n \n 1 x \n \n Bag\n \n \n \n (Plastic)\n \n
\n \n \n ",
+ "html" : "\n \n \n 1 x \n \n Bag\n \n \n \n (Plastic)\n \n \n
\n \n \n ",
"icon_alt" : "Unknown",
"icon_color_from_evaluation" : true,
"icon_url" : "http://static.openfoodfacts.localhost/images/icons/dist/help.svg",
diff --git a/tests/integration/expected_test_results/product_read/get-existing-product.html b/tests/integration/expected_test_results/product_read/get-existing-product.html
index 14e84f003920a..ebda9c3dcc1dd 100644
--- a/tests/integration/expected_test_results/product_read/get-existing-product.html
+++ b/tests/integration/expected_test_results/product_read/get-existing-product.html
@@ -4023,6 +4023,7 @@ Packaging
(Wood)
+
@@ -4037,6 +4038,7 @@ Packaging
(Steel)
+
@@ -4084,6 +4086,7 @@ Packaging
(Plastic)
+
@@ -4125,6 +4128,7 @@ Packaging
(Glass)
+
diff --git a/tests/unit/expected_test_results/packaging_food_contact/canned_tomatoes.json b/tests/unit/expected_test_results/packaging_food_contact/canned_tomatoes.json
new file mode 100644
index 0000000000000..931eaee31ec74
--- /dev/null
+++ b/tests/unit/expected_test_results/packaging_food_contact/canned_tomatoes.json
@@ -0,0 +1,37 @@
+{
+ "lc" : "en",
+ "misc_tags" : [
+ "en:packagings-number-of-components-2",
+ "en:packagings-not-complete",
+ "en:packagings-not-empty-but-not-complete",
+ "en:packagings-not-empty"
+ ],
+ "packaging_materials_tags" : [
+ "en:metal",
+ "en:paper"
+ ],
+ "packaging_recycling_tags" : [],
+ "packaging_shapes_tags" : [
+ "en:can",
+ "en:label"
+ ],
+ "packaging_text" : "can, paper label",
+ "packagings" : [
+ {
+ "food_contact" : 1,
+ "material" : "en:metal",
+ "shape" : "en:can"
+ },
+ {
+ "food_contact" : 0,
+ "material" : "en:paper",
+ "shape" : "en:label"
+ }
+ ],
+ "packagings_materials" : {
+ "all" : {},
+ "en:metal" : {},
+ "en:paper-or-cardboard" : {}
+ },
+ "packagings_n" : 2
+}
diff --git a/tests/unit/expected_test_results/packaging_food_contact/coffee_capsule.json b/tests/unit/expected_test_results/packaging_food_contact/coffee_capsule.json
new file mode 100644
index 0000000000000..45c7f89a3b4d1
--- /dev/null
+++ b/tests/unit/expected_test_results/packaging_food_contact/coffee_capsule.json
@@ -0,0 +1,38 @@
+{
+ "lc" : "en",
+ "misc_tags" : [
+ "en:packagings-number-of-components-3",
+ "en:packagings-not-complete",
+ "en:packagings-not-empty-but-not-complete",
+ "en:packagings-not-empty"
+ ],
+ "packaging_materials_tags" : [
+ "en:plastic"
+ ],
+ "packaging_recycling_tags" : [],
+ "packaging_shapes_tags" : [
+ "en:box",
+ "en:capsule",
+ "en:film"
+ ],
+ "packaging_text" : "carboard box, plastic capsule, plastic film",
+ "packagings" : [
+ {
+ "shape" : "en:box"
+ },
+ {
+ "material" : "en:plastic",
+ "shape" : "en:capsule"
+ },
+ {
+ "material" : "en:plastic",
+ "shape" : "en:film"
+ }
+ ],
+ "packagings_materials" : {
+ "all" : {},
+ "en:plastic" : {},
+ "en:unknown" : {}
+ },
+ "packagings_n" : 3
+}
diff --git a/tests/unit/expected_test_results/packaging_food_contact/empty_packagings.json b/tests/unit/expected_test_results/packaging_food_contact/empty_packagings.json
new file mode 100644
index 0000000000000..b8143031e6d5d
--- /dev/null
+++ b/tests/unit/expected_test_results/packaging_food_contact/empty_packagings.json
@@ -0,0 +1,14 @@
+{
+ "lc" : "en",
+ "misc_tags" : [
+ "en:packagings-number-of-components-0",
+ "en:packagings-not-complete",
+ "en:packagings-empty"
+ ],
+ "packaging_materials_tags" : [],
+ "packaging_recycling_tags" : [],
+ "packaging_shapes_tags" : [],
+ "packaging_text" : "",
+ "packagings" : [],
+ "packagings_materials" : {}
+}
diff --git a/tests/unit/expected_test_results/packaging_food_contact/hazelnut_paste_glass_jar.json b/tests/unit/expected_test_results/packaging_food_contact/hazelnut_paste_glass_jar.json
new file mode 100644
index 0000000000000..c53ff616ae277
--- /dev/null
+++ b/tests/unit/expected_test_results/packaging_food_contact/hazelnut_paste_glass_jar.json
@@ -0,0 +1,58 @@
+{
+ "lc" : "en",
+ "misc_tags" : [
+ "en:packagings-number-of-components-5",
+ "en:packagings-not-complete",
+ "en:packagings-not-empty-but-not-complete",
+ "en:packagings-not-empty"
+ ],
+ "packaging_materials_tags" : [
+ "en:cardboard",
+ "en:glass",
+ "en:paper",
+ "en:plastic"
+ ],
+ "packaging_recycling_tags" : [],
+ "packaging_shapes_tags" : [
+ "en:box",
+ "en:jar",
+ "en:label",
+ "en:lid",
+ "en:seal"
+ ],
+ "packaging_text" : "glass jar, plastic lid, paper label, paper seal, cardboard box",
+ "packagings" : [
+ {
+ "food_contact" : 1,
+ "material" : "en:glass",
+ "shape" : "en:jar"
+ },
+ {
+ "food_contact" : 0,
+ "material" : "en:plastic",
+ "shape" : "en:lid"
+ },
+ {
+ "food_contact" : 0,
+ "material" : "en:paper",
+ "shape" : "en:label"
+ },
+ {
+ "food_contact" : 1,
+ "material" : "en:paper",
+ "shape" : "en:seal"
+ },
+ {
+ "food_contact" : 0,
+ "material" : "en:cardboard",
+ "shape" : "en:box"
+ }
+ ],
+ "packagings_materials" : {
+ "all" : {},
+ "en:glass" : {},
+ "en:paper-or-cardboard" : {},
+ "en:plastic" : {}
+ },
+ "packagings_n" : 5
+}
diff --git a/tests/unit/expected_test_results/packaging_food_contact/meat_tray.json b/tests/unit/expected_test_results/packaging_food_contact/meat_tray.json
new file mode 100644
index 0000000000000..24e9c0608c8e2
--- /dev/null
+++ b/tests/unit/expected_test_results/packaging_food_contact/meat_tray.json
@@ -0,0 +1,43 @@
+{
+ "lc" : "en",
+ "misc_tags" : [
+ "en:packagings-number-of-components-3",
+ "en:packagings-not-complete",
+ "en:packagings-not-empty-but-not-complete",
+ "en:packagings-not-empty"
+ ],
+ "packaging_materials_tags" : [
+ "en:paper",
+ "en:plastic"
+ ],
+ "packaging_recycling_tags" : [],
+ "packaging_shapes_tags" : [
+ "en:film",
+ "en:label",
+ "en:tray"
+ ],
+ "packaging_text" : "plastic tray, plastic film, paper label",
+ "packagings" : [
+ {
+ "food_contact" : 1,
+ "material" : "en:plastic",
+ "shape" : "en:tray"
+ },
+ {
+ "food_contact" : 1,
+ "material" : "en:plastic",
+ "shape" : "en:film"
+ },
+ {
+ "food_contact" : 0,
+ "material" : "en:paper",
+ "shape" : "en:label"
+ }
+ ],
+ "packagings_materials" : {
+ "all" : {},
+ "en:paper-or-cardboard" : {},
+ "en:plastic" : {}
+ },
+ "packagings_n" : 3
+}
diff --git a/tests/unit/expected_test_results/packaging_food_contact/wine_bottle.json b/tests/unit/expected_test_results/packaging_food_contact/wine_bottle.json
new file mode 100644
index 0000000000000..9366aa431eb99
--- /dev/null
+++ b/tests/unit/expected_test_results/packaging_food_contact/wine_bottle.json
@@ -0,0 +1,48 @@
+{
+ "lc" : "en",
+ "misc_tags" : [
+ "en:packagings-number-of-components-3",
+ "en:packagings-not-complete",
+ "en:packagings-not-empty-but-not-complete",
+ "en:packagings-not-empty"
+ ],
+ "packaging_materials_tags" : [
+ "en:cork",
+ "en:glass",
+ "en:paper"
+ ],
+ "packaging_recycling_tags" : [
+ "en:recycle"
+ ],
+ "packaging_shapes_tags" : [
+ "en:bottle",
+ "en:label",
+ "en:unknown"
+ ],
+ "packaging_text" : "glass bottle, cork, paper label",
+ "packagings" : [
+ {
+ "food_contact" : 1,
+ "material" : "en:glass",
+ "recycling" : "en:recycle",
+ "shape" : "en:bottle"
+ },
+ {
+ "food_contact" : 0,
+ "material" : "en:cork",
+ "shape" : "en:unknown"
+ },
+ {
+ "food_contact" : 0,
+ "material" : "en:paper",
+ "shape" : "en:label"
+ }
+ ],
+ "packagings_materials" : {
+ "all" : {},
+ "en:glass" : {},
+ "en:paper-or-cardboard" : {},
+ "en:unknown" : {}
+ },
+ "packagings_n" : 3
+}
diff --git a/tests/unit/packaging_food_contact.t b/tests/unit/packaging_food_contact.t
new file mode 100644
index 0000000000000..074364373d3b3
--- /dev/null
+++ b/tests/unit/packaging_food_contact.t
@@ -0,0 +1,92 @@
+#!/usr/bin/perl -w
+
+use Modern::Perl '2017';
+use utf8;
+
+use Test2::V0;
+use Data::Dumper;
+$Data::Dumper::Terse = 1;
+use Log::Any::Adapter 'TAP';
+
+use JSON;
+
+use ProductOpener::Config qw/:all/;
+use ProductOpener::Packaging qw/init_packaging_taxonomies_regexps analyze_and_combine_packaging_data/;
+use ProductOpener::PackagingFoodContact qw/determine_food_contact_of_packaging_components/;
+use ProductOpener::Test qw/compare_to_expected_results init_expected_results/;
+use ProductOpener::API qw/get_initialized_response/;
+
+my ($test_id, $test_dir, $expected_result_dir, $update_expected_results) = (init_expected_results(__FILE__));
+
+init_packaging_taxonomies_regexps();
+
+# Tests for determine_food_contact_of_packaging_components()
+
+my @tests = (
+
+ [
+ 'empty_packagings',
+ {
+ lc => "en",
+ packaging_text => "",
+ }
+ ],
+ [
+ 'hazelnut_paste_glass_jar',
+ {
+ lc => "en",
+ packaging_text => "glass jar, plastic lid, paper label, paper seal, cardboard box",
+ }
+ ],
+ [
+ 'canned_tomatoes',
+ {
+ lc => "en",
+ packaging_text => "can, paper label",
+ }
+ ],
+ [
+ 'coffee_capsule',
+ {
+ lc => "en",
+ packaging_text => "carboard box, plastic capsule, plastic film",
+ }
+ ],
+ [
+ 'meat_tray',
+ {
+ lc => "en",
+ packaging_text => "plastic tray, plastic film, paper label",
+ }
+ ],
+ [
+ 'wine_bottle',
+ {
+ lc => "en",
+ packaging_text => "glass bottle, cork, paper label",
+ }
+ ],
+
+);
+
+my $json = JSON->new->allow_nonref->canonical;
+
+foreach my $test_ref (@tests) {
+
+ my $testid = $test_ref->[0];
+ my $product_ref = $test_ref->[1];
+
+ # Run the test
+
+ # Response structure to keep track of warnings and errors
+ # Note: currently some warnings and errors are added,
+ # but we do not yet do anything with them
+ my $response_ref = get_initialized_response();
+
+ analyze_and_combine_packaging_data($product_ref, $response_ref);
+ determine_food_contact_of_packaging_components($product_ref->{packagings});
+
+ compare_to_expected_results($product_ref, "$expected_result_dir/$testid.json", $update_expected_results);
+}
+
+done_testing();