Files
GeoLibre/tests/python-authoring-roundtrip.test.ts
T
Qiusheng Wu 49f083b334 feat(bookmarks): save bookmarks in the project (#3006)
* feat(bookmarks): save bookmarks in the project

Bookmarks lived only in the browser's localStorage, so they did not
travel with a .geolibre.json and the Python package and MCP server
could not author them.

- Project format: optional `bookmarks` and `bookmarkGroups` (folders),
  normalized on load (camera range checks, duplicate ids, bearing wrap,
  dangling folder ids dropped) and omitted when empty.
- Store: `bookmarks`, `bookmarkGroups` and `setBookmarks`; saved,
  autosaved and shared in collaboration like other project sections.
- Bookmarks panel: no longer persists to localStorage. It opens with
  the project's bookmarks, writes every edit (including host-dialog
  imports) back to the project, and rebuilds when a project load or a
  collaborator replaces them. Bookmarks saved by older versions move
  into the first project opened that has none, once.
- Python `Map.add_bookmark` / `remove_bookmark` and MCP tools
  `add_bookmark` / `remove_bookmark` (camera from the saved view by
  default, folder by id or name, layers to show), plus bookmarks in
  `describe_project`. The Python round-trip fixture now carries
  bookmarks through the app's parseProject.
- Docs: project format, Python, MCP, user guide, and the agent skill.

Part of #2869

* Address review feedback

- Trim a bookmark's groupId before matching it to the (trimmed) folder
  ids, so a hand-edited id with spaces is not silently un-filed
  (Claude review).
- Legacy migration no longer deletes the old localStorage bookmarks:
  a marker key records the one-time copy, so bookmarks copied into the
  wrong project stay recoverable from browser storage (Claude review).
- Python add_bookmark refuses a 501st bookmark before changing the
  project, matching the app's MAX_PROJECT_BOOKMARKS (500), with a drift
  test that pins the two together (CodeRabbit).

* Address review feedback

- Write the legacy-bookmark migration marker only once the project that
  received them is saved; a project discarded or replaced unsaved leaves
  it unset so the next empty project gets the copy (CodeRabbit).
- Correct the user guide: the old browser copy is left in place but no
  longer updated, not kept as a backup (Claude).
- Move the bookmarkGroups normalization above the print-layout comment so
  the comment stays attached to its code (Claude).
2026-10-06 18:20:03 -04:00

93 lines
3.6 KiB
TypeScript

import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { describe, it } from "node:test";
import {
DEFAULT_LAYER_STYLE,
normalizeLayerDescriptiveMetadata,
parseProject,
validateLayerDescriptiveMetadata,
validateMapExpression,
} from "@geolibre/core";
// Written by the geolibre Python package's authoring functions
// (python/tests/test_authoring_parity.py pins it to what they produce). Loading
// it through the app's own parseProject proves the layer filter, labels, plugin
// state and story map the Python package and MCP server write are read back
// unchanged, rather than silently normalized away.
const fixtureText = readFileSync(
new URL("./fixtures/python-authoring.geolibre.json", import.meta.url),
"utf8",
);
const fixture = JSON.parse(fixtureText);
/** Drop `undefined` members the normalizers add, so shapes compare as JSON. */
function asJson(value: unknown): unknown {
return JSON.parse(JSON.stringify(value));
}
describe("Python-authored project round trip", () => {
const project = parseProject(fixtureText);
const layer = project.layers.find((item) => item.id === "cities");
const source = fixture.layers.find((item: { id: string }) => item.id === "cities");
it("keeps the persistent layer filter", () => {
assert.ok(layer);
assert.deepEqual(layer.filterExpression, source.filterExpression);
assert.ok(
validateMapExpression(JSON.stringify(source.filterExpression), { expectedType: "boolean" })
.ok,
);
});
it("keeps the descriptive metadata, already in its normalized form", () => {
assert.ok(layer);
assert.ok(source.descriptiveMetadata);
assert.deepEqual(layer.descriptiveMetadata, source.descriptiveMetadata);
assert.deepEqual(
normalizeLayerDescriptiveMetadata(source.descriptiveMetadata),
source.descriptiveMetadata,
);
assert.deepEqual(validateLayerDescriptiveMetadata(layer.descriptiveMetadata), []);
});
it("keeps every label setting, with no key the app does not know", () => {
assert.ok(layer);
assert.deepEqual(layer.style.labels, source.style.labels);
assert.deepEqual(
Object.keys(source.style.labels).sort(),
Object.keys(DEFAULT_LAYER_STYLE.labels).sort(),
);
for (const key of ["sizeExpression", "visibilityExpression"] as const) {
assert.ok(validateMapExpression(layer.style.labels[key]).ok, key);
}
assert.ok(
validateMapExpression(layer.style.labels.visibilityExpression, { expectedType: "boolean" })
.ok,
);
});
it("keeps the plugin state, its corner, and the default plugins", () => {
assert.ok(project.plugins);
assert.deepEqual(project.plugins.settings, fixture.plugins.settings);
assert.deepEqual(project.plugins.mapControlPositions, fixture.plugins.mapControlPositions);
assert.deepEqual(project.plugins.activePluginIds, fixture.plugins.activePluginIds);
});
it("keeps the story map and its chapters exactly", () => {
assert.deepEqual(asJson(project.storymap), fixture.storymap);
assert.deepEqual(
project.storymap?.chapters.map((chapter) => chapter.id),
["overview", "close-up"],
);
});
it("keeps the bookmarks and their folder, already in normalized form", () => {
assert.ok(Array.isArray(fixture.bookmarks) && fixture.bookmarks.length === 2);
assert.deepEqual(asJson(project.bookmarks), fixture.bookmarks);
assert.deepEqual(asJson(project.bookmarkGroups), fixture.bookmarkGroups);
assert.equal(project.bookmarks?.[0].groupId, "hoods");
assert.deepEqual(project.bookmarks?.[0].extra, { visibleLayerIds: ["cities"] });
});
});