From 02502c990ce6a0739349f2be76909dcfbb778b66 Mon Sep 17 00:00:00 2001 From: Sergey Matyunin Date: Thu, 13 Aug 2026 14:22:30 +0300 Subject: [PATCH] feat: add labs and isometric geometry core Issue: #89 User-Visible: no --- src/iso-projection.ts | 113 +++++++++++++++++++ src/iso-walls.ts | 128 ++++++++++++++++++++++ src/labs.ts | 206 +++++++++++++++++++++++++++++++++++ test/iso-projection.test.mjs | 61 +++++++++++ test/iso-walls.test.mjs | 44 ++++++++ test/labs.test.mjs | 51 +++++++++ tsconfig.test.json | 1 + 7 files changed, 604 insertions(+) create mode 100644 src/iso-projection.ts create mode 100644 src/iso-walls.ts create mode 100644 src/labs.ts create mode 100644 test/iso-projection.test.mjs create mode 100644 test/iso-walls.test.mjs create mode 100644 test/labs.test.mjs diff --git a/src/iso-projection.ts b/src/iso-projection.ts new file mode 100644 index 00000000..52f0264a --- /dev/null +++ b/src/iso-projection.ts @@ -0,0 +1,113 @@ +export type PlanPoint = readonly [number, number]; +export type ScenePoint = readonly [number, number]; + +export interface IsoCamera { + rotDeg: number; + tiltDeg: number; + xyScale: number; + zScale: number; + origin: PlanPoint; +} + +export interface ViewRect { x: number; y: number; w: number; h: number; } + +export interface IsoFrameInput { + rect: ViewRect; + wallHeight: number; +} + +export const ISO_CAMERA: Readonly = Object.freeze({ + rotDeg: 0, + tiltDeg: 20, + xyScale: 1, + zScale: 1, + origin: Object.freeze([500, 500]) as PlanPoint, +}); + +export const ISO_WALL_HEIGHT = 64; + +function finiteCamera(camera: IsoCamera): boolean { + return [camera.rotDeg, camera.tiltDeg, camera.xyScale, camera.zScale, + camera.origin[0], camera.origin[1]].every(Number.isFinite) + && Math.abs(camera.xyScale) > 1e-12 && Math.abs(Math.cos(camera.tiltDeg * Math.PI / 180)) > 1e-12; +} + +export function projectPlanPoint( + point: PlanPoint, zUnits: number, camera: IsoCamera = ISO_CAMERA, +): ScenePoint { + if (!finiteCamera(camera) || !Number.isFinite(point[0]) || !Number.isFinite(point[1]) + || !Number.isFinite(zUnits)) throw new Error('invalid isometric projection input'); + const rot = camera.rotDeg * Math.PI / 180; + const tilt = camera.tiltDeg * Math.PI / 180; + const dx = point[0] - camera.origin[0], dy = point[1] - camera.origin[1]; + const rx = (dx * Math.cos(rot) - dy * Math.sin(rot)) * camera.xyScale; + const ry = (dx * Math.sin(rot) + dy * Math.cos(rot)) * camera.xyScale; + return [ + camera.origin[0] + rx, + camera.origin[1] + ry * Math.cos(tilt) - zUnits * camera.zScale * Math.sin(tilt), + ]; +} + +export function unprojectFloorPoint( + point: ScenePoint, camera: IsoCamera = ISO_CAMERA, +): PlanPoint { + if (!finiteCamera(camera) || !Number.isFinite(point[0]) || !Number.isFinite(point[1])) + throw new Error('invalid isometric projection input'); + const rot = camera.rotDeg * Math.PI / 180; + const tilt = camera.tiltDeg * Math.PI / 180; + const rx = (point[0] - camera.origin[0]) / camera.xyScale; + const ry = (point[1] - camera.origin[1]) / (camera.xyScale * Math.cos(tilt)); + return [ + camera.origin[0] + rx * Math.cos(rot) + ry * Math.sin(rot), + camera.origin[1] - rx * Math.sin(rot) + ry * Math.cos(rot), + ]; +} + +export function isoFloorMatrix(camera: IsoCamera = ISO_CAMERA): readonly [number, number, number, number, number, number] { + if (!finiteCamera(camera)) throw new Error('invalid isometric camera'); + const rot = camera.rotDeg * Math.PI / 180; + const tilt = camera.tiltDeg * Math.PI / 180; + const a = camera.xyScale * Math.cos(rot); + const c = -camera.xyScale * Math.sin(rot); + const b = camera.xyScale * Math.sin(rot) * Math.cos(tilt); + const d = camera.xyScale * Math.cos(rot) * Math.cos(tilt); + const e = camera.origin[0] - a * camera.origin[0] - c * camera.origin[1]; + const f = camera.origin[1] - b * camera.origin[0] - d * camera.origin[1]; + return [a, b, c, d, e, f]; +} + +export function isoFloorMatrixCss(camera: IsoCamera = ISO_CAMERA): string { + return `matrix(${isoFloorMatrix(camera).map((value) => Number(value.toFixed(12))).join(' ')})`; +} + +export function clientToScenePoint( + client: PlanPoint, + stageRect: Pick, + view: ViewRect, +): ScenePoint { + if (!(stageRect.width > 0) || !(stageRect.height > 0) || !(view.w > 0) || !(view.h > 0)) + throw new Error('invalid client/scene frame'); + return [ + view.x + ((client[0] - stageRect.left) / stageRect.width) * view.w, + view.y + ((client[1] - stageRect.top) / stageRect.height) * view.h, + ]; +} + +export function projectedFrame( + input: IsoFrameInput, camera: IsoCamera = ISO_CAMERA, +): ViewRect { + const { rect, wallHeight } = input; + if (!(rect.w >= 0) || !(rect.h >= 0) || !Number.isFinite(wallHeight)) + throw new Error('invalid isometric frame'); + const corners: PlanPoint[] = [ + [rect.x, rect.y], [rect.x + rect.w, rect.y], + [rect.x + rect.w, rect.y + rect.h], [rect.x, rect.y + rect.h], + ]; + const points = corners.flatMap((point) => [ + projectPlanPoint(point, 0, camera), projectPlanPoint(point, wallHeight, camera), + ]); + const xs = points.map((point) => point[0]), ys = points.map((point) => point[1]); + const x = Math.min(...xs), y = Math.min(...ys); + return { x, y, w: Math.max(...xs) - x, h: Math.max(...ys) - y }; +} + diff --git a/src/iso-walls.ts b/src/iso-walls.ts new file mode 100644 index 00000000..d71115f6 --- /dev/null +++ b/src/iso-walls.ts @@ -0,0 +1,128 @@ +import { + ISO_CAMERA, ISO_WALL_HEIGHT, projectPlanPoint, + type IsoCamera, type PlanPoint, type ScenePoint, +} from './iso-projection'; + +export interface IsoWallFace { + d: string; + depth: number; + polygon: number; + ring: number; + edge: number; +} + +export interface IsoWallGeometry { + topPath: string; + sides: readonly IsoWallFace[]; + edgeCount: number; +} + +const pointText = (point: ScenePoint): string => + `${Number(point[0].toFixed(4))} ${Number(point[1].toFixed(4))}`; + +function openRing(raw: any): PlanPoint[] { + const points = (Array.isArray(raw) ? raw : []) + .filter((point) => Array.isArray(point) && Number.isFinite(point[0]) && Number.isFinite(point[1])) + .map((point) => [Number(point[0]), Number(point[1])] as PlanPoint); + const last = points[points.length - 1]; + if (points.length > 1 && points[0][0] === last[0] && points[0][1] === last[1]) + points.pop(); + return points; +} + +function signedArea(points: readonly PlanPoint[]): number { + let area = 0; + for (let index = 0; index < points.length; index++) { + const point = points[index], next = points[(index + 1) % points.length]; + area += point[0] * next[1] - next[0] * point[1]; + } + return area / 2; +} + +/** Outer rings become positive, holes negative; solid always stays to the left. */ +function normalizedRing(raw: any, hole: boolean): PlanPoint[] { + const points = openRing(raw); + if (points.length < 3) return []; + const wantPositive = !hole; + return (signedArea(points) > 0) === wantPositive ? points : [...points].reverse(); +} + +function topRingPath(points: readonly PlanPoint[], camera: IsoCamera, height: number): string { + return points.length + ? `M ${points.map((point) => pointText(projectPlanPoint(point, height, camera))).join(' L ')} Z` + : ''; +} + +function visibleNormalY(a: PlanPoint, b: PlanPoint, camera: IsoCamera): number { + // Normal points to the right of a normalized edge, away from the solid. + const nx = b[1] - a[1], ny = -(b[0] - a[0]); + const rot = camera.rotDeg * Math.PI / 180; + return nx * Math.sin(rot) + ny * Math.cos(rot); +} + +/** Build one top surface and at most one side per canonical ring edge: O(E). */ +export function buildIsoWallGeometry( + geometry: any, + camera: IsoCamera = ISO_CAMERA, + height = ISO_WALL_HEIGHT, +): IsoWallGeometry { + if (!Number.isFinite(height) || height < 0) throw new Error('invalid wall height'); + const tops: string[] = []; + const sides: IsoWallFace[] = []; + let edgeCount = 0; + for (let polygon = 0; polygon < (geometry || []).length; polygon++) { + const source = geometry[polygon]; + for (let ring = 0; ring < (source || []).length; ring++) { + const points = normalizedRing(source[ring], ring > 0); + if (points.length < 3 || Math.abs(signedArea(points)) < 1e-9) continue; + tops.push(topRingPath(points, camera, height)); + edgeCount += points.length; + for (let edge = 0; edge < points.length; edge++) { + const a = points[edge], b = points[(edge + 1) % points.length]; + if (visibleNormalY(a, b, camera) <= 1e-9) continue; + const floorA = projectPlanPoint(a, 0, camera); + const floorB = projectPlanPoint(b, 0, camera); + const topB = projectPlanPoint(b, height, camera); + const topA = projectPlanPoint(a, height, camera); + sides.push({ + d: `M ${pointText(floorA)} L ${pointText(floorB)} L ${pointText(topB)} L ${pointText(topA)} Z`, + depth: Math.max(floorA[1], floorB[1]), polygon, ring, edge, + }); + } + } + } + sides.sort((a, b) => a.depth - b.depth || a.polygon - b.polygon + || a.ring - b.ring || a.edge - b.edge); + return { topPath: tops.join(' '), sides, edgeCount }; +} + +function mixHash(hash: number, value: number): number { + hash ^= Math.round((Number.isFinite(value) ? value : 0) * 64); + return Math.imul(hash, 0x01000193) >>> 0; +} + +/** Content fingerprint: intentionally independent of config epochs and HA state. */ +export function isoGeometryFingerprint(value: unknown): string { + let hash = 0x811c9dc5; + const visit = (item: unknown): void => { + if (typeof item === 'number') { hash = mixHash(hash, item); return; } + if (typeof item === 'string') { + for (const char of item) hash = mixHash(hash, char.codePointAt(0) || 0); + return; + } + if (typeof item === 'boolean') { hash = mixHash(hash, item ? 1 : 0); return; } + if (Array.isArray(item)) { + hash = mixHash(hash, item.length); + for (const child of item) visit(child); + return; + } + if (item && typeof item === 'object') { + const record = item as Record; + for (const key of Object.keys(record).sort()) { visit(key); visit(record[key]); } + return; + } + hash = mixHash(hash, -1); + }; + visit(value); + return hash.toString(36); +} diff --git a/src/labs.ts b/src/labs.ts new file mode 100644 index 00000000..1d3b89e3 --- /dev/null +++ b/src/labs.ts @@ -0,0 +1,206 @@ +/** Reusable, presentation-only Labs flags. No flag may gate data or requests. */ + +export interface LabsFlag { + id: string; + issue: number; + since: string; + expires: string; + summary: string; +} + +export const LABS_STORAGE_KEY = 'houseplan_card_labs_v1'; + +export const LABS_FLAGS: readonly LabsFlag[] = Object.freeze([ + Object.freeze({ + id: 'iso', + issue: 89, + since: '1.62.0', + expires: '1.65.0', + summary: 'Volumetric plan renderer', + }), +]); + +export interface LabsLocation { + search: string; + hash: string; +} + +export interface LabsResolution { + active: readonly string[]; + space: string; + persist: string | undefined; + knownUrlOperation: boolean; +} + +export interface LabsSnapshot { + active: readonly string[]; + space: string; +} + +type VersionCore = readonly [number, number, number]; + +export function parseVersionCore(value: string): VersionCore | null { + const match = /^(\d+)\.(\d+)\.(\d+)(?:-[0-9A-Za-z.-]+)?$/.exec(String(value || '').trim()); + if (!match) return null; + const core = match.slice(1).map(Number) as unknown as VersionCore; + return core.every((part) => Number.isSafeInteger(part)) ? core : null; +} + +function compareVersion(a: VersionCore, b: VersionCore): number { + for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1; + return 0; +} + +export function validLabsFlag(flag: LabsFlag): boolean { + if (!/^[a-z][a-z0-9-]*$/.test(flag.id) || !Number.isInteger(flag.issue) || flag.issue <= 0 + || !flag.summary.trim()) return false; + const since = parseVersionCore(flag.since); + const expires = parseVersionCore(flag.expires); + return !!since && !!expires && compareVersion(since, expires) < 0; +} + +export function liveLabsFlags( + version: string, registry: readonly LabsFlag[] = LABS_FLAGS, +): ReadonlyMap { + const current = parseVersionCore(version); + const live = new Map(); + if (!current) return live; + const ids = new Set(); + for (const flag of registry) { + if (!validLabsFlag(flag) || ids.has(flag.id)) continue; + ids.add(flag.id); + const since = parseVersionCore(flag.since)!; + const expires = parseVersionCore(flag.expires)!; + if (compareVersion(current, since) >= 0 && compareVersion(current, expires) < 0) { + live.set(flag.id, flag); + } + } + return live; +} + +export function hashParams(hash: string): URLSearchParams { + const raw = String(hash || '').replace(/^#/, ''); + return new URLSearchParams(raw); +} + +export function hashSpace(hash: string): string { + return hashParams(hash).get('space') || ''; +} + +function storedFlags(raw: string | null | undefined): string[] { + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string') : []; + } catch { + return []; + } +} + +function operations(params: URLSearchParams): string[] { + return params.getAll('hp-labs').flatMap((value) => value.split(',')) + .map((value) => value.trim()).filter(Boolean); +} + +/** Pure URL/storage resolver. Query operations run before stronger hash operations. */ +export function resolveLabs( + location: LabsLocation, + storageValue: string | null | undefined, + version: string, + registry: readonly LabsFlag[] = LABS_FLAGS, +): LabsResolution { + const live = liveLabsFlags(version, registry); + const known = new Set(registry.filter(validLabsFlag).map((flag) => flag.id)); + const active = new Set(storedFlags(storageValue).filter((id) => live.has(id))); + let knownUrlOperation = false; + const apply = (token: string) => { + if (token === 'off') { + active.clear(); + knownUrlOperation = true; + return; + } + const remove = token.startsWith('-'); + const id = remove ? token.slice(1) : token; + if (!known.has(id)) return; + knownUrlOperation = true; + if (remove || !live.has(id)) active.delete(id); + else active.add(id); + }; + for (const token of operations(new URLSearchParams(String(location.search || '').replace(/^\?/, '')))) apply(token); + for (const token of operations(hashParams(location.hash))) apply(token); + const sorted = Object.freeze([...active].sort()); + return { + active: sorted, + space: hashSpace(location.hash), + persist: knownUrlOperation ? JSON.stringify(sorted) : undefined, + knownUrlOperation, + }; +} + +declare global { + interface Window { __hpLabs?: readonly string[]; } +} + +let snapshot: LabsSnapshot = { active: Object.freeze([]), space: '' }; +let configuredVersion = ''; +let listening = false; +let loggedSignature = ''; +const subscribers = new Set<(value: LabsSnapshot) => void>(); + +function browserStorageRead(): string | null { + try { return window.localStorage.getItem(LABS_STORAGE_KEY); } catch { return null; } +} + +function browserStorageWrite(value: string): void { + try { window.localStorage.setItem(LABS_STORAGE_KEY, value); } catch { /* private mode/quota */ } +} + +function publishBrowserLabs(): LabsSnapshot { + if (typeof window === 'undefined') return snapshot; + const resolved = resolveLabs(window.location, browserStorageRead(), configuredVersion); + if (resolved.persist !== undefined) browserStorageWrite(resolved.persist); + snapshot = { active: resolved.active, space: resolved.space }; + window.__hpLabs = resolved.active; + for (const subscriber of subscribers) subscriber(snapshot); + return snapshot; +} + +function onLocationChange(): void { publishBrowserLabs(); } + +function ensureBrowserLabs(version: string): LabsSnapshot { + configuredVersion = version; + if (typeof window === 'undefined') return snapshot; + if (!listening) { + listening = true; + window.addEventListener('hashchange', onLocationChange); + window.addEventListener('popstate', onLocationChange); + } + return publishBrowserLabs(); +} + +/** One module-level browser listener; card instances only subscribe to its snapshot. */ +export function subscribeLabs( + version: string, subscriber: (value: LabsSnapshot) => void, +): () => void { + subscribers.add(subscriber); + subscriber(ensureBrowserLabs(version)); + return () => { subscribers.delete(subscriber); }; +} + +export function currentLabs(version: string): LabsSnapshot { + return ensureBrowserLabs(version); +} + +/** Diagnostics are emitted only when an active Labs frame is actually rendered. */ +export function noteLabsRender(version: string): void { + const current = currentLabs(version); + const signature = current.active.join(','); + if (!signature || signature === loggedSignature) return; + loggedSignature = signature; + const registry = new Map(LABS_FLAGS.map((flag) => [flag.id, flag])); + const summary = current.active.map((id) => { + const flag = registry.get(id)!; + return `${id} (#${flag.issue}, expires ${flag.expires})`; + }).join(', '); + console.info(`HOUSEPLAN LABS: ${summary}`); +} diff --git a/test/iso-projection.test.mjs b/test/iso-projection.test.mjs new file mode 100644 index 00000000..2647bb29 --- /dev/null +++ b/test/iso-projection.test.mjs @@ -0,0 +1,61 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + ISO_CAMERA, ISO_WALL_HEIGHT, clientToScenePoint, isoFloorMatrix, + projectPlanPoint, projectedFrame, unprojectFloorPoint, +} from '../test-build/iso-projection.js'; + +const close = (actual, expected, epsilon = 1e-9) => + assert.ok(Math.abs(actual - expected) <= epsilon, `${actual} != ${expected}`); + +test('fixed camera is orthographic, unrotated and inside the owner range', () => { + assert.equal(ISO_CAMERA.rotDeg, 0); + assert.ok(ISO_CAMERA.tiltDeg >= 18 && ISO_CAMERA.tiltDeg <= 22); + const origin = projectPlanPoint([500, 500], 0); + assert.deepEqual(origin, [500, 500]); + const x = projectPlanPoint([600, 500], 0); + const y = projectPlanPoint([500, 600], 0); + close(x[1], 500); + close(y[0], 500); + assert.ok(y[1] > 500, 'plan Y remains a screen-aligned axis'); +}); + +test('floor projection round-trips across the infinite canvas contract', () => { + for (const point of [[-5000, -5000], [0, 0], [500, 500], [5000, 5000], [-1234.5, 4321.25]]) { + const roundTrip = unprojectFloorPoint(projectPlanPoint(point, 0)); + close(roundTrip[0], point[0]); + close(roundTrip[1], point[1]); + } +}); + +test('floor matrix is identical to point projection', () => { + const [a, b, c, d, e, f] = isoFloorMatrix(); + for (const point of [[0, 0], [250, 750], [-3000, 4000]]) { + const projected = projectPlanPoint(point, 0); + close(a * point[0] + c * point[1] + e, projected[0]); + close(b * point[0] + d * point[1] + f, projected[1]); + } +}); + +test('projected frame includes raised wall tops and is view-state independent', () => { + const rect = { x: 100, y: 200, w: 400, h: 300 }; + const frame = projectedFrame({ rect, wallHeight: ISO_WALL_HEIGHT }); + const floorOnly = projectedFrame({ rect, wallHeight: 0 }); + assert.ok(frame.y < floorOnly.y); + assert.ok(frame.h > floorOnly.h); + assert.deepEqual(projectedFrame({ rect, wallHeight: ISO_WALL_HEIGHT }), frame); +}); + +test('client coordinates map through the current scene view', () => { + const scene = clientToScenePoint([250, 175], { left: 50, top: 25, width: 400, height: 300 }, + { x: 100, y: 200, w: 800, h: 600 }); + assert.deepEqual(scene, [500, 500]); +}); + +test('degenerate cameras and frames throw instead of mixing projections', () => { + assert.throws(() => projectPlanPoint([0, 0], 0, { ...ISO_CAMERA, xyScale: 0 })); + assert.throws(() => unprojectFloorPoint([0, 0], { ...ISO_CAMERA, tiltDeg: 90 })); + assert.throws(() => clientToScenePoint([0, 0], { left: 0, top: 0, width: 0, height: 1 }, + { x: 0, y: 0, w: 1, h: 1 })); +}); + diff --git a/test/iso-walls.test.mjs b/test/iso-walls.test.mjs new file mode 100644 index 00000000..693b394f --- /dev/null +++ b/test/iso-walls.test.mjs @@ -0,0 +1,44 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { buildIsoWallGeometry, isoGeometryFingerprint } from '../test-build/iso-walls.js'; + +const closed = (points) => [...points, points[0]]; +const square = closed([[0, 0], [100, 0], [100, 100], [0, 100]]); + +test('one top and only O(E) visible sides are built deterministically', () => { + const first = buildIsoWallGeometry([[square]]); + const second = buildIsoWallGeometry([[square]]); + assert.deepEqual(second, first); + assert.equal(first.edgeCount, 4); + assert.ok(first.sides.length > 0 && first.sides.length <= first.edgeCount); + assert.match(first.topPath, /^M /); + assert.equal(first.sides.every((face) => /^M .* Z$/.test(face.d)), true); +}); + +test('holes preserve an evenodd top and add visible inner/jamb edges', () => { + const hole = closed([[40, 40], [40, 60], [60, 60], [60, 40]]); + const geometry = buildIsoWallGeometry([[square, hole]]); + assert.equal(geometry.edgeCount, 8); + assert.equal((geometry.topPath.match(/M /g) || []).length, 2); + assert.ok(geometry.sides.some((face) => face.ring === 1)); +}); + +test('a full-height gap remains split into jamb edges without a bridge', () => { + const left = closed([[0, 0], [40, 0], [40, 20], [0, 20]]); + const right = closed([[60, 0], [100, 0], [100, 20], [60, 20]]); + const geometry = buildIsoWallGeometry([[left], [right]]); + assert.equal(geometry.edgeCount, 8); + assert.equal((geometry.topPath.match(/M /g) || []).length, 2); + assert.equal(geometry.topPath.includes('40 0 L 60 0'), false); +}); + +test('fingerprint is content based, stable and sensitive to in-place geometry edits', () => { + const value = { rooms: [{ poly: [[0, 0], [1, 0], [1, 1]] }], showBorders: true }; + const first = isoGeometryFingerprint(value); + assert.equal(isoGeometryFingerprint({ showBorders: true, rooms: value.rooms }), first); + value.rooms[0].poly[1][0] = 2; + assert.notEqual(isoGeometryFingerprint(value), first); + assert.equal(isoGeometryFingerprint({ rooms: value.rooms, showBorders: false }), + isoGeometryFingerprint({ rooms: value.rooms, showBorders: false })); +}); + diff --git a/test/labs.test.mjs b/test/labs.test.mjs new file mode 100644 index 00000000..60286b60 --- /dev/null +++ b/test/labs.test.mjs @@ -0,0 +1,51 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + LABS_FLAGS, hashSpace, liveLabsFlags, parseVersionCore, resolveLabs, validLabsFlag, +} from '../test-build/labs.js'; + +const resolve = (search = '', hash = '', storage = null, version = '1.62.0') => + resolveLabs({ search, hash }, storage, version); + +test('registry metadata and numeric-core lifetime fail closed', () => { + assert.equal(LABS_FLAGS.every(validLabsFlag), true); + assert.deepEqual(parseVersionCore('1.65.0-beta.1'), [1, 65, 0]); + assert.equal(liveLabsFlags('1.64.9').has('iso'), true); + assert.equal(liveLabsFlags('1.65.0-beta.1').has('iso'), false); + assert.equal(liveLabsFlags('1.65.0').has('iso'), false); + assert.equal(liveLabsFlags('not-a-version').size, 0); + assert.equal(validLabsFlag({ ...LABS_FLAGS[0], since: '1.65.0' }), false); +}); + +test('URL operations are ordered query then hash with off and removal semantics', () => { + assert.deepEqual(resolve('?hp-labs=off,iso').active, ['iso']); + assert.deepEqual(resolve('?hp-labs=iso,-iso').active, []); + assert.deepEqual(resolve('?hp-labs=-iso', '', '["iso"]').active, []); + assert.deepEqual(resolve('?hp-labs=iso', '#hp-labs=-iso').active, []); + assert.deepEqual(resolve('?hp-labs=off&hp-labs=iso').active, ['iso']); + assert.deepEqual(resolve('?hp-labs=iso', '#hp-labs=off,iso').active, ['iso']); +}); + +test('unknown operations do not rewrite storage and expired flags cannot revive', () => { + const unknown = resolve('?hp-labs=unknown', '', '["iso"]'); + assert.deepEqual(unknown.active, ['iso']); + assert.equal(unknown.persist, undefined); + assert.equal(unknown.knownUrlOperation, false); + const expired = resolve('?hp-labs=iso', '', '["iso"]', '1.65.0-beta.1'); + assert.deepEqual(expired.active, []); + assert.equal(expired.persist, '[]'); +}); + +test('hash parser shares space and labs grammar with percent encoding', () => { + assert.equal(hashSpace('#hp-labs=iso&space=first%20floor'), 'first floor'); + assert.equal(hashSpace('#space=ground&hp-labs=iso'), 'ground'); + const result = resolve('', '#space=ground&hp-labs=iso'); + assert.equal(result.space, 'ground'); + assert.deepEqual(result.active, ['iso']); +}); + +test('malformed and unavailable storage are safe', () => { + assert.deepEqual(resolve('', '', '{bad').active, []); + assert.deepEqual(resolve('?hp-labs=iso', '', undefined).active, ['iso']); +}); + diff --git a/tsconfig.test.json b/tsconfig.test.json index 4121126e..fee67868 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -31,6 +31,7 @@ "src/opening-placement.ts", "src/render/opening-symbol.ts", "src/wall-thickness.ts", "src/physical-geometry.ts", + "src/labs.ts", "src/iso-projection.ts", "src/iso-walls.ts", "src/open-spans.ts", "src/editors/decor/geometry.ts" ]