import { MAX_MICROPHONES, addMicrophone, arrayGeometryIssues, arrayStatistics, centerMicrophones, clampPositionToAperture, createArrayDefinition, fitApertureToMicrophones, generatePresetMicrophones, inferApertureShape, moveMicrophone, nextMicrophoneId, 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("1,024채널 random preset은 공간 index로 고유 좌표를 빠르게 생성한다", () => { const startedAt = performance.now(); const microphones = generatePresetMicrophones({ type: "random", aperture: { width: 0.45, height: 0.45 }, shape: "rectangle", count: 1024, seed: 20260828, minimumSpacingM: 0.002, }); const elapsedMs = performance.now() - startedAt; assertEqual(microphones.length, 1024, "maximum random channel count"); assertEqual( new Set(microphones.map((microphone) => microphone.position.join(","))).size, 1024, "maximum random positions are unique", ); assert(elapsedMs < 3000, "maximum random generation stays interactive: " + elapsedMs + " ms"); const failedAt = performance.now(); assertThrows( () => generatePresetMicrophones({ type: "random", aperture: { width: 0.45, height: 0.45 }, shape: "rectangle", count: 1024, seed: 7, minimumSpacingM: 0.03, }), "배치할 수 없습니다", "dense maximum random failure", ); const failedElapsedMs = performance.now() - failedAt; assert( failedElapsedMs < 3000, "impossible maximum random layout fails within a bound: " + failedElapsedMs + " ms", ); }); 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"); grid.description = "Spring-mounted rectangular array"; assertEqual(inferApertureShape(grid), "rectangle", "word boundary avoids spring false positive"); }); test("매우 큰 유한 좌표에서도 배열 중심 통계가 유한하게 유지된다", () => { const arrayDefinition = createArrayDefinition({ columns: 2, rows: 1 }); arrayDefinition.microphones[0].position = [Number.MAX_VALUE, Number.MAX_VALUE, 0]; arrayDefinition.microphones[1].position = [Number.MAX_VALUE, -Number.MAX_VALUE, 0]; const statistics = arrayStatistics(arrayDefinition); assert(Number.isFinite(statistics.centroid[0]), "large x centroid remains finite"); assert(Number.isFinite(statistics.centroid[1]), "large y centroid remains finite"); assertEqual(statistics.centroid[0], Number.MAX_VALUE, "large x centroid"); assertEqual(statistics.centroid[1], 0, "balanced y centroid"); }); test("큰 숫자 suffix ID도 마이크 추가를 멈추지 않고 정확히 증가시킨다", () => { const arrayDefinition = createArrayDefinition({ columns: 1, rows: 1 }); arrayDefinition.microphones[0].id = "mic-9007199254740992"; assertEqual( nextMicrophoneId(arrayDefinition), "mic-9007199254740993", "BigInt suffix increment", ); assertEqual( addMicrophone(arrayDefinition, [0.01, 0, 0]).id, "mic-9007199254740993", "manual add uses the safe ID", ); }); test("고정밀 import 좌표는 실제 값이 다르면 중복으로 오인하지 않는다", () => { const arrayDefinition = createArrayDefinition({ columns: 2, rows: 1 }); arrayDefinition.microphones[0].position = [0.0000000001, 0, 0]; arrayDefinition.microphones[1].position = [0.0000000002, 0, 0]; assert( !arrayGeometryIssues(arrayDefinition).some((issue) => issue.severity === "error"), "distinct high-precision positions", ); });