feat(map): add reference layers and stabilize workspace controls
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
|
||||
test("Cesium elevated targets keep their profile border while labels occlude them inside the scene overlay", async () => {
|
||||
const [renderer, cesiumDisplay] = await Promise.all([
|
||||
readFile(new URL("../apps/catalog/src/CesiumMapRenderer.tsx", import.meta.url), "utf8"),
|
||||
readFile(new URL("../node_modules/@cesium/engine/Source/DataSources/DataSourceDisplay.js", import.meta.url), "utf8"),
|
||||
]);
|
||||
|
||||
assert.match(renderer, /BillboardGraphics/);
|
||||
assert.match(renderer, /elevatedTargetImage\(\s*color,\s*Color\.fromCssColorString\(target\.outlineColor\)\.withAlpha\(target\.outlineOpacity\),\s*target\.outlineWidthPx,\s*target\.headSizePx/);
|
||||
assert.match(renderer, /entity\.point = undefined;\s*entity\.billboard = new BillboardGraphics/);
|
||||
assert.match(renderer, /disableDepthTestDistance: Number\.POSITIVE_INFINITY/);
|
||||
assert.match(renderer, /outlineWidth: 0,\s*style: LabelStyle\.FILL/);
|
||||
assert.doesNotMatch(renderer, /style: 2/);
|
||||
assert.ok(cesiumDisplay.indexOf("new BillboardVisualizer") < cesiumDisplay.indexOf("new LabelVisualizer"));
|
||||
});
|
||||
|
||||
test("Map Inspector open section is controlled and included in the saved page layout", async () => {
|
||||
const [inspector, preview, server, mcpSchema, manifestSchema] = await Promise.all([
|
||||
readFile(new URL("../packages/ui-react/src/Inspector.tsx", import.meta.url), "utf8"),
|
||||
readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8"),
|
||||
readFile(new URL("../server/catalog-server.mjs", import.meta.url), "utf8"),
|
||||
readFile(new URL("../server/foundry-mcp.mjs", import.meta.url), "utf8"),
|
||||
readFile(new URL("../registry/schemas/application-manifest-v0.1.schema.json", import.meta.url), "utf8"),
|
||||
]);
|
||||
|
||||
assert.match(inspector, /openSections\?: string\[\]/);
|
||||
assert.match(inspector, /onOpenSectionsChange\?: \(ids: string\[\]\) => void/);
|
||||
assert.match(inspector, /onOpenSectionsChange\?\.\(\[\.\.\.next\]\)/);
|
||||
assert.match(preview, /const \[inspectorOpenSections, setInspectorOpenSections\]/);
|
||||
assert.match(preview, /openSections=\{inspectorOpenSections\}/);
|
||||
assert.match(preview, /onOpenSectionsChange=\{setInspectorOpenSections\}/);
|
||||
assert.match(preview, /referenceLayers,\s*inspectorOpenSections,\s*subjectStates/);
|
||||
assert.match(server, /validateMapInspectorOpenSections/);
|
||||
assert.match(server, /inspectorOpenSections: \["map-base"\]/);
|
||||
assert.match(mcpSchema, /inspectorOpenSections:[\s\S]*?maxItems: 1/);
|
||||
assert.match(manifestSchema, /"inspectorOpenSections"[\s\S]*?"maxItems": 1/);
|
||||
});
|
||||
|
||||
test("canonical station profiles retain visible target borders while label text stays halo-free", async () => {
|
||||
const registry = JSON.parse(await readFile(
|
||||
new URL("../registry/map-presentation-profiles.json", import.meta.url),
|
||||
"utf8",
|
||||
));
|
||||
|
||||
for (const profile of registry.profiles) {
|
||||
assert.equal(profile.label.outlineWidthPx, 0);
|
||||
}
|
||||
const stationProfiles = registry.profiles.filter((profile) => profile.id.startsWith("map.reference.transport."));
|
||||
assert.equal(stationProfiles.length, 3);
|
||||
stationProfiles.forEach((profile) => assert.ok(profile.target.outlineWidthPx > 0));
|
||||
const zone = registry.profiles.find((profile) => profile.target.variant === "surface-fill");
|
||||
assert.ok(zone.target.outlineWidthPx > 0);
|
||||
});
|
||||
@@ -2,22 +2,22 @@ import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
|
||||
test("Objects menu treats each binding as a persisted visibility layer", async () => {
|
||||
test("Objects menu opens controllable groups from the row and keeps plain bindings as visibility layers", async () => {
|
||||
const preview = await readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.match(preview, /const toggleSubjectVisibility = \(bindingId: string\)/);
|
||||
assert.match(preview, /role="menuitemcheckbox"/);
|
||||
assert.match(preview, /aria-checked=\{visible\}/);
|
||||
assert.match(preview, /onClick=\{\(\) => toggleSubjectVisibility\(summary\.bindingId\)\}/);
|
||||
assert.match(preview, /role=\{hasControls \? "menuitem" : "menuitemcheckbox"\}/);
|
||||
assert.match(preview, /if \(hasControls\) \{\s*openSubjectWindow\(summary\.bindingId\);\s*close\(\)/);
|
||||
assert.match(preview, /toggleSubjectVisibility\(summary\.bindingId\)/);
|
||||
assert.match(preview, /visible: !state\.visible/);
|
||||
assert.match(preview, /слой скрыт · \$\{summary\.total\} объектов/);
|
||||
assert.doesNotMatch(preview, /Фильтры и счётчики: \$\{summary\.displayName\}/);
|
||||
});
|
||||
|
||||
test("a subject window exists only when the presentation profile exposes controls", async () => {
|
||||
const preview = await readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.match(preview, /function hasSubjectWindowControls\(profile: MapPresentationProfile\)/);
|
||||
assert.match(preview, /\{hasControls \? \(/);
|
||||
assert.match(preview, /if \(!state\?\.window\.open \|\| !hasSubjectWindowControls\(summary\.profile\)\) return null/);
|
||||
assert.match(preview, /window: \{ \.\.\.state\.window, open: false \}/);
|
||||
});
|
||||
@@ -28,3 +28,52 @@ test("hidden and visible layers have an explicit visual state", async () => {
|
||||
assert.match(styles, /\.catalog-map-fixture__objects-menu-item\[data-visible\]/);
|
||||
assert.match(styles, /\.catalog-map-fixture__objects-menu-item:not\(\[data-visible\]\)/);
|
||||
});
|
||||
|
||||
test("joined detail aspects do not become independent map layers", async () => {
|
||||
const preview = await readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.match(preview, /dataProductBindings\.filter\(\(binding\) => !binding\.joinToBindingId\)/);
|
||||
assert.match(preview, /runtimeBindings\.filter\(\(binding\) => primaryBindingIds\.has\(binding\.bindingId\)\)/);
|
||||
assert.match(preview, /runtimeBindings=\{\[\.\.\.primaryRuntimeBindings, \.\.\.referenceRuntimeBindings\]\}/);
|
||||
});
|
||||
|
||||
test("reference stations are first-class Objects menu layers without provider settings actions", async () => {
|
||||
const preview = await readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.match(preview, /const referenceObjectSummaries = useMemo/);
|
||||
assert.match(preview, /const objectLayerCount = presentationSummaries\.length \+ referenceObjectSummaries\.length/);
|
||||
assert.match(preview, /referenceObjectSummaries\.map\(\(\{ layer, displayName, total \}\)/);
|
||||
assert.match(preview, /candidate\.id === layer\.id \? \{ \.\.\.candidate, visible: !candidate\.visible \} : candidate/);
|
||||
assert.match(preview, /role="menuitemcheckbox"/);
|
||||
});
|
||||
|
||||
test("facet window auto-fits expanded and collapsed content inside map workspace", async () => {
|
||||
const [preview, workspace] = await Promise.all([
|
||||
readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8"),
|
||||
readFile(new URL("../packages/ui-react/src/WorkspaceWindow.tsx", import.meta.url), "utf8"),
|
||||
]);
|
||||
|
||||
assert.match(preview, /minHeight=\{220\}\s*autoHeight/);
|
||||
assert.match(workspace, /autoHeight\?: boolean/);
|
||||
assert.match(workspace, /content\.scrollHeight/);
|
||||
assert.match(workspace, /availableHeight = Math\.max\(0, nextBounds\.height - normalized\.y\)/);
|
||||
assert.match(workspace, /new ResizeObserver\(scheduleFit\)/);
|
||||
});
|
||||
|
||||
test("facet tree selection focuses the subject without resetting a compatible detail tab", async () => {
|
||||
const preview = await readFile(new URL("../apps/catalog/src/MapFixturePreview.tsx", import.meta.url), "utf8");
|
||||
const renderer = await readFile(new URL("../apps/catalog/src/CesiumMapRenderer.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.match(preview, /const \[expandedFacetRows, setExpandedFacetRows\]/);
|
||||
assert.match(preview, /const handleSelectAndFocus = useCallback/);
|
||||
assert.match(preview, /mapRendererRef\.current\?\.focusRuntimeEntity\(entityId\)/);
|
||||
assert.match(preview, /setSubjectCardTabId\(\(current\) =>/);
|
||||
assert.match(preview, /profile\?\.tabs\.some\(\(tab\) => tab\.id === current\)/);
|
||||
assert.match(preview, /\(profile\?\.defaultTabId \?\? "overview"\)/);
|
||||
assert.match(renderer, /camera\.pickEllipsoid/);
|
||||
assert.match(renderer, /Cartesian3\.subtract\(camera\.position, viewportCenter/);
|
||||
assert.match(renderer, /heading: camera\.heading/);
|
||||
assert.match(renderer, /pitch: camera\.pitch/);
|
||||
assert.match(renderer, /roll: camera\.roll/);
|
||||
assert.match(renderer, /duration: 2\.1/);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
|
||||
const root = new URL("../", import.meta.url);
|
||||
|
||||
test("Map Page registers a provider-neutral reference-points slot and three station profiles", async () => {
|
||||
const pages = JSON.parse(await readFile(new URL("registry/pages.json", root), "utf8"));
|
||||
const profiles = JSON.parse(await readFile(new URL("registry/map-presentation-profiles.json", root), "utf8"));
|
||||
const mapPage = pages.templates.find((template) => template.id === "map");
|
||||
const stationProfiles = profiles.profiles.filter((profile) => profile.id.startsWith("map.reference.transport."));
|
||||
|
||||
assert.ok(mapPage.slots.some((slot) => slot.id === "reference-points" && slot.kind === "entity-stream"));
|
||||
assert.deepEqual(
|
||||
stationProfiles.map((profile) => profile.id).sort(),
|
||||
[
|
||||
"map.reference.transport.metro.v1",
|
||||
"map.reference.transport.railway-station.v1",
|
||||
"map.reference.transport.terminal.v1",
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
new Set(stationProfiles.flatMap((profile) => profile.semanticTypes)),
|
||||
new Set(["map.station", "map.terminal"]),
|
||||
);
|
||||
assert.ok(stationProfiles.every((profile) => profile.target.variant === "elevated-spike"));
|
||||
});
|
||||
|
||||
test("existing layouts upgrade reference layers on read and renderer accepts their slot", async () => {
|
||||
const server = await readFile(new URL("server/catalog-server.mjs", root), "utf8");
|
||||
const renderer = await readFile(new URL("apps/catalog/src/CesiumMapRenderer.tsx", root), "utf8");
|
||||
const runtime = await readFile(new URL("apps/catalog/src/useMapReferenceRuntime.ts", root), "utf8");
|
||||
|
||||
assert.match(server, /pageLayoutMatch\[1\] === "map" \? validateMapPageLayout\(stored\) : stored/);
|
||||
assert.match(server, /referenceLayers: structuredClone\(canonicalMapReferenceLayers\)/);
|
||||
assert.match(renderer, /binding\.slotId === "reference-points"/);
|
||||
assert.match(runtime, /\/api\/map-gateway\/api\/map\/reference-sources\/v1\/profiles\//);
|
||||
assert.match(runtime, /allowedAttributes = new Set\(\["name", "category", "network", "operator", "official_name", "local_name", "uic_ref", "wheelchair"\]\)/);
|
||||
assert.doesNotMatch(runtime, /credential|accessToken|providerEndpoint|rawPayload/);
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { RangeControl } from "../packages/ui-react/dist/index.js";
|
||||
|
||||
test("RangeControl keeps native drag semantics and exposes one persistent exact-value editor", () => {
|
||||
const markup = renderToStaticMarkup(createElement(RangeControl, {
|
||||
label: "Размер головки",
|
||||
value: 20,
|
||||
min: 1,
|
||||
max: 32,
|
||||
step: 1,
|
||||
formatValue: (value) => `${value} px`,
|
||||
onChange: () => {},
|
||||
}));
|
||||
|
||||
assert.match(markup, /type="range"/);
|
||||
assert.match(markup, /aria-valuetext="20 px"/);
|
||||
assert.match(markup, /class="nodedc-range__editor"/);
|
||||
assert.match(markup, /aria-label="Размер головки: точное значение"/);
|
||||
assert.doesNotMatch(markup, /nodedc-range__value-hit/);
|
||||
});
|
||||
|
||||
test("exact editing is a stable native caret field without remount, forced selection or a dark editor box", async () => {
|
||||
const [component, styles, windowComponent] = await Promise.all([
|
||||
readFile(new URL("../packages/ui-react/src/RangeControl.tsx", import.meta.url), "utf8"),
|
||||
readFile(new URL("../packages/ui-core/styles.css", import.meta.url), "utf8"),
|
||||
readFile(new URL("../packages/ui-react/src/Window.tsx", import.meta.url), "utf8"),
|
||||
]);
|
||||
|
||||
assert.match(component, /normalizeEditedRangeValue\(parsed, min, max, step\)/);
|
||||
assert.match(component, /event\.key === "Enter"/);
|
||||
assert.match(component, /event\.key === "Escape"/);
|
||||
assert.match(component, /event\.stopPropagation\(\)/);
|
||||
assert.match(component, /cancelNextBlurRef/);
|
||||
assert.match(component, /event\.currentTarget\.blur\(\)/);
|
||||
assert.match(component, /onBlur=\{\(\) => \{/);
|
||||
assert.doesNotMatch(component, /\.select\(\)/);
|
||||
assert.doesNotMatch(component, /setSelectionRange/);
|
||||
assert.doesNotMatch(component, /\{editing \? \(\s*<input/);
|
||||
assert.match(styles, /\.nodedc-range input\[type="range"\][\s\S]*?z-index: 4/);
|
||||
assert.match(styles, /\.nodedc-range__editor[\s\S]*?z-index: 5[\s\S]*?background: transparent[\s\S]*?color: transparent[\s\S]*?caret-color: transparent[\s\S]*?box-shadow: none/);
|
||||
assert.match(styles, /\.nodedc-range__editor\[data-active\][\s\S]*?caret-color: currentColor/);
|
||||
assert.match(windowComponent, /const onCloseRef = useRef\(onClose\)/);
|
||||
assert.match(windowComponent, /onCloseRef\.current = onClose/);
|
||||
assert.match(windowComponent, /onCloseRef\.current\(\)/);
|
||||
assert.doesNotMatch(windowComponent, /\[closeOnEscape, onClose, open, shouldLockBodyScroll, shouldTrapFocus\]/);
|
||||
});
|
||||
Reference in New Issue
Block a user