Phase 5: simulated-annealing optimizer in a Web Worker, auto-fill, undo/redo
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user