test(recipes): pilote le scroll dans le scénario 3 pages des sources
Some checks failed
CI / lint (push) Successful in 2m36s
CI / build (push) Successful in 2m39s
CI / intent-service-test (push) Successful in 4m18s
CI / e2e (push) Failing after 7m20s
CI / test (push) Has been cancelled

Le scénario ajouté supposait que l'IntersectionObserver se redéclenchait
tout seul avec une liste courte — faux : il ne se déclenche qu'à une
*transition* d'intersection. Les pages font maintenant 15 éléments (la
liste déborde son conteneur, la sentinelle démarre sous la ligne de
flottaison) et chaque page suivante est demandée via un
`scrollTo("bottom")` explicite, qui produit une vraie transition.

Avec l'ancien code bugué, la sentinelle stale rappelle « cursor-2 » au 2e
scroll : « Page 3 Item 1 » n'apparaît jamais et un curseur est redemandé
— les deux assertions échouent.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Nicolas 2026-09-05 20:32:49 +02:00
parent c48625cb0c
commit c842fbb348
2 changed files with 44 additions and 27 deletions

View file

@ -63,9 +63,11 @@ Feature: Browsing external recipe sources
And browsing TheMealDB returns three pages, each addressed by its own cursor
When I visit "/recettes"
And I click the button "TheMealDB"
Then I should see the source item "Page One Dish"
And I should see the source item "Page Two Dish"
And I should see the source item "Page Three Dish"
Then I should see the source item "Page 1 Item 1"
When I scroll the source list to the bottom
Then I should see the source item "Page 2 Item 1"
When I scroll the source list to the bottom
Then I should see the source item "Page 3 Item 1"
And no browse cursor should have been requested more than once
Scenario: Offers a retry when loading the next page fails

View file

@ -133,28 +133,39 @@ Given("browsing TheMealDB returns two pages of items", () => {
});
/**
* Three real pages, each addressed by a distinct `cursor` value the mock
* actually parses (not just "does the URL contain `cursor=`"). Records
* every cursor it serves so the scenario can assert none was requested
* twice — the tell-tale of the issue #38 loop, where a stale closure
* re-requests the same page forever instead of advancing.
* Three full pages, each addressed by a distinct `cursor` value the mock
* actually parses (not just "does the URL contain `cursor=`"). Pages are
* deliberately large enough to overflow the list's own scroll container so
* the sentinel starts below the fold — each `scrollTo("bottom")` then
* produces a real IntersectionObserver transition, the only thing that
* makes the observer fire again. Records every cursor it serves so the
* scenario can assert none was requested twice — the tell-tale of the
* issue #38 loop, where a stale closure re-requests the same page forever
* instead of advancing.
*/
const THREE_PAGE_SIZE = 15;
const threePageCursorsRequested: string[] = [];
function threePagePage(pageNo: number, nextCursor: string | null) {
return {
items: Array.from({ length: THREE_PAGE_SIZE }, (_, index) => ({
externalId: `p${pageNo}-${index}`,
title: `Page ${pageNo} Item ${index + 1}`,
picture: null,
url: `https://www.themealdb.com/meal/p${pageNo}-${index}`,
alreadyImported: false,
recipeId: null,
})),
nextCursor,
};
}
Given("browsing TheMealDB returns three pages, each addressed by its own cursor", () => {
threePageCursorsRequested.length = 0;
const item = (externalId: string, title: string) => ({
externalId,
title,
picture: null,
url: `https://www.themealdb.com/meal/${externalId}`,
alreadyImported: false,
recipeId: null,
});
const pages: Record<string, { items: unknown[]; nextCursor: string | null }> = {
"": { items: [item("p1", "Page One Dish")], nextCursor: "cursor-2" },
"cursor-2": { items: [item("p2", "Page Two Dish")], nextCursor: "cursor-3" },
"cursor-3": { items: [item("p3", "Page Three Dish")], nextCursor: null },
const pages: Record<string, ReturnType<typeof threePagePage>> = {
"": threePagePage(1, "cursor-2"),
"cursor-2": threePagePage(2, "cursor-3"),
"cursor-3": threePagePage(3, null),
};
cy.intercept("GET", "**/sources/theMealDb/browse*", (req) => {
const cursor = new URL(req.url).searchParams.get("cursor") ?? "";
@ -163,15 +174,19 @@ Given("browsing TheMealDB returns three pages, each addressed by its own cursor"
});
});
When("I scroll the source list to the bottom", () => {
cy.get(".source-items-column .recipe-table-wrap").scrollTo("bottom", {
ensureScrollable: false,
});
// IntersectionObserver callbacks are delivered on their own task, and the
// next page's fetch + merge then needs a render — give both a beat.
cy.wait(400);
});
Then("no browse cursor should have been requested more than once", () => {
// Let the panel settle (auto-scroll + one-page-ahead prefetch) before
// asserting. A looping IntersectionObserver would re-hit "" or "cursor-2"
// here; the latest-ref fix keeps each cursor to a single request.
cy.wait(600).then(() => {
const seen = [...threePageCursorsRequested];
expect(seen.length, `cursors requested: ${JSON.stringify(seen)}`).to.equal(new Set(seen).size);
});
});
// The panel prefetches the next page as soon as page 1 is on screen (before
// anyone's actually waited on it), so the *first* request for it is that