Dans la partie 3, on a trié la cave sans jamais toucher à l’originale, et on a démonté le mythe du tri « stable par hasard ».
On s’était quitté sur une dernière promesse : Search, filtrer la cave sur le nom, la région et l’année.
C’est le quatrième et dernier test du Winery exercice. Le plus court à écrire… et celui qui cache le plus de pièges de typage. 🍷
💡 Tout le code est sur GitHub : pcavezzan/2026-july-technical-interview-sample, branche
result/winery.
🎯 Ce que le test attend
Search prend une chaîne et renvoie une Cellar avec tous les vins qui la contiennent, dans le nom, la région ou l’année :
func TestSearchWines(t *testing.T) {
cellar := openWineCatalog(t)
assert.Equal(t, 6, cellar.Length())
res1 := cellar.Search("")
assert.Equal(t, "0,1,2,3,4,5", res1.dump())
res2 := cellar.Search("cru")
assert.Equal(t, "1,4,5", res2.dump())
res3 := cellar.Search("bord")
assert.Equal(t, "0,2,3", res3.dump())
res4 := cellar.Search("88")
assert.Equal(t, "0,3", res4.dump())
res5 := cellar.Search("AUX")
assert.Equal(t, "0,2,3", res5.dump())
}
Reprenons le catalogue, c’est la clé pour lire les assertions :
| Id | Nom | Année | Région |
|---|---|---|---|
| 0 | Chateau Petrus | 1988 | Bordeaux |
| 1 | Grand Cru Montrachet - Louis Jadot | 2018 | Burgundy |
| 2 | Chateau Margaux | 2000 | Bordeaux |
| 3 | Chateau Cheval Blanc | 1988 | Bordeaux |
| 4 | La tâche grand cru - Domaine La Romanée Contie | 2013 | Burgundy |
| 5 | Fourchaume - Chablis 1er Cru | 2018 | Burgundy |
Chaque assertion isole une exigence, et à elles cinq elles dessinent tout le cahier des charges :
""ressort tout. La chaîne vide est contenue dans n’importe quelle chaîne, donc les 6 vins remontent. Pas de cas particulier à écrire : c’est un cadeau dustrings.Contains, à condition de ne pas le court-circuiter. 🎁"cru"touche le nom des vins 1, 4 et 5, dont leCrumajuscule du vin 5. Premier signal : la recherche est insensible à la casse."bord"tape dans la régionBordeaux(vins 0, 2, 3). On cherche donc bien sur plusieurs champs, pas seulement le nom."AUX"retrouveBordeaux, en majuscules cette fois. La casse ne doit décidément jamais compter. 🎯"88"sort l’année1988(vins 0 et 3). Et là, piège :Yearest unint, pas unestring. On cherche une sous-chaîne dans un entier. 🪤
🧩 Le point de départ (code fourni)
// Search returns wine that matches the search terms on below wine properties
// - Name
// - Region
// - Year
// Note that the search implementation must be case insensitive
func (c Cellar) Search(str string) Cellar {
var res Cellar
// TODO: Candidate Codes
return res
}
Le squelette est le plus honnête de la série : même le commentaire prévient que la recherche doit être insensible à la casse.
Reste à ne pas se faire avoir par les deux détails que le commentaire ne dit pas. 👇
🛠️ Le réflexe naturel… et les deux vins qui manquent
Le premier jet, celui qu’on écrit sans réfléchir : on boucle, on teste strings.Contains sur chaque champ.
func (c Cellar) Search(str string) Cellar {
var res Cellar
for _, wine := range c {
if strings.Contains(wine.Name, str) ||
strings.Contains(wine.Region, str) {
res = append(res, wine)
}
}
return res
}
Deux problèmes, et le compilateur n’en signale aucun.
1️⃣ La casse.
strings.Contains("Fourchaume - Chablis 1er Cru", "cru") est faux : Cru porte une majuscule.
Et strings.Contains("Bordeaux", "AUX") l’est tout autant. Les recherches "cru" et "AUX" rendraient des résultats incomplets.
2️⃣ L’année manquante.
On ne peut même pas écrire strings.Contains(wine.Year, str) : wine.Year est un int, et strings.Contains attend deux string. Ça ne compile pas. La recherche "88" n’a aucune chance de sortir les vins 0 et 3.
🛠️ Le fix : normaliser la casse, stringifier l’année
func (c Cellar) Search(str string) Cellar {
var res Cellar
searchTerm := strings.ToLower(str)
for _, wine := range c {
if strings.Contains(strings.ToLower(wine.Name), searchTerm) ||
strings.Contains(strings.ToLower(wine.Region), searchTerm) ||
strings.Contains(strconv.Itoa(wine.Year), searchTerm) {
res = append(res, wine)
}
}
return res
}
Trois gestes, et les trois cas retombent sur leurs pieds :
strings.ToLowerdes deux côtés. On abaisse la casse du terme recherché une seule fois (searchTerm), puis celle de chaque champ."AUX"devient"aux","Bordeaux"devient"bordeaux", et la sous-chaîne se retrouve. 🧼strconv.Itoa(wine.Year)transforme l’entier1988en"1988", dans lequelstrings.Containsretrouve"88". La recherche sur l’année redevient une recherche de sous-chaîne comme les autres.- La chaîne vide reste gérée gratuitement.
strings.Contains(x, "")vauttruepour toutx, doncSearch("")renvoie les 6 vins sans qu’on écrive quoi que ce soit.
🔎 ToLower abaisse la casse, il ne déplie pas les accents
Le vin 4 s'appelle « La tâche grand cru ». strings.ToLower transforme bien T en t, mais laisse â intact : une recherche "tache" (sans accent) ne retrouverait pas "tâche". Le test ne tape jamais dans ce cas, donc ToLower suffit ici. Mais c'est le genre d'angle mort qu'un vrai moteur de recherche doit traiter, et on y revient dans l'aparté. 👀
Quatre tests au vert. Le Winery exercice est bouclé. 🎉
🥊 L’aparté du jour : « insensible à la casse » ne veut pas dire « insensible aux accents »
Le commentaire du squelette demandait une recherche case insensitive.
On l’a rendue telle avec strings.ToLower mais dans une vraie cave, un utilisateur qui tape "tache" s’attend à trouver « La tâche grand cru », et "romanee" à trouver « Romanée ». Or ToLower ne défait pas les diacritiques : â reste â, é reste é.
📐 Casse, pliage et normalisation : trois choses distinctes
- Insensibilité à la casse —
strings.ToLower/strings.EqualFold:A=a. - Insensibilité aux accents — retirer les diacritiques :
â→a,é→e. La lib standard ne le fait pas seule. - Normalisation Unicode — un même « é » peut être un seul code point (
U+00E9) ou unesuivi d'un accent combinant (U+0065 U+0301). Sans normalisation, ces deux « é » ne sont pas égaux octet pour octet.
👉 La normalisation Unicode est le plus tordu des trois mais j'en fais un aparté dédié dans l'annexe dépliable en fin d'article. 👇
Le remède idiomatique passe par golang.org/x/text. On décompose (forme NFD), on retire la catégorie des marques diacritiques, on recompose :
import (
"golang.org/x/text/runes"
"golang.org/x/text/transform"
"golang.org/x/text/unicode/norm"
"unicode"
)
// fold abaisse la casse ET retire les accents : "La tâche" -> "la tache"
func fold(s string) string {
t := transform.Chain(
norm.NFD, // é -> e + ´
runes.Remove(runes.In(unicode.Mn)), // supprime les marques (Mn)
norm.NFC, // recompose
)
out, _, _ := transform.String(t, s)
return strings.ToLower(out)
}
Avec fold à la place de strings.ToLower, Search("tache") retrouverait enfin le vin 4.
⚖️ Le vrai verdict
Pour l'exercice, strings.ToLower est le bon niveau d'effort : le test ne demande que la casse, et ajouter x/text serait sur-ingénierie. Mais le jour où cette cave devient un vrai catalogue avec une barre de recherche, un fold qui gère aussi les accents n'est plus un luxe : c'est ce que l'utilisateur attend par défaut. Savoir que ToLower s'arrête à la casse évite de promettre une insensibilité qu'on ne livre pas.
🕳️ Le second angle mort : chercher une sous-chaîne dans un nombre
strconv.Itoa(wine.Year) fait passer le test, mais transforme la recherche d'année en recherche de sous-chaîne textuelle. "88" retrouve bien 1988 ✓, mais "20" ressortirait 2000, 2013 et 2018, trois millésimes distincts d'un coup. Est-ce un bug ou une feature ? Ça dépend du produit. Une recherche « année = 2013 » attend une égalité, pas un Contains. Le contrat du test dit « sous-chaîne », on lui obéit, mais c'est un choix de sémantique à assumer, pas une évidence. 🤔
Retrouver un exemple d’implémentation case-sensitive ici
🧪 Rendre le test à la hauteur des trois précédents
Comme dans les parties 1 à 3, TestSearchWines traîne les mêmes petits défauts, et se prête idéalement au table testing.
assert là où require s’impose
cellar := openWineCatalog(t)
assert.Equal(t, 6, cellar.Length())
Rebelote : cellar.Length() == 6 est le prérequis de tout le reste. Si le catalogue ne contient pas 6 vins, les cinq résultats attendus ("0,1,2,3,4,5", "1,4,5"…) décrivent des indices qui n’ont plus de sens. Un require.Equal coupe net au lieu de dérouler une cascade d’échecs illisibles.
La forme table-driven
Les cinq recherches ne sont que cinq lignes d’un même tableau (entrée → ids attendus). C’est le cas d’école du table testing : chaque cas isolé, chaque intention nommée.
func TestSearchWines(t *testing.T) {
t.Parallel()
tests := []struct {
name string
query string
want string
}{
{name: "chaîne vide : toute la cave", query: "", want: "0,1,2,3,4,5"},
{name: "sur le nom (Cru, casse mélangée)", query: "cru", want: "1,4,5"},
{name: "sur la région (Bordeaux)", query: "bord", want: "0,2,3"},
{name: "sur l'année (sous-chaîne de 1988)", query: "88", want: "0,3"},
{name: "insensible à la casse (AUX)", query: "AUX", want: "0,2,3"},
}
cellar := openWineCatalog(t)
require.Equal(t, 6, cellar.Length())
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := cellar.Search(tt.query)
assert.Equal(t, tt.want, got.dump())
})
}
}
🔁 Ici, partager la cave entre sous-tests est sûr
Contrairement à TestSortPrice (partie 3), où chaque cas devait recharger sa propre cave parce que SortByPrice mettait la non-mutation à l'épreuve, Search est une opération en lecture seule : elle construit une nouvelle Cellar et ne touche jamais l'originale. Les sous-tests parallèles peuvent donc partager la même cellar sans risque de course. Le jour où un doute subsiste, la règle est simple : recharge par sous-test.
💡 La version table-driven complète est visible sur la branche
result/winery.
✅ Résultat
Une poignée de lignes, et le dernier test passe. Search n’avait rien de sorcier une fois les deux pièges vus : normaliser la casse des deux côtés, et convertir l’année en chaîne avant de la chercher. Le reste, strings.Contains s’en charge tout seul, jusqu’à la chaîne vide qui ressort tout le catalogue sans le moindre cas particulier.
La vraie leçon de cette manche est en creux : « insensible à la casse » et « chercher dans un entier » sont deux formulations qui masquent des décisions de sémantique (accents, égalité ou sous-chaîne) que le test ne pose pas, mais qu’un vrai produit devra trancher.
🍷 Le mot de la fin de la série
Quatre tests, quatre pièges, et un fil rouge : en Go, le typage du domaine et les valeurs par défaut font une partie du travail… à condition de savoir où ils s’arrêtent.
- Partie 1 — l’aller-retour JSON et la valeur zéro qui vaut « absent ».
- Partie 2 — la map nil qui panique en écriture, pas en lecture.
- Partie 3 — le slice qui partage son tableau, et le tri « stable par chance ».
- Partie 4 — la casse, les accents, et la recherche dans un entier.
Aucun de ces tests n’est difficile. Ce qui les rend instructifs, c’est qu’ils échouent tous silencieusement sur le premier réflexe, puis se réparent en une ou deux lignes une fois le mécanisme compris. C’est exactement ce qu’un exercice technique devrait faire : révéler ce qu’on croyait savoir. 🍇
Merci de m’avoir suivi sur ces quatre manches. À bientôt ! 🥂
📎 Annexe : Unicode et le piège du « é » à deux visages
Déplier : pourquoi un même « é » peut casser une comparaison (NFC / NFD)
La troisième ligne de l’encart plus haut est la plus déroutante, alors prenons le temps de la dérouler. Deux chaînes peuvent s’afficher rigoureusement pareil à l’écran et pourtant différer octet pour octet. Le coupable, c’est justement notre « é ».
Unicode autorise deux façons de l’écrire :
| Forme | Code points | Octets UTF-8 |
|---|---|---|
| Précomposée (NFC) | U+00E9 | C3 A9 |
| Décomposée (NFD) | U+0065 (e) + U+0301 (accent aigu combinant) | 65 CC 81 |
Le premier est un « é » d’un seul tenant. Le second est un « e » nu, suivi d’un accent qui vient se poser dessus au moment du rendu. Même dessin à l’écran, deux histoires en mémoire. Et pour Go, deux chaînes sans aucun rapport :
s1 := "café" // NFC : le é en un seul code point
s2 := "cafe\u0301" // NFD : e + accent aigu combinant (forcé décomposé)
s1 == s2 // false
len(s1) == len(s2) // false : 5 octets contre 6
🏛️ D'où vient cette dualité
C'est un héritage, pas un caprice. Unicode devait rester compatible avec les vieux jeux de caractères comme Latin-1, où « é » existait déjà comme un caractère unique : de là vient la forme précomposée. Mais il voulait aussi pouvoir fabriquer n'importe quelle lettre accentuée en collant un diacritique sur une base, ce qui est indispensable pour des langues comme le vietnamien ou pour les translittérations savantes : de là vient la forme décomposée. Les deux cohabitent depuis.
En pratique, d’où sort du NFD ? De macOS, surtout. Son ancien système de fichiers HFS+ stockait les noms en décomposé, et certaines API Apple en renvoient encore. Le web, Windows et la plupart des bases de données travaillent en NFC. Le jour où un texte saisi sur un Mac croise un texte venu d’ailleurs, les « é » ne se reconnaissent plus.
Normaliser, c’est ramener tout le monde à une forme de référence avant de comparer. Il en existe quatre, mais deux suffisent au quotidien : NFC (tout recomposé, la forme recommandée par le W3C et la plus répandue sur le web) et NFD (tout décomposé). Les variantes NFKC et NFKD vont plus loin en gommant aussi les équivalences de compatibilité (fi → fi, l’exposant ² → 2) : pratique pour la recherche, mais destructif, à réserver aux cas où on sait ce qu’on fait.
En Go, tout tient dans golang.org/x/text/unicode/norm :
import "golang.org/x/text/unicode/norm"
s1 := "café"
s2 := "cafe\u0301" // même mot, forme décomposée
norm.NFC.String(s1) == norm.NFC.String(s2) // true
🩹 Là où ça fait mal, pour de vrai
- Comparaison de chaînes — deux pseudos ou deux tags « identiques » qui échouent au
==. - Contrainte d'unicité en base — PostgreSQL ne normalise pas de lui-même. Deux
cafépeuvent cohabiter dans une colonneUNIQUE. On normalise côté appli avant l'INSERT, ou on passe par une colonne générée avecnormalize(col, NFC)(PostgreSQL 13+). - Recherche full-text — sans normalisation en amont, une recherche sur
caféen NFC rate les documents stockés en NFD. - URLs et slugs — deux
/articles/cafépeuvent viser deux ressources différentes selon leur forme. - Tests — un fichier de fixtures édité sur un Mac peut faire tomber une assertion d'égalité sur une CI Linux, sans qu'on comprenne pourquoi.
La règle qui désamorce tout ça tient en une ligne : normaliser en NFC à la frontière (saisie utilisateur, lecture de fichier, réponse d’une API externe), puis considérer que tout ce qui circule ensuite dans le système est propre. Le fold vu plus haut dans l’aparté ne fait rien d’autre avant de retirer les accents : il commence par décomposer (norm.NFD) précisément pour attraper les accents combinants, parce qu’un « é » précomposé et un « é » décomposé ne se laissent pas dépouiller de la même façon.
Retour sur notre fold : un pli « agressif » qui a ses limites
Le fold proposé plus haut va plus loin que la simple normalisation : il retire carrément les accents pour produire une forme canonique qui ignore casse et diacritiques. Pratique pour une recherche tolérante, un slug ou une clé d’index. Reprenons "La tâche", étape par étape :
norm.NFDdécompose : leâ(un seul code point) éclate enasuivi d’un circonflexe combinant. Les accents deviennent des caractères séparés, qu’on peut manipuler un par un.runes.Remove(runes.In(unicode.Mn))supprime la catégorie Unicode « Mark, Nonspacing », c’est-à-dire tous les diacritiques combinants (aigu, grave, circonflexe, tréma, tilde, macron…). Le circonflexe saute, leade base reste : on obtient"La tache".norm.NFCrecompose ce qui subsiste. Ici ça ne change rien de visible, mais sur un"naïve"décomposé, une fois le tréma retiré, on récupère un"naive"propre.strings.ToLowertermine le travail :"la tache".
Le transform.Chain enchaîne ces étapes en flux, sans matérialiser la chaîne intermédiaire à chaque passe : marginal sur un mot, appréciable sur un gros document. Petit piège au passage : transform.String renvoie une erreur qu’on ignore ici (out, _, _). C’est sans conséquence sur de l’UTF-8 valide, mais sur une entrée mal formée, on récupérerait un texte à moitié transformé sans le savoir. Si la source n’est pas de confiance, je vérifie l’erreur.
⚠️ Ce que ce pli agressif casse
- Ce n'est pas un vrai case folding Unicode.
ToLowermappe caractère par caractère et rate des cas : leßallemand devrait devenirss, leİturc suit des règles propres à la locale, le sigma final grecςdiffère duσ. Pour bien faire,cases.Fold()degolang.org/x/text/casesremplace avantageusement leToLowerfinal. - Certaines langues détestent ça. En allemand,
ä/ö/üne sont pas desa/o/uaccentués mais se translittèrent enae/oe/ue; en suédois,åest une lettre à part entière de l'alphabet. Aplatir les accents détruit ces distinctions. Pour du multilingue sérieux, on bascule sur la collation Unicode (x/text/collate) ou sur un vrai moteur comme Elasticsearch et ses analyzers. - Ça peut changer le sens.
Mnemporte aussi les harakat arabes, les nikkoud hébreux et les tons vietnamiens. En vietnamien, retirer les tons transforme un mot en un autre : tolérance bienvenue ici, perte de sens ailleurs. - Ça détruit de l'information. Ne stocke jamais uniquement la version foldée : garde toujours l'originale à côté, et réserve le fold à l'indexation et à la recherche.
Côté base, le même pli se fait souvent directement en Postgres, avec unaccent (qui revient grosso modo à un NFD suivi de la suppression des Mn) sur une colonne générée, indexée en trigrammes :
ALTER TABLE articles
ADD COLUMN title_freesearch TEXT
GENERATED ALWAYS AS (lower(unaccent(title_freesearch))) STORED;
CREATE INDEX articles_title_freesearch_trgm_idx
ON articles USING gin (title_freesearch gin_trgm_ops);
Reste la seule règle qui compte vraiment : la fonction qui produit la clé stockée et celle qui transforme la requête utilisateur doivent être rigoureusement identiques. Si fold(x) côté Go et lower(unaccent(x)) côté SQL divergent, la recherche rate des lignes en silence. Un test de parité sur un jeu d’entrées représentatif (« Café », « L’Été à Nîmes », « Jean-François Müller »…) vaut son pesant d’or.

