import { MAX_MICROPHONES, addMicrophone, arrayGeometryIssues, centerMicrophones, clampPositionToAperture, createArrayDefinition, fitApertureToMicrophones, generatePresetMicrophones, inferApertureShape, moveMicrophone, positionInsideAperture, removeMicrophone, replaceMicrophonesWithPreset, updateMicrophone, } from "/apps/array-builder/model.js"; import { validateArrayDefinition } from "/packages/contracts/validation.js"; function assertNear(actual, expected, tolerance, label) { assert(Math.abs(actual - expected) <= tolerance, label + ": expected " + expected + ", received " + actual); } function assertThrows(callback, text, label) { let error; try { callback(); } catch (caught) { error = caught; } assert(error instanceof Error, label + " should throw"); assert(error.message.includes(text), label + ": " + error.message); } test("Array Builder 기본 4x3 문서가 표준 Array 계약과 row-major 좌표를 만족한다", async () => { const arrayDefinition = createArrayDefinition(); assertEqual(arrayDefinition.microphones.length, 12, "default microphone count"); assertEqual(arrayDefinition.microphones[0].id, "mic-001", "first channel id"); assertEqual(arrayDefinition.microphones[11].id, "mic-012", "last channel id"); assertEqual(arrayDefinition.microphones[0].position.join(","), "-0.225,-0.225,0", "bottom-left first"); assertEqual(arrayDefinition.microphones[3].position.join(","), "0.225,-0.225,0", "row advances left to right"); assertEqual(arrayDefinition.microphones[11].position.join(","), "0.225,0.225,0", "top-right last"); assertEqual((await validateArrayDefinition(arrayDefinition)).length, 0, "default array schema"); }); test("격자·선형·링·Fermat preset이 개수와 생성 경계를 지킨다", () => { const aperture = { width: 0.4, height: 0.2 }; const grid = generatePresetMicrophones({ type: "grid", aperture, columns: 3, rows: 2 }); assertEqual(grid.length, 6, "grid count"); assertEqual(grid[0].position.join(","), "-0.2,-0.1,0", "grid first"); assertEqual(grid[5].position.join(","), "0.2,0.1,0", "grid last"); for (const type of ["linear", "ring", "spiral"]) { const microphones = generatePresetMicrophones({ type, aperture, shape: "ellipse", count: 17, axis: "diagonal", }); assertEqual(microphones.length, 17, type + " count"); assert( microphones.every((microphone) => positionInsideAperture(microphone.position, aperture, "ellipse")), type + " ellipse bounds", ); } }); test("타원 격자는 요청한 rows x columns 수를 유지하며 경계 안에 배치된다", () => { const aperture = { width: 0.45, height: 0.3 }; const microphones = generatePresetMicrophones({ type: "grid", aperture, shape: "ellipse", columns: 7, rows: 5, }); assertEqual(microphones.length, 35, "ellipse grid count"); assert( microphones.every((microphone) => positionInsideAperture(microphone.position, aperture, "ellipse")), "ellipse-adapted grid bounds", ); }); test("seed random preset은 재현되고 최소 간격과 형상 경계를 지킨다", () => { const options = { type: "random", aperture: { width: 0.5, height: 0.35 }, shape: "ellipse", count: 24, seed: 12345, minimumSpacingM: 0.01, }; const first = generatePresetMicrophones(options); const repeated = generatePresetMicrophones(options); const different = generatePresetMicrophones({ ...options, seed: 54321 }); assertEqual(JSON.stringify(first), JSON.stringify(repeated), "same seed layout"); assert(JSON.stringify(first) !== JSON.stringify(different), "different seed layout"); assert( first.every((microphone) => positionInsideAperture(microphone.position, options.aperture, "ellipse")), "random ellipse bounds", ); for (let left = 0; left < first.length; left += 1) { for (let right = left + 1; right < first.length; right += 1) { const distance = Math.hypot( first[left].position[0] - first[right].position[0], first[left].position[1] - first[right].position[1], ); assert(distance >= 0.01 - 1e-9, "random minimum spacing"); } } }); test("불가능한 random 조건과 최대 채널 초과는 기존 배열을 보존한다", () => { const arrayDefinition = createArrayDefinition(); const before = JSON.stringify(arrayDefinition.microphones); assertThrows( () => replaceMicrophonesWithPreset(arrayDefinition, { type: "random", aperture: arrayDefinition.aperture, count: 100, minimumSpacingM: 0.4, seed: 1, }), "배치할 수 없습니다", "impossible random spacing", ); assertEqual(JSON.stringify(arrayDefinition.microphones), before, "failed generation is atomic"); assertThrows( () => generatePresetMicrophones({ type: "linear", aperture: arrayDefinition.aperture, count: MAX_MICROPHONES + 1, }), "1–1024", "microphone maximum", ); }); test("수동 add/update/reorder/remove가 ID와 calibration을 보존한다", () => { const arrayDefinition = createArrayDefinition({ columns: 2, rows: 1 }); arrayDefinition.microphones[0].sensitivityDb = -1.5; arrayDefinition.microphones[0].phaseOffsetDeg = 8; arrayDefinition.microphones[0].frequencyResponse = [ { frequencyHz: 100, gainDb: -2 }, { frequencyHz: 1000, gainDb: 0.5, phaseDeg: 2 }, ]; updateMicrophone(arrayDefinition, "mic-001", { position: [-0.1, 0.02, 0.003] }); assertEqual(arrayDefinition.microphones[0].sensitivityDb, -1.5, "sensitivity preserved"); assertEqual(arrayDefinition.microphones[0].frequencyResponse.length, 2, "response preserved"); const added = addMicrophone(arrayDefinition, [0, 0, 0]); assertEqual(added.id, "mic-003", "stable next id"); assert(moveMicrophone(arrayDefinition, added.id, -1), "channel reordered"); assertEqual(arrayDefinition.microphones[1].id, "mic-003", "new channel order"); assert(removeMicrophone(arrayDefinition, "mic-002"), "channel removed"); assertEqual(addMicrophone(arrayDefinition, [0.05, 0, 0]).id, "mic-004", "deleted suffix is not reused"); while (arrayDefinition.microphones.length > 1) { removeMicrophone(arrayDefinition, arrayDefinition.microphones.at(-1).id); } assertEqual(removeMicrophone(arrayDefinition, arrayDefinition.microphones[0].id), false, "last microphone retained"); }); test("Builder geometry 진단은 중복을 오류, 생성 경계 초과를 경고로 구분한다", () => { const arrayDefinition = createArrayDefinition({ columns: 2, rows: 1 }); arrayDefinition.microphones[0].position[0] = 0.4; arrayDefinition.microphones[1].position = [...arrayDefinition.microphones[0].position]; const issues = arrayGeometryIssues(arrayDefinition, { shape: "ellipse" }); assert(issues.some((issue) => issue.severity === "error" && issue.message.includes("중복")), "duplicate error"); assert(issues.some((issue) => issue.severity === "warning" && issue.message.includes("경계 밖")), "bounds warning"); }); test("좌표 clamp, 중심 정렬과 aperture 맞춤이 명시적으로 동작한다", () => { const aperture = { width: 0.4, height: 0.2 }; const clamped = clampPositionToAperture([0.4, 0.2, 0.03], aperture, "ellipse"); assert(positionInsideAperture(clamped, aperture, "ellipse"), "ellipse clamp"); assertNear(clamped[2], 0.03, 1e-12, "z remains editable"); const arrayDefinition = createArrayDefinition({ columns: 2, rows: 1, width: 0.2, height: 0.1 }); arrayDefinition.microphones[0].position = [0.2, 0.1, 0.02]; arrayDefinition.microphones[1].position = [0.4, 0.1, 0.04]; centerMicrophones(arrayDefinition); assertEqual(arrayDefinition.microphones[0].position.join(","), "-0.1,0,-0.01", "center first"); assertEqual(arrayDefinition.microphones[1].position.join(","), "0.1,0,0.01", "center second"); fitApertureToMicrophones(arrayDefinition, { shape: "rectangle", paddingM: 0.01 }); assertNear(arrayDefinition.aperture.width, 0.22, 1e-12, "fitted width"); assertNear(arrayDefinition.aperture.height, 0.021, 1e-12, "fitted height"); }); test("설명과 좌표에서 원형 생성 형상을 합리적으로 추론한다", () => { const spiral = createArrayDefinition({ description: "Fermat spiral on circular aperture", presetType: "spiral", count: 32, }); assertEqual(inferApertureShape(spiral), "ellipse", "description inference"); const grid = createArrayDefinition(); assertEqual(inferApertureShape(grid), "rectangle", "grid inference"); });