70c2199d87
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
124 lines
5.1 KiB
TypeScript
124 lines
5.1 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { boundsOf, DEFAULT_ROOM, getBlockedRects, isInsideRoom, overlaps } from "../geometry";
|
|
import { remaining } from "../inventory";
|
|
import { evaluateRules } from "../scoring";
|
|
import { initialState } from "../store";
|
|
import type { FurnitureItem, Room, Rule } from "../types";
|
|
import { anneal, type AnnealInput } from "./anneal";
|
|
import { autoFill } from "./autofill";
|
|
import { createRng } from "./rng";
|
|
|
|
const configs = initialState.configs;
|
|
const weights = initialState.weights;
|
|
|
|
const item = (id: string, type: string, x: number, y: number, extra: Partial<FurnitureItem> = {}): FurnitureItem => {
|
|
const c = configs[type];
|
|
return { id, type, x, y, width: c.defaultWidth, height: c.defaultHeight, rotation: 0, locked: false, ...extra };
|
|
};
|
|
|
|
const room: Room = {
|
|
...DEFAULT_ROOM,
|
|
screens: [{ id: "s", wall: "top", offset: 3, width: 4 }],
|
|
obstacles: [{ id: "o", x: 6, y: 3, width: 1, height: 1 }],
|
|
};
|
|
|
|
const layout = (): FurnitureItem[] => [
|
|
item("t1", "table", 1, 2),
|
|
item("t2", "table", 3, 5),
|
|
item("t3", "table", 7, 5, { locked: true }),
|
|
item("c1", "chair", 1.2, 3.1),
|
|
item("c2", "chair", 8, 1),
|
|
item("c3", "chair", 5, 2),
|
|
];
|
|
|
|
const run = (over: Partial<AnnealInput> = {}, iterations = 1500) =>
|
|
anneal({ room, items: layout(), configs, rules: [], weights, seed: 7, ...over }, { iterations });
|
|
|
|
const assertValid = (r: Room, items: FurnitureItem[]) => {
|
|
const blocked = getBlockedRects(r);
|
|
items.forEach((a, i) => {
|
|
const b = boundsOf(a);
|
|
expect(isInsideRoom(b, r)).toBe(true);
|
|
blocked.forEach((z) => expect(overlaps(b, z)).toBe(false));
|
|
items.slice(i + 1).forEach((o) => expect(overlaps(b, boundsOf(o))).toBe(false));
|
|
});
|
|
};
|
|
|
|
describe("anneal", () => {
|
|
it("never moves or rotates locked items", () => {
|
|
const res = run();
|
|
const before = layout().find((i) => i.id === "t3")!;
|
|
expect(res.items.find((i) => i.id === "t3")).toEqual(before);
|
|
expect(res.skippedLocked).toBe(1);
|
|
});
|
|
|
|
it("never returns an overlapping, out-of-room or blocked layout", () => {
|
|
for (const seed of [1, 2, 3]) assertValid(room, run({ seed }).items);
|
|
});
|
|
|
|
it("keeps Required rules unbroken when the start is valid", () => {
|
|
const rules: Rule[] = [{ id: "r", kind: "againstWall", priority: "required", typeA: "chair" }];
|
|
const start = layout().map((i) => (i.type === "chair" ? { ...i, x: 0, y: i.y } : i)); // chairs on the left wall
|
|
expect(evaluateRules(room, start, configs, rules).violations).toHaveLength(0);
|
|
const res = run({ items: start, rules });
|
|
expect(evaluateRules(room, res.items, configs, rules).violations).toHaveLength(0);
|
|
expect(res.remainingViolations).toBe(0);
|
|
});
|
|
|
|
it("repairs a start that breaks a Required rule", () => {
|
|
const rules: Rule[] = [{ id: "r", kind: "againstWall", priority: "required", typeA: "chair" }];
|
|
const startViolations = evaluateRules(room, layout(), configs, rules).violations.length;
|
|
expect(startViolations).toBeGreaterThan(0);
|
|
const res = run({ rules }, 4000);
|
|
expect(res.remainingViolations).toBeLessThan(startViolations);
|
|
});
|
|
|
|
it("gives the same result for the same seed", () => {
|
|
expect(run({ seed: 42 })).toEqual(run({ seed: 42 }));
|
|
expect(run({ seed: 42 }).items).not.toEqual(run({ seed: 43 }).items);
|
|
});
|
|
|
|
it("never ends worse than it started", () => {
|
|
for (const seed of [1, 2, 3, 4]) {
|
|
const r = run({ seed });
|
|
expect(r.bestScore).toBeGreaterThanOrEqual(r.startScore - 1e-9);
|
|
}
|
|
});
|
|
|
|
it("only moves the allowed items", () => {
|
|
const res = run({ movableIds: new Set(["c2"]) });
|
|
const start = layout();
|
|
res.items.filter((i) => i.id !== "c2").forEach((i) => expect(i).toEqual(start.find((s) => s.id === i.id)));
|
|
});
|
|
|
|
it("runs thousands of iterations per second on a typical room", () => {
|
|
// ~5,000/s on an idle machine; best of three and a 1,000/s floor so a busy machine doesn't fail the test
|
|
let best = 0;
|
|
for (let k = 0; k < 3; k++) {
|
|
const t0 = performance.now();
|
|
const res = anneal({ room, items: layout(), configs, rules: [], weights, seed: 1 }, { iterations: 2000 });
|
|
best = Math.max(best, (res.iterations / (performance.now() - t0)) * 1000);
|
|
}
|
|
console.log(`iterations/s: ${Math.round(best)}`);
|
|
expect(best).toBeGreaterThan(1000);
|
|
});
|
|
});
|
|
|
|
describe("autoFill", () => {
|
|
it("adds tables first, then chairs, from remaining inventory", () => {
|
|
const f = autoFill(room, [], configs, initialState.typeOrder, [], 20, createRng(1));
|
|
expect(f.tablesAdded).toBeGreaterThan(0);
|
|
expect(f.seats).toBeGreaterThanOrEqual(20);
|
|
assertValid(room, f.items);
|
|
expect(remaining(f.items, "table", configs.table.available)).toBeGreaterThanOrEqual(0);
|
|
});
|
|
|
|
it("uses everything it can and reports a shortfall", () => {
|
|
const f = autoFill(room, [], configs, initialState.typeOrder, [], 500, createRng(1));
|
|
expect(f.shortfall).toBeGreaterThan(0);
|
|
expect(f.message).toMatch(/Only/);
|
|
expect(remaining(f.items, "table", configs.table.available)).toBeGreaterThanOrEqual(0);
|
|
expect(remaining(f.items, "chair", configs.chair.available)).toBeGreaterThanOrEqual(0);
|
|
});
|
|
});
|