fix(home): a shelf move acts on the order shown, one write at a time

- During a budget switch the rail shows the previous budget's payload
  (keepPreviousData) while the new key is empty; the move read that empty
  cache, found no tile and silently did nothing. It now acts on the order
  the rail rendered.
- A refused move was undone only by a re-read; when that read failed too,
  the shelf kept showing the order the user had just been told was not
  saved. Moves are now written one at a time (the move rows wait, with a
  "saving" note), which makes restoring the shown order correct — the
  overlapping-moves problem that ruled a rollback out cannot occur.
This commit is contained in:
2026-09-19 08:13:07 +02:00
parent 444f9b8166
commit c6bf84a578
4 changed files with 91 additions and 25 deletions
+46 -4
View File
@@ -210,10 +210,7 @@ describe("Home — the comfort shelf's context menu", () => {
]);
});
it("re-asks the server when a move fails instead of restoring a snapshot", async () => {
// Writing a captured `before` back was wrong twice: two overlapping moves each reverted to their
// own snapshot and the shelf settled on an order neither side held, and `before` is the WHOLE
// payload, so the rollback also discarded any rail refresh that landed mid-flight.
it("re-asks the server when a move fails", async () => {
reorderComfortPins.mockRejectedValue(new Error("nope"));
mount();
await screen.findByText("Comfort A");
@@ -223,6 +220,51 @@ describe("Home — the comfort shelf's context menu", () => {
await waitFor(() => expect(discover).toHaveBeenCalledTimes(2));
});
it("puts the shown order back when a move is refused, even if the re-read fails too", async () => {
// Only a re-read used to undo a refused move — and when that read failed for the same reason the
// write did, react-query kept the last data: the shelf showed the order the user had just been
// told was not saved. With one write at a time, the order shown before it IS the server's.
reorderComfortPins.mockRejectedValue(new Error("nope"));
mount();
await screen.findByText("Comfort A");
discover.mockRejectedValue(new Error("still down"));
fireEvent.contextMenu(screen.getByText("Comfort A"));
fireEvent.click(item("home.shelf.moveRight"));
await waitFor(() => expect(discover).toHaveBeenCalledTimes(2));
await waitFor(() =>
expect(screen.getAllByText(/^Comfort [ABC]$/).map((e) => e.textContent)).toEqual([
"Comfort A",
"Comfort B",
"Comfort C",
]),
);
});
it("writes one move at a time: the rows wait, and say so, while one is being saved", async () => {
reorderComfortPins.mockReturnValue(new Promise(() => {}));
discover.mockResolvedValueOnce(payload()).mockReturnValue(new Promise(() => {}));
mount();
fireEvent.contextMenu(await screen.findByText("Comfort A"));
fireEvent.click(item("home.shelf.moveRight"));
await waitFor(() => expect(reorderComfortPins).toHaveBeenCalledTimes(1));
fireEvent.contextMenu(screen.getByText("Comfort C"));
// The first row carries the note, so its text is its label followed by the note.
expect(item("home.shelf.moveFirst" + "home.shelf.moveSaving").disabled).toBe(true);
expect(item("home.shelf.moveLeft").disabled).toBe(true);
});
it("moves what the rail SHOWS while a new budget is still loading", async () => {
// Under keepPreviousData the rail keeps the previous budget's payload on screen while the new
// key holds nothing yet; the move read that empty cache, found no tile and did nothing.
mount();
await screen.findByText("Comfort A");
discover.mockReturnValue(new Promise(() => {})); // the new budget never arrives
fireEvent.click(screen.getByText("home.budget.short"));
fireEvent.contextMenu(screen.getByText("Comfort A"));
fireEvent.click(item("home.shelf.moveRight"));
await waitFor(() => expect(reorderComfortPins).toHaveBeenCalledWith([7, 3, 9]));
});
it("unpins with the pin id the shelf card carries", async () => {
mount();
fireEvent.contextMenu(await screen.findByText("Comfort C"));