Expose côté UI ce que tech-step-matcher.ts détecte déjà à la sauvegarde
(Step.techSteps) mais qui restait backend-only : dans le panneau détail
d'une recette, les mots exacts ayant déclenché une technique sont
surlignés, avec un tooltip (survol/focus clavier) donnant son nom.
- tech-step-matcher.ts : matchTechStepSpans(description, mappings) expose
désormais {techStepId, start, end} en plus de la simple séquence d'ids
(déjà calculé en interne, jusqu'ici jeté). matchTechSteps devient un
wrapper fin dessus — aucun changement à ses ~12 tests existants ni à
recipe-translation.ts.
- StepTechStep gagne start/end (nullable, pas de backfill — même leçon que
l'incident de migration ingredient_unit_catalog : NOT NULL sans défaut
sur une table déjà peuplée casse le déploiement). Une ligne pré-existante
sans span est simplement omise de la réponse API plutôt que de fuiter un
null, jusqu'à ce que la recette soit resauvegardée.
- recipe.service.ts : createRecipe/updateRecipe persistent start/end ;
StepView expose techSteps: { techStep: {id,key}, start, end }[]. Le
recalcul complet à chaque édition (ajout/modif/suppression d'étape) était
déjà garanti par le delete-then-recreate existant d'updateRecipe — testé
explicitement (nouveau test "recomputes techniques from scratch...").
- Frontend : StepDescription.tsx (découpe le texte via
highlight-tech-steps.ts, pur et testé) remplace le <p> brut dans
RecipeDetailPanel. Nouveau Tooltip.tsx (composants/ui, CSS pur, aucune
lib externe — même esprit que Dialog.tsx) : un <button> (focusable
nativement, pas de tabIndex sur un <mark> non interactif) affiche le nom
de la technique (catalog.techSteps.<key>) au survol/focus.
Tests : matchTechStepSpans (spans corrects, chevauchement résolu),
recipe.test.ts (forme API + recalcul complet sur modif/ajout/suppression
d'étape, avec vérification que les anciennes lignes StepTechStep sont bien
supprimées), splitDescriptionByTechSteps (tri, bornes invalides ignorées,
chevauchement résiduel ignoré), scénario Cypress recipes.feature
(surlignage + tooltip au focus).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
100 lines
4.4 KiB
TypeScript
100 lines
4.4 KiB
TypeScript
import type { StepTechStepView } from "@batch-cooking/shared";
|
|
import { splitDescriptionByTechSteps } from "../../src/features/recipes/highlight-tech-steps";
|
|
|
|
// Pure logic, no DOM/mount needed — reuses the component-test runner
|
|
// (Cypress's Mocha/Chai, same as CheckboxOption.cy.tsx) purely for its
|
|
// `expect`, not for rendering. `.cy.tsx` (not `.cy.ts`) only because that's
|
|
// what `cypress.config.ts`'s component `specPattern` looks for.
|
|
|
|
function techStep(key: string, id: number, start: number, end: number): StepTechStepView {
|
|
return { techStep: { id, key }, start, end };
|
|
}
|
|
|
|
describe("splitDescriptionByTechSteps", () => {
|
|
it("returns the whole description as one plain segment when there are no matches", () => {
|
|
expect(splitDescriptionByTechSteps("Servir immédiatement", [])).to.deep.equal([
|
|
{ text: "Servir immédiatement", techStep: null },
|
|
]);
|
|
});
|
|
|
|
it("splits a single match into before/match/after segments", () => {
|
|
// "Faire mijoter à feu doux" — "mijoter" is [6, 13).
|
|
const result = splitDescriptionByTechSteps("Faire mijoter à feu doux", [
|
|
techStep("simmer", 1, 6, 13),
|
|
]);
|
|
expect(result).to.deep.equal([
|
|
{ text: "Faire ", techStep: null },
|
|
{ text: "mijoter", techStep: { id: 1, key: "simmer" } },
|
|
{ text: " à feu doux", techStep: null },
|
|
]);
|
|
});
|
|
|
|
it("handles a match at the very start, with nothing before it", () => {
|
|
const result = splitDescriptionByTechSteps("Hacher les oignons", [techStep("chop", 2, 0, 6)]);
|
|
expect(result).to.deep.equal([
|
|
{ text: "Hacher", techStep: { id: 2, key: "chop" } },
|
|
{ text: " les oignons", techStep: null },
|
|
]);
|
|
});
|
|
|
|
it("handles a match at the very end, with nothing after it", () => {
|
|
const result = splitDescriptionByTechSteps("Faire cuire", [techStep("cook", 3, 6, 11)]);
|
|
expect(result).to.deep.equal([
|
|
{ text: "Faire ", techStep: null },
|
|
{ text: "cuire", techStep: { id: 3, key: "cook" } },
|
|
]);
|
|
});
|
|
|
|
it("handles several non-adjacent matches, preserving the plain text between them", () => {
|
|
const text = "Préchauffer la poêle, puis faire fondre le beurre";
|
|
const result = splitDescriptionByTechSteps(text, [
|
|
techStep("preheat", 4, 0, 11),
|
|
techStep("melt", 5, 27, 39),
|
|
]);
|
|
expect(result.map((s) => s.text).join("")).to.equal(text);
|
|
expect(result.filter((s) => s.techStep !== null)).to.have.length(2);
|
|
expect(result[0]).to.deep.equal({ text: "Préchauffer", techStep: { id: 4, key: "preheat" } });
|
|
});
|
|
|
|
it("re-sorts entries that aren't already in start order", () => {
|
|
const text = "Faire fondre le beurre puis préchauffer le four";
|
|
// Passed in techStepId order, not text order — the function must sort
|
|
// by `start`, not trust the input order.
|
|
const result = splitDescriptionByTechSteps(text, [
|
|
techStep("preheat", 4, 28, 39),
|
|
techStep("melt", 5, 0, 12),
|
|
]);
|
|
const matches = result.filter((s) => s.techStep !== null);
|
|
expect(matches.map((s) => s.techStep?.key)).to.deep.equal(["melt", "preheat"]);
|
|
});
|
|
|
|
it("drops a match whose end is past the end of the description", () => {
|
|
const result = splitDescriptionByTechSteps("Cuire", [techStep("cook", 3, 0, 999)]);
|
|
expect(result).to.deep.equal([{ text: "Cuire", techStep: null }]);
|
|
});
|
|
|
|
it("drops a match with a negative start", () => {
|
|
const result = splitDescriptionByTechSteps("Cuire", [techStep("cook", 3, -1, 5)]);
|
|
expect(result).to.deep.equal([{ text: "Cuire", techStep: null }]);
|
|
});
|
|
|
|
it("drops a match whose start isn't before its end", () => {
|
|
const result = splitDescriptionByTechSteps("Cuire", [techStep("cook", 3, 3, 3)]);
|
|
expect(result).to.deep.equal([{ text: "Cuire", techStep: null }]);
|
|
});
|
|
|
|
it("drops a later match that overlaps one already accepted", () => {
|
|
// Two entries claiming overlapping ranges shouldn't happen in practice
|
|
// (the backend already resolves overlaps), but the splitter defends
|
|
// against it anyway rather than producing a garbled/duplicated slice.
|
|
const result = splitDescriptionByTechSteps("Cuire au four", [
|
|
techStep("bake", 3, 0, 13),
|
|
techStep("cook", 2, 0, 5),
|
|
]);
|
|
expect(result).to.deep.equal([{ text: "Cuire au four", techStep: { id: 3, key: "bake" } }]);
|
|
});
|
|
|
|
it("returns a single empty-ish segment for an empty description with no matches", () => {
|
|
expect(splitDescriptionByTechSteps("", [])).to.deep.equal([]);
|
|
});
|
|
});
|