From c87aa8f474dbc825b293494901989c97ccb45eea Mon Sep 17 00:00:00 2001 From: Nico Date: Wed, 3 Jun 2026 12:09:22 -0700 Subject: [PATCH] test: cover starter map layout --- backend/models/location-map.model.js | 52 ++++--------- .../services/location-map-layout.service.js | 71 +++++++++++++++++ .../tests/location-map-layout.service.test.js | 78 +++++++++++++++++++ 3 files changed, 163 insertions(+), 38 deletions(-) create mode 100644 backend/services/location-map-layout.service.js create mode 100644 backend/tests/location-map-layout.service.test.js diff --git a/backend/models/location-map.model.js b/backend/models/location-map.model.js index 3323402..ff4d384 100644 --- a/backend/models/location-map.model.js +++ b/backend/models/location-map.model.js @@ -1,5 +1,9 @@ const pool = require("../db/pool"); const storeModel = require("./store.model"); +const { + buildStarterObjectsFromZones, + getStarterMapSize, +} = require("../services/location-map-layout.service"); const DEFAULT_MAP_WIDTH = 1000; const DEFAULT_MAP_HEIGHT = 700; @@ -349,35 +353,6 @@ async function upsertDraftMap(db, householdId, locationId, userId, mapPayload) { return normalizeMapRow(result.rows[0]); } -function buildStarterObjectsFromZones(zones, width, height) { - if (!zones.length) return []; - - const columnCount = zones.length <= 4 ? 2 : 3; - const rowCount = Math.ceil(zones.length / columnCount); - const gap = 28; - const cellWidth = (width - gap * (columnCount + 1)) / columnCount; - const cellHeight = (height - gap * (rowCount + 1)) / rowCount; - - return zones.map((zone, index) => { - const column = index % columnCount; - const row = Math.floor(index / columnCount); - return { - zone_id: zone.id, - type: "zone", - label: zone.name, - x: Math.round((gap + column * (cellWidth + gap)) * 100) / 100, - y: Math.round((gap + row * (cellHeight + gap)) * 100) / 100, - width: Math.max(120, Math.round(cellWidth * 100) / 100), - height: Math.max(80, Math.round(cellHeight * 100) / 100), - rotation: 0, - locked: false, - visible: true, - sort_order: index + 1, - metadata_json: { starter: true }, - }; - }); -} - exports.getMapState = async (householdId, locationId, includeDraft = false) => { const location = await storeModel.getLocationById(householdId, locationId); if (!location) return null; @@ -432,18 +407,19 @@ exports.createDraftFromZones = async (householdId, locationId, userId, mapPayloa const map = await upsertDraftMap(client, householdId, locationId, userId, mapPayload); await client.query("DELETE FROM location_map_objects WHERE location_map_id = $1", [map.id]); - const starterHeight = Math.max( - map.height, - Math.ceil(Math.max(zones.length, 1) / (zones.length <= 4 ? 2 : 3)) * 210 - ); - if (starterHeight !== map.height) { + const starterSize = getStarterMapSize(zones, map); + if (starterSize.width !== map.width || starterSize.height !== map.height) { await client.query( `UPDATE location_maps - SET height = $1, updated_at = NOW(), updated_by = $2 - WHERE id = $3`, - [starterHeight, userId, map.id] + SET width = $1, + height = $2, + updated_at = NOW(), + updated_by = $3 + WHERE id = $4`, + [starterSize.width, starterSize.height, userId, map.id] ); - map.height = starterHeight; + map.width = starterSize.width; + map.height = starterSize.height; } await insertObjects( diff --git a/backend/services/location-map-layout.service.js b/backend/services/location-map-layout.service.js new file mode 100644 index 0000000..d51e4f4 --- /dev/null +++ b/backend/services/location-map-layout.service.js @@ -0,0 +1,71 @@ +const DEFAULT_MAP_WIDTH = 1000; +const DEFAULT_MAP_HEIGHT = 700; +const STARTER_GAP = 28; +const STARTER_MIN_OBJECT_WIDTH = 120; +const STARTER_MIN_OBJECT_HEIGHT = 80; +const STARTER_TARGET_ROW_HEIGHT = 210; + +function toPositiveInteger(value, fallback) { + const parsed = Number.parseInt(String(value), 10); + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; +} + +function roundToTwo(value) { + return Math.round(value * 100) / 100; +} + +function getStarterGrid(zones) { + const zoneCount = Array.isArray(zones) ? zones.length : 0; + const columnCount = zoneCount <= 4 ? 2 : 3; + const rowCount = Math.ceil(Math.max(zoneCount, 1) / columnCount); + return { columnCount, rowCount }; +} + +function getStarterMapSize(zones, mapSize = {}) { + const { columnCount, rowCount } = getStarterGrid(zones); + const currentWidth = toPositiveInteger(mapSize.width, DEFAULT_MAP_WIDTH); + const currentHeight = toPositiveInteger(mapSize.height, DEFAULT_MAP_HEIGHT); + const requiredWidth = + columnCount * STARTER_MIN_OBJECT_WIDTH + STARTER_GAP * (columnCount + 1); + const minimumGridHeight = + rowCount * STARTER_MIN_OBJECT_HEIGHT + STARTER_GAP * (rowCount + 1); + const preferredGridHeight = rowCount * STARTER_TARGET_ROW_HEIGHT; + + return { + width: Math.max(currentWidth, requiredWidth), + height: Math.max(currentHeight, minimumGridHeight, preferredGridHeight), + }; +} + +function buildStarterObjectsFromZones(zones, width, height) { + if (!Array.isArray(zones) || !zones.length) return []; + + const mapSize = getStarterMapSize(zones, { width, height }); + const { columnCount, rowCount } = getStarterGrid(zones); + const cellWidth = (mapSize.width - STARTER_GAP * (columnCount + 1)) / columnCount; + const cellHeight = (mapSize.height - STARTER_GAP * (rowCount + 1)) / rowCount; + + return zones.map((zone, index) => { + const column = index % columnCount; + const row = Math.floor(index / columnCount); + return { + zone_id: zone.id, + type: "zone", + label: zone.name, + x: roundToTwo(STARTER_GAP + column * (cellWidth + STARTER_GAP)), + y: roundToTwo(STARTER_GAP + row * (cellHeight + STARTER_GAP)), + width: Math.max(STARTER_MIN_OBJECT_WIDTH, roundToTwo(cellWidth)), + height: Math.max(STARTER_MIN_OBJECT_HEIGHT, roundToTwo(cellHeight)), + rotation: 0, + locked: false, + visible: true, + sort_order: index + 1, + metadata_json: { starter: true }, + }; + }); +} + +module.exports = { + buildStarterObjectsFromZones, + getStarterMapSize, +}; diff --git a/backend/tests/location-map-layout.service.test.js b/backend/tests/location-map-layout.service.test.js new file mode 100644 index 0000000..e840fa2 --- /dev/null +++ b/backend/tests/location-map-layout.service.test.js @@ -0,0 +1,78 @@ +const { + buildStarterObjectsFromZones, + getStarterMapSize, +} = require("../services/location-map-layout.service"); + +function makeZones(count) { + return Array.from({ length: count }, (_, index) => ({ + id: index + 1, + name: `Zone ${index + 1}`, + })); +} + +function overlaps(first, second) { + return !( + first.x + first.width <= second.x || + second.x + second.width <= first.x || + first.y + first.height <= second.y || + second.y + second.height <= first.y + ); +} + +describe("location map starter layout", () => { + test("creates bounded starter rectangles for many zones", () => { + const zones = makeZones(13); + const mapSize = getStarterMapSize(zones, { width: 1000, height: 700 }); + const objects = buildStarterObjectsFromZones(zones, mapSize.width, mapSize.height); + + expect(mapSize).toEqual({ width: 1000, height: 1050 }); + expect(objects).toHaveLength(13); + + objects.forEach((object, index) => { + expect(object).toEqual( + expect.objectContaining({ + zone_id: zones[index].id, + type: "zone", + label: zones[index].name, + locked: false, + visible: true, + sort_order: index + 1, + metadata_json: { starter: true }, + }) + ); + expect(object.x).toBeGreaterThanOrEqual(0); + expect(object.y).toBeGreaterThanOrEqual(0); + expect(object.width).toBeGreaterThanOrEqual(120); + expect(object.height).toBeGreaterThanOrEqual(80); + expect(object.x + object.width).toBeLessThanOrEqual(mapSize.width); + expect(object.y + object.height).toBeLessThanOrEqual(mapSize.height); + }); + + for (let firstIndex = 0; firstIndex < objects.length; firstIndex += 1) { + for (let secondIndex = firstIndex + 1; secondIndex < objects.length; secondIndex += 1) { + expect(overlaps(objects[firstIndex], objects[secondIndex])).toBe(false); + } + } + }); + + test("expands cramped map dimensions before placing starter rectangles", () => { + const zones = makeZones(5); + const mapSize = getStarterMapSize(zones, { width: 300, height: 180 }); + const objects = buildStarterObjectsFromZones(zones, 300, 180); + + expect(mapSize).toEqual({ width: 472, height: 420 }); + expect(objects).toHaveLength(5); + objects.forEach((object) => { + expect(object.x + object.width).toBeLessThanOrEqual(mapSize.width); + expect(object.y + object.height).toBeLessThanOrEqual(mapSize.height); + }); + }); + + test("returns no objects for locations with no zones", () => { + expect(buildStarterObjectsFromZones([], 1000, 700)).toEqual([]); + expect(getStarterMapSize([], { width: 1000, height: 700 })).toEqual({ + width: 1000, + height: 700, + }); + }); +});