Files
furniture/src/optimizer/anneal.test.ts
T

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);
});
});