Deleting a note moved everything else. A row leaving is the same problem as a row arriving, in reverse: a line's position counts the rows above it, so a note deleted above a line left the line counting rows that were no longer there, and everything under it appeared to jump up past it. withoutRow renumbers the list a row leaves, and every place one leaves says so -- the bin, a move, a chapter being removed. Arrivals land at the top: an old position belongs to an arrangement that has moved on. A line could not be dragged under another line. Two lines in the same gap carry the same position, and the comparator claimed "a separator comes first" without asking what the other one was -- both directions at once for that case. A comparator that contradicts itself has no defined result, so the sort returned them in whichever order it happened to walk. A chapter is always in the user's own order and can be arranged like any other list: its notes wear handles under the same switch as the list beside them and are dragged among themselves, strips at both ends included. The two lists keep apart by scope -- a chapter unfolded under its row holds rows inside a row of the notebook's list, so the nearest row above the pointer cannot say which list a drag belongs to. The sort menu is gone inside a chapter: the mode belongs to the notebook, and a menu there would offer a choice that cannot be honoured. The chapters suite no longer assumes the first chapter row is the one that matters. A chapter is a row in the arrangement and can be anywhere in it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
349 lines
14 KiB
TypeScript
349 lines
14 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import {
|
|
END_ID,
|
|
START_ID,
|
|
canReorder,
|
|
mergeEntries,
|
|
placeOnTop,
|
|
reindex,
|
|
reindexEntries,
|
|
sortModesFor,
|
|
sortNotes,
|
|
withNewSeparator,
|
|
withoutRow,
|
|
} from "./noteSort";
|
|
import type { NoteMeta } from "../../types";
|
|
|
|
const note = (id: string, over: Partial<NoteMeta> = {}): NoteMeta => ({
|
|
id,
|
|
title: id,
|
|
folderId: "nb",
|
|
starred: false,
|
|
trashed: false,
|
|
createdAt: "2026-01-01T00:00:00.000Z",
|
|
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
...over,
|
|
});
|
|
|
|
const ids = (list: NoteMeta[]) => list.map((n) => n.id);
|
|
|
|
describe("sortNotes", () => {
|
|
const notes = [
|
|
note("b", { updatedAt: "2026-05-02T00:00:00Z", createdAt: "2026-01-03T00:00:00Z", sortOrder: 2 }),
|
|
note("a", { updatedAt: "2026-05-03T00:00:00Z", createdAt: "2026-01-01T00:00:00Z", sortOrder: 1 }),
|
|
note("c", { updatedAt: "2026-05-01T00:00:00Z", createdAt: "2026-01-02T00:00:00Z", sortOrder: 0 }),
|
|
];
|
|
|
|
it("puts the most recently changed first", () => {
|
|
expect(ids(sortNotes(notes, "updated"))).toEqual(["a", "b", "c"]);
|
|
});
|
|
|
|
it("reverses that for oldest", () => {
|
|
expect(ids(sortNotes(notes, "oldest"))).toEqual(["c", "b", "a"]);
|
|
});
|
|
|
|
it("sorts by creation date, which is not the same as by change date", () => {
|
|
expect(ids(sortNotes(notes, "created"))).toEqual(["b", "c", "a"]);
|
|
});
|
|
|
|
it("follows the stored positions when manual", () => {
|
|
expect(ids(sortNotes(notes, "manual"))).toEqual(["c", "a", "b"]);
|
|
});
|
|
|
|
it("does not mutate the list it was given", () => {
|
|
const original = [...notes];
|
|
sortNotes(notes, "title");
|
|
expect(notes).toEqual(original);
|
|
});
|
|
|
|
it("sorts titles the way German expects, ignoring case and numbering", () => {
|
|
const list = [
|
|
note("3", { title: "Übung 10" }),
|
|
note("1", { title: "übung 2" }),
|
|
note("2", { title: "Alpha" }),
|
|
];
|
|
expect(sortNotes(list, "title").map((n) => n.title)).toEqual(["Alpha", "übung 2", "Übung 10"]);
|
|
});
|
|
|
|
it("orders notes that have never been dragged newest first, not arbitrarily", () => {
|
|
// Everything imported at once carries position 0; without a tie-break the
|
|
// list would come out in whatever order the store happened to return.
|
|
const fresh = [
|
|
note("old", { updatedAt: "2026-01-01T00:00:00Z" }),
|
|
note("new", { updatedAt: "2026-06-01T00:00:00Z" }),
|
|
note("mid", { updatedAt: "2026-03-01T00:00:00Z" }),
|
|
];
|
|
expect(ids(sortNotes(fresh, "manual"))).toEqual(["new", "mid", "old"]);
|
|
});
|
|
|
|
it("is stable across repeated sorts of an entirely tied list", () => {
|
|
const tied = [note("z"), note("m"), note("a")];
|
|
const once = ids(sortNotes(tied, "manual"));
|
|
expect(ids(sortNotes([...tied].reverse(), "manual"))).toEqual(once);
|
|
});
|
|
});
|
|
|
|
describe("reindex", () => {
|
|
const all = [
|
|
note("a", { sortOrder: 0 }),
|
|
note("b", { sortOrder: 1 }),
|
|
note("c", { sortOrder: 2 }),
|
|
note("d", { sortOrder: 3 }),
|
|
];
|
|
|
|
/* Under, always under. Both directions meant every gap could be aimed at from
|
|
two sides, which is what made a drag hard to predict. */
|
|
it("moves a note under the one it was dropped on", () => {
|
|
expect(reindex(all, "d", "b")).toEqual(["a", "b", "d", "c"]);
|
|
});
|
|
|
|
it("works upwards as well as downwards", () => {
|
|
expect(reindex(all, "a", "c")).toEqual(["b", "c", "a", "d"]);
|
|
});
|
|
|
|
it("reports nothing to do when a note is dropped on itself", () => {
|
|
expect(reindex(all, "b", "b")).toBeNull();
|
|
});
|
|
|
|
it("reports nothing to do for a note that is not in the notebook", () => {
|
|
expect(reindex(all, "ghost", "b")).toBeNull();
|
|
});
|
|
|
|
/* The point of the whole design: an arrangement has to survive being looked
|
|
* away from. This is the case that used to go wrong in note apps — reorder
|
|
* against a filtered list and the hidden notes keep positions that now
|
|
* collide with the visible ones. */
|
|
it("keeps the arrangement intact when the view was filtered in between", () => {
|
|
const arranged = reindex(all, "d", "a")!;
|
|
const positioned = all.map((n) => ({ ...n, sortOrder: arranged.indexOf(n.id) }));
|
|
|
|
// The user searches, sees only two notes, then goes back to manual order.
|
|
const filtered = positioned.filter((n) => n.id === "b" || n.id === "d");
|
|
expect(ids(sortNotes(filtered, "manual"))).toEqual(["d", "b"]);
|
|
|
|
// Clearing the search must show exactly what was arranged.
|
|
expect(ids(sortNotes(positioned, "manual"))).toEqual(arranged);
|
|
});
|
|
|
|
it("renumbers every note in the notebook, not only the ones on screen", () => {
|
|
const order = reindex(all, "c", "a")!;
|
|
expect(order).toHaveLength(all.length);
|
|
expect(new Set(order)).toEqual(new Set(ids(all)));
|
|
});
|
|
});
|
|
|
|
describe("canReorder", () => {
|
|
it("allows dragging only in manual order", () => {
|
|
expect(canReorder("manual", false)).toBe(true);
|
|
expect(canReorder("updated", false)).toBe(false);
|
|
});
|
|
|
|
it("refuses while a search is narrowing the list", () => {
|
|
expect(canReorder("manual", true)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("sortModesFor", () => {
|
|
it("offers a hand-made order inside a notebook", () => {
|
|
expect(sortModesFor(true)).toContain("manual");
|
|
});
|
|
|
|
it("does not offer one in a view, which has nowhere to keep it", () => {
|
|
// "All Notes" is a question asked of every notebook, not a place — there is
|
|
// nothing for an arrangement to belong to.
|
|
expect(sortModesFor(false)).not.toContain("manual");
|
|
expect(sortModesFor(false)).toContain("updated");
|
|
});
|
|
});
|
|
|
|
describe("separators", () => {
|
|
const notes = [note("a", { sortOrder: 0 }), note("b", { sortOrder: 1 }), note("c", { sortOrder: 2 })];
|
|
const kinds = (list: ReturnType<typeof mergeEntries>) => list.map((e) => `${e.kind[0]}:${e.id}`);
|
|
|
|
it("sits between the notes it was placed between", () => {
|
|
const merged = mergeEntries(notes, [{ id: "s1", label: "Später", order: 2 }]);
|
|
expect(kinds(merged)).toEqual(["n:a", "n:b", "s:s1", "n:c"]);
|
|
});
|
|
|
|
it("comes before the note it shares a position with, so it reads as a header", () => {
|
|
const merged = mergeEntries(notes, [{ id: "s1", label: "", order: 0 }]);
|
|
expect(kinds(merged)[0]).toBe("s:s1");
|
|
});
|
|
|
|
it("still appears when the notebook has shrunk beneath it", () => {
|
|
// Its label is the user's work; dropping it silently because two notes were
|
|
// deleted would be worse than showing it at the end.
|
|
const merged = mergeEntries(notes, [{ id: "s1", label: "Alt", order: 99 }]);
|
|
expect(kinds(merged)[merged.length - 1]).toBe("s:s1");
|
|
});
|
|
|
|
it("is not pushed behind notes that have never been dragged", () => {
|
|
// Untouched notes all carry position 0. Merging on the stored numbers rather
|
|
// than on rank would pile them all at 0 and put every separator last.
|
|
const fresh = [note("x"), note("y"), note("z")];
|
|
const merged = mergeEntries(fresh, [{ id: "s1", label: "", order: 1 }]);
|
|
expect(kinds(merged)[1]).toBe("s:s1");
|
|
});
|
|
|
|
it("keeps its place as the notes around it are edited", () => {
|
|
const separators = [{ id: "s1", label: "Archiv", order: 2 }];
|
|
const touched = notes.map((n) => (n.id === "a" ? { ...n, updatedAt: "2030-01-01T00:00:00Z" } : n));
|
|
expect(kinds(mergeEntries(touched, separators))).toEqual(["n:a", "n:b", "s:s1", "n:c"]);
|
|
});
|
|
|
|
it("is added at the top, where it can be seen and then moved", () => {
|
|
const added = withNewSeparator([{ id: "old", label: "", order: 3 }], "new");
|
|
expect(added[0]!.id).toBe("new");
|
|
expect(added[0]!.order).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("reindexEntries", () => {
|
|
const notes = [note("a", { sortOrder: 0 }), note("b", { sortOrder: 1 }), note("c", { sortOrder: 2 })];
|
|
const separators = [{ id: "s1", label: "Teil 2", order: 1 }];
|
|
|
|
it("moves a note across a separator and leaves the line above the same note", () => {
|
|
// a │ ─── │ b │ c, and c is dropped under a. The line still sits above b;
|
|
// its number counts the rows before it, and there is one more of those now.
|
|
const next = reindexEntries(notes, separators, "c", "a")!;
|
|
expect(next.noteIds).toEqual(["a", "c", "b"]);
|
|
expect(next.separators[0]!.order).toBe(2);
|
|
});
|
|
|
|
it("moves a separator itself", () => {
|
|
const next = reindexEntries(notes, separators, "s1", START_ID)!;
|
|
expect(next.separators[0]!.order).toBe(0);
|
|
expect(next.noteIds).toEqual(["a", "b", "c"]);
|
|
});
|
|
|
|
it("sends a row to the top when dropped on the strip above the first one", () => {
|
|
// The one place a drop under a row cannot reach.
|
|
expect(reindexEntries(notes, [], "c", START_ID)!.noteIds).toEqual(["c", "a", "b"]);
|
|
});
|
|
|
|
it("sends a row to the end when dropped below the last one", () => {
|
|
expect(reindexEntries(notes, [], "a", END_ID)!.noteIds).toEqual(["b", "c", "a"]);
|
|
});
|
|
|
|
it("reports nothing to do for a row that is not there", () => {
|
|
expect(reindexEntries(notes, separators, "ghost", "a")).toBeNull();
|
|
expect(reindexEntries(notes, separators, "a", "ghost")).toBeNull();
|
|
});
|
|
|
|
it("leaves the separators untouched when only notes moved past each other", () => {
|
|
const next = reindexEntries(notes, [{ id: "s1", label: "x", order: 0 }], "c", "b")!;
|
|
expect(next.separators).toEqual([{ id: "s1", label: "x", order: 0 }]);
|
|
});
|
|
});
|
|
|
|
describe("placeOnTop", () => {
|
|
/* A notebook someone has arranged: two notes with a line between them, and a
|
|
chapter above both. The numbers are what a drag left behind. */
|
|
const notes = [note("a", { sortOrder: 1 }), note("b", { sortOrder: 2 })];
|
|
const chapters = [{ id: "ch", notebookId: "nb", name: "Kapitel", sortOrder: 0 }] as never;
|
|
const separators = [{ id: "s1", label: "Teil 2", order: 2 }];
|
|
const kinds = (list: ReturnType<typeof mergeEntries>) => list.map((e) => `${e.kind[0]}:${e.id}`);
|
|
|
|
const fresh = [note("neu", { sortOrder: 0 }), ...notes];
|
|
const arranged = placeOnTop(fresh, separators, chapters, "neu")!;
|
|
|
|
it("puts the new note above everything, lines and chapters included", () => {
|
|
expect(arranged.notes[0]).toEqual({ id: "neu", order: 0 });
|
|
expect(arranged.chapters).toEqual([{ id: "ch", order: 1 }]);
|
|
});
|
|
|
|
it("leaves every other row in the order it was in", () => {
|
|
expect(arranged.noteIds).toEqual(["neu", "a", "b"]);
|
|
});
|
|
|
|
/* The whole point. A separator's number counts the rows above it, so a row
|
|
appearing at the top without a renumber leaves every line one row too high
|
|
— which is how a notebook came apart a little more with each new note. */
|
|
it("moves the lines down with the rows they sat above", () => {
|
|
expect(arranged.separators[0]!.order).toBe(3);
|
|
// Drawn again from exactly what was written back.
|
|
const after = [note("neu", { sortOrder: 0 }), note("a", { sortOrder: 2 }), note("b", { sortOrder: 3 })];
|
|
const movedChapters = [{ id: "ch", notebookId: "nb", name: "Kapitel", sortOrder: 1 }] as never;
|
|
expect(kinds(mergeEntries(after, arranged.separators, movedChapters))).toEqual([
|
|
"n:neu",
|
|
"c:ch",
|
|
"n:a",
|
|
"s:s1",
|
|
"n:b",
|
|
]);
|
|
});
|
|
|
|
it("reports nothing to do for a note that is not in the list", () => {
|
|
expect(placeOnTop(notes, separators, chapters, "ghost")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("two lines in the same gap", () => {
|
|
/* A separator's position counts the rows above it, so two lines with nothing
|
|
between them carry the same number. Which of them comes first is then only
|
|
the order they are written in — and the comparator used to claim *both*
|
|
directions for that case, so a line dragged under another sprang back. */
|
|
const notes = [note("a", { sortOrder: 0 }), note("b", { sortOrder: 1 })];
|
|
const kinds = (list: ReturnType<typeof mergeEntries>) => list.map((e) => `${e.kind[0]}:${e.id}`);
|
|
|
|
it("keeps the order they were written in", () => {
|
|
const two = [
|
|
{ id: "s1", label: "Erst", order: 1 },
|
|
{ id: "s2", label: "Dann", order: 1 },
|
|
];
|
|
expect(kinds(mergeEntries(notes, two))).toEqual(["n:a", "s:s1", "s:s2", "n:b"]);
|
|
expect(kinds(mergeEntries(notes, [two[1]!, two[0]!]))).toEqual(["n:a", "s:s2", "s:s1", "n:b"]);
|
|
});
|
|
|
|
it("can be swapped by dragging one under the other", () => {
|
|
const two = [
|
|
{ id: "s1", label: "Erst", order: 1 },
|
|
{ id: "s2", label: "Dann", order: 1 },
|
|
];
|
|
const next = reindexEntries(notes, two, "s1", "s2")!;
|
|
expect(next.separators.map((s) => s.id)).toEqual(["s2", "s1"]);
|
|
expect(kinds(mergeEntries(notes, next.separators))).toEqual(["n:a", "s:s2", "s:s1", "n:b"]);
|
|
});
|
|
});
|
|
|
|
describe("withoutRow", () => {
|
|
/* a │ b │ ─── │ c: deleting a note above the line used to leave the line
|
|
counting rows that were no longer there, so everything under it appeared to
|
|
jump up past it. */
|
|
const notes = [note("a", { sortOrder: 0 }), note("b", { sortOrder: 1 }), note("c", { sortOrder: 2 })];
|
|
const separators = [{ id: "s1", label: "Teil 2", order: 2 }];
|
|
const kinds = (list: ReturnType<typeof mergeEntries>) => list.map((e) => `${e.kind[0]}:${e.id}`);
|
|
|
|
it("closes the gap and takes the lines down with it", () => {
|
|
const next = withoutRow(notes, separators, [], "a")!;
|
|
expect(next.noteIds).toEqual(["b", "c"]);
|
|
expect(next.separators[0]!.order).toBe(1);
|
|
const left = [note("b", { sortOrder: 0 }), note("c", { sortOrder: 1 })];
|
|
expect(kinds(mergeEntries(left, next.separators))).toEqual(["n:b", "s:s1", "n:c"]);
|
|
});
|
|
|
|
it("leaves a line that was already above the gap where it was", () => {
|
|
const next = withoutRow(notes, [{ id: "s1", label: "", order: 1 }], [], "c")!;
|
|
expect(next.separators[0]!.order).toBe(1);
|
|
});
|
|
|
|
it("reports nothing to do for a row that is not in the list", () => {
|
|
expect(withoutRow(notes, separators, [], "ghost")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("placeOnTop with several notes", () => {
|
|
/* Removing a chapter frees its notes into the notebook. They arrive together,
|
|
at the top, in the order they had inside it. */
|
|
const notes = [note("x", { sortOrder: 0 }), note("y", { sortOrder: 1 })];
|
|
const freed = [note("p"), note("q")];
|
|
const separators = [{ id: "s1", label: "", order: 1 }];
|
|
|
|
it("puts them all in front, in the order given", () => {
|
|
const next = placeOnTop([...freed, ...notes], separators, [], ["p", "q"])!;
|
|
expect(next.noteIds).toEqual(["p", "q", "x", "y"]);
|
|
expect(next.separators[0]!.order).toBe(3);
|
|
});
|
|
});
|