test: cover starter map layout
This commit is contained in:
parent
96f1cf4625
commit
c87aa8f474
@ -1,5 +1,9 @@
|
|||||||
const pool = require("../db/pool");
|
const pool = require("../db/pool");
|
||||||
const storeModel = require("./store.model");
|
const storeModel = require("./store.model");
|
||||||
|
const {
|
||||||
|
buildStarterObjectsFromZones,
|
||||||
|
getStarterMapSize,
|
||||||
|
} = require("../services/location-map-layout.service");
|
||||||
|
|
||||||
const DEFAULT_MAP_WIDTH = 1000;
|
const DEFAULT_MAP_WIDTH = 1000;
|
||||||
const DEFAULT_MAP_HEIGHT = 700;
|
const DEFAULT_MAP_HEIGHT = 700;
|
||||||
@ -349,35 +353,6 @@ async function upsertDraftMap(db, householdId, locationId, userId, mapPayload) {
|
|||||||
return normalizeMapRow(result.rows[0]);
|
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) => {
|
exports.getMapState = async (householdId, locationId, includeDraft = false) => {
|
||||||
const location = await storeModel.getLocationById(householdId, locationId);
|
const location = await storeModel.getLocationById(householdId, locationId);
|
||||||
if (!location) return null;
|
if (!location) return null;
|
||||||
@ -432,18 +407,19 @@ exports.createDraftFromZones = async (householdId, locationId, userId, mapPayloa
|
|||||||
const map = await upsertDraftMap(client, householdId, locationId, userId, mapPayload);
|
const map = await upsertDraftMap(client, householdId, locationId, userId, mapPayload);
|
||||||
await client.query("DELETE FROM location_map_objects WHERE location_map_id = $1", [map.id]);
|
await client.query("DELETE FROM location_map_objects WHERE location_map_id = $1", [map.id]);
|
||||||
|
|
||||||
const starterHeight = Math.max(
|
const starterSize = getStarterMapSize(zones, map);
|
||||||
map.height,
|
if (starterSize.width !== map.width || starterSize.height !== map.height) {
|
||||||
Math.ceil(Math.max(zones.length, 1) / (zones.length <= 4 ? 2 : 3)) * 210
|
|
||||||
);
|
|
||||||
if (starterHeight !== map.height) {
|
|
||||||
await client.query(
|
await client.query(
|
||||||
`UPDATE location_maps
|
`UPDATE location_maps
|
||||||
SET height = $1, updated_at = NOW(), updated_by = $2
|
SET width = $1,
|
||||||
WHERE id = $3`,
|
height = $2,
|
||||||
[starterHeight, userId, map.id]
|
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(
|
await insertObjects(
|
||||||
|
|||||||
71
backend/services/location-map-layout.service.js
Normal file
71
backend/services/location-map-layout.service.js
Normal file
@ -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,
|
||||||
|
};
|
||||||
78
backend/tests/location-map-layout.service.test.js
Normal file
78
backend/tests/location-map-layout.service.test.js
Normal file
@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user