fix: harden array builder editing workflows
This commit is contained in:
@@ -1,4 +1,9 @@
|
||||
import { validateArrayDefinition } from "/packages/contracts/validation.js";
|
||||
import {
|
||||
arrayPositionToCanvas,
|
||||
fitArrayViewport,
|
||||
} from "/apps/array-builder/canvas-view.js";
|
||||
import { createArrayDefinition } from "/apps/array-builder/model.js";
|
||||
|
||||
function assertNear(actual, expected, tolerance, label) {
|
||||
assert(
|
||||
@@ -103,16 +108,116 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
|
||||
documentObject.querySelector("#array-canvas").getBoundingClientRect().height >= 500,
|
||||
"canvas dominates the desktop workspace",
|
||||
);
|
||||
assert(
|
||||
!documentObject.querySelector("#canvas-instructions").classList.contains("is-hidden"),
|
||||
"canvas guidance starts visible",
|
||||
);
|
||||
|
||||
const canvas = documentObject.querySelector("#array-canvas");
|
||||
const defaultArray = createArrayDefinition();
|
||||
const initialCanvasRect = canvas.getBoundingClientRect();
|
||||
const initialViewport = fitArrayViewport(
|
||||
initialCanvasRect.width,
|
||||
initialCanvasRect.height,
|
||||
defaultArray,
|
||||
);
|
||||
const firstMicrophonePixel = arrayPositionToCanvas(
|
||||
defaultArray.microphones[0].position,
|
||||
initialViewport,
|
||||
);
|
||||
const initialFirstPosition = selectedCoordinates(documentObject);
|
||||
const dragPointerId = 21;
|
||||
const dispatchDragPointer = (type, offsetX, offsetY, buttons) => {
|
||||
canvas.dispatchEvent(new windowObject.PointerEvent(type, {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
pointerId: dragPointerId,
|
||||
pointerType: "mouse",
|
||||
isPrimary: true,
|
||||
button: type === "pointermove" ? -1 : 0,
|
||||
buttons,
|
||||
clientX: initialCanvasRect.left + firstMicrophonePixel.x + offsetX,
|
||||
clientY: initialCanvasRect.top + firstMicrophonePixel.y + offsetY,
|
||||
}));
|
||||
};
|
||||
dispatchDragPointer("pointerdown", 0, 0, 1);
|
||||
dispatchDragPointer("pointermove", 36, -24, 1);
|
||||
dispatchDragPointer("pointerup", 36, -24, 0);
|
||||
await nextPaint(windowObject);
|
||||
const draggedPosition = selectedCoordinates(documentObject);
|
||||
assert(
|
||||
draggedPosition[0] !== initialFirstPosition[0] &&
|
||||
draggedPosition[1] !== initialFirstPosition[1],
|
||||
"pointer drag changes both array coordinates",
|
||||
);
|
||||
assert(
|
||||
documentObject.querySelector("#canvas-instructions").classList.contains("is-hidden"),
|
||||
"first canvas edit dismisses the guidance overlay",
|
||||
);
|
||||
documentObject.querySelector("#undo-button").click();
|
||||
assertEqual(
|
||||
selectedCoordinates(documentObject).join(","),
|
||||
initialFirstPosition.join(","),
|
||||
"one undo restores the whole drag",
|
||||
);
|
||||
|
||||
dispatchDragPointer("pointerdown", 0, 0, 1);
|
||||
dispatchDragPointer("pointermove", 28, -18, 1);
|
||||
canvas.dispatchEvent(new windowObject.KeyboardEvent("keydown", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
key: "Escape",
|
||||
}));
|
||||
await nextPaint(windowObject);
|
||||
assertEqual(
|
||||
selectedCoordinates(documentObject).join(","),
|
||||
initialFirstPosition.join(","),
|
||||
"Escape cancels an in-progress drag",
|
||||
);
|
||||
|
||||
const initialApertureWidth = documentObject.querySelector("#aperture-width").value;
|
||||
dispatchValue(windowObject, documentObject.querySelector("#aperture-width"), 0);
|
||||
assertEqual(
|
||||
documentObject.querySelector("#aperture-width").value,
|
||||
initialApertureWidth,
|
||||
"rejected aperture input is restored to the active value",
|
||||
);
|
||||
assert(
|
||||
documentObject.querySelector(".validation-card").classList.contains("is-invalid"),
|
||||
"rejected action has an error visual state",
|
||||
);
|
||||
|
||||
dispatchValue(windowObject, documentObject.querySelector("#preset-count"), "");
|
||||
dispatchValue(windowObject, documentObject.querySelector("#preset-columns"), 3);
|
||||
dispatchValue(windowObject, documentObject.querySelector("#preset-rows"), 2);
|
||||
documentObject.querySelector("#generate-preset-button").click();
|
||||
await waitFor(() => microphoneCount(documentObject) === 6, "3x2 grid generation");
|
||||
assertEqual(
|
||||
documentObject.querySelector("#preset-count").value,
|
||||
"",
|
||||
"hidden count does not block grid generation",
|
||||
);
|
||||
assertEqual(
|
||||
microphoneButtons(documentObject).map((button) => button.dataset.microphoneId).join(","),
|
||||
"mic-001,mic-002,mic-003,mic-004,mic-005,mic-006",
|
||||
"grid keeps row-major physical channel IDs",
|
||||
);
|
||||
assertEqual(
|
||||
microphoneButtons(documentObject).filter((button) => button.tabIndex === 0).length,
|
||||
1,
|
||||
"microphone list has one roving Tab stop",
|
||||
);
|
||||
microphoneButtons(documentObject)[0].focus();
|
||||
microphoneButtons(documentObject)[0].dispatchEvent(new windowObject.KeyboardEvent("keydown", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
key: "ArrowDown",
|
||||
}));
|
||||
assertEqual(
|
||||
documentObject.activeElement.dataset.microphoneId,
|
||||
"mic-002",
|
||||
"ArrowDown selects and focuses the next channel",
|
||||
);
|
||||
|
||||
dispatchValue(windowObject, documentObject.querySelector("#aperture-shape"), "ellipse");
|
||||
dispatchValue(windowObject, documentObject.querySelector("#preset-type"), "ring");
|
||||
@@ -158,9 +263,14 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
|
||||
"reference-channel",
|
||||
"manual ID edit",
|
||||
);
|
||||
documentObject.querySelector("#move-microphone-up-button").click();
|
||||
assertEqual(
|
||||
microphoneButtons(documentObject).at(-2).dataset.microphoneId,
|
||||
"reference-channel",
|
||||
"channel forward button changes physical order",
|
||||
);
|
||||
|
||||
const beforeNudge = selectedCoordinates(documentObject);
|
||||
const canvas = documentObject.querySelector("#array-canvas");
|
||||
canvas.focus();
|
||||
canvas.dispatchEvent(new windowObject.KeyboardEvent("keydown", {
|
||||
bubbles: true,
|
||||
@@ -172,6 +282,80 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
|
||||
assertNear(afterNudge[0], beforeNudge[0] + 0.005, 1e-9, "keyboard nudge uses snap step");
|
||||
assertNear(afterNudge[1], beforeNudge[1], 1e-9, "keyboard nudge keeps y");
|
||||
|
||||
const touchCanvasRect = canvas.getBoundingClientRect();
|
||||
const touchViewport = fitArrayViewport(
|
||||
touchCanvasRect.width,
|
||||
touchCanvasRect.height,
|
||||
{
|
||||
aperture: {
|
||||
width: Number(documentObject.querySelector("#aperture-width").value),
|
||||
height: Number(documentObject.querySelector("#aperture-height").value),
|
||||
},
|
||||
microphones: [{ id: "selected", position: afterNudge }],
|
||||
},
|
||||
);
|
||||
const selectedTouchPixel = arrayPositionToCanvas(afterNudge, touchViewport);
|
||||
const dispatchTouchDragPointer = (type, pointerId, offsetX, offsetY, buttons) =>
|
||||
canvas.dispatchEvent(new windowObject.PointerEvent(type, {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
pointerId,
|
||||
pointerType: "touch",
|
||||
isPrimary: true,
|
||||
button: type === "pointermove" ? -1 : 0,
|
||||
buttons,
|
||||
clientX: touchCanvasRect.left + selectedTouchPixel.x + offsetX,
|
||||
clientY: touchCanvasRect.top + selectedTouchPixel.y + offsetY,
|
||||
}));
|
||||
|
||||
dispatchTouchDragPointer("pointerdown", 22, 0, 0, 1);
|
||||
const microphoneTouchStart = new windowObject.Event("touchstart", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
Object.defineProperty(microphoneTouchStart, "changedTouches", {
|
||||
value: [{
|
||||
clientX: touchCanvasRect.left + selectedTouchPixel.x,
|
||||
clientY: touchCanvasRect.top + selectedTouchPixel.y,
|
||||
}],
|
||||
});
|
||||
canvas.dispatchEvent(microphoneTouchStart);
|
||||
assert(microphoneTouchStart.defaultPrevented, "touch on a microphone reserves the drag gesture");
|
||||
dispatchTouchDragPointer("pointermove", 22, 3, 2, 1);
|
||||
dispatchTouchDragPointer("pointerup", 22, 3, 2, 0);
|
||||
await nextPaint(windowObject);
|
||||
assertEqual(
|
||||
selectedCoordinates(documentObject).join(","),
|
||||
afterNudge.join(","),
|
||||
"touch jitter below the gesture threshold does not move a microphone",
|
||||
);
|
||||
|
||||
const blankTouchStart = new windowObject.Event("touchstart", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
Object.defineProperty(blankTouchStart, "changedTouches", {
|
||||
value: [{ clientX: touchCanvasRect.left + 3, clientY: touchCanvasRect.top + 3 }],
|
||||
});
|
||||
canvas.dispatchEvent(blankTouchStart);
|
||||
assert(!blankTouchStart.defaultPrevented, "blank canvas touch remains available to page scroll");
|
||||
|
||||
dispatchTouchDragPointer("pointerdown", 23, 0, 0, 1);
|
||||
dispatchTouchDragPointer("pointermove", 23, 24, -18, 1);
|
||||
dispatchTouchDragPointer("pointerup", 23, 24, -18, 0);
|
||||
await nextPaint(windowObject);
|
||||
const touchDraggedPosition = selectedCoordinates(documentObject);
|
||||
assert(
|
||||
touchDraggedPosition[0] !== afterNudge[0] && touchDraggedPosition[1] !== afterNudge[1],
|
||||
"touch movement beyond the threshold drags both coordinates",
|
||||
);
|
||||
documentObject.querySelector("#undo-button").click();
|
||||
assertEqual(
|
||||
selectedCoordinates(documentObject).join(","),
|
||||
afterNudge.join(","),
|
||||
"one undo restores the whole touch drag",
|
||||
);
|
||||
|
||||
documentObject.querySelector("#add-tool-button").click();
|
||||
const canvasRect = canvas.getBoundingClientRect();
|
||||
canvas.dispatchEvent(new windowObject.PointerEvent("pointerdown", {
|
||||
@@ -190,6 +374,44 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
|
||||
documentObject.querySelector("#remove-microphone-button").click();
|
||||
await waitFor(() => microphoneCount(documentObject) === 13, "manual delete");
|
||||
|
||||
documentObject.querySelector("#add-tool-button").click();
|
||||
const dispatchTouchAddPointer = (type, pointerId, offsetX, offsetY, buttons) =>
|
||||
canvas.dispatchEvent(new windowObject.PointerEvent(type, {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
pointerId,
|
||||
pointerType: "touch",
|
||||
isPrimary: true,
|
||||
button: type === "pointermove" ? -1 : 0,
|
||||
buttons,
|
||||
clientX: canvasRect.left + canvasRect.width * 0.7 + offsetX,
|
||||
clientY: canvasRect.top + canvasRect.height * 0.35 + offsetY,
|
||||
}));
|
||||
assert(
|
||||
dispatchTouchAddPointer("pointerdown", 32, 0, 0, 1),
|
||||
"touch add pointerdown remains available to page scroll",
|
||||
);
|
||||
assertEqual(microphoneCount(documentObject), 13, "touch add waits for pointerup");
|
||||
assert(
|
||||
dispatchTouchAddPointer("pointermove", 32, 0, 12, 1),
|
||||
"touch add scroll movement is not canceled",
|
||||
);
|
||||
dispatchTouchAddPointer("pointerup", 32, 0, 12, 0);
|
||||
await nextPaint(windowObject);
|
||||
assertEqual(microphoneCount(documentObject), 13, "touch scroll intent does not add a microphone");
|
||||
|
||||
dispatchTouchAddPointer("pointerdown", 33, 0, 0, 1);
|
||||
assertEqual(microphoneCount(documentObject), 13, "touch tap still waits while pressed");
|
||||
dispatchTouchAddPointer("pointermove", 33, 3, 2, 1);
|
||||
assert(
|
||||
!dispatchTouchAddPointer("pointerup", 33, 3, 2, 0),
|
||||
"completed touch add tap prevents its synthetic click",
|
||||
);
|
||||
await waitFor(() => microphoneCount(documentObject) === 14, "touch canvas microphone add");
|
||||
documentObject.querySelector("#select-tool-button").click();
|
||||
documentObject.querySelector("#remove-microphone-button").click();
|
||||
await waitFor(() => microphoneCount(documentObject) === 13, "touch-added microphone delete");
|
||||
|
||||
const firstButton = microphoneButtons(documentObject)[0];
|
||||
const secondButton = microphoneButtons(documentObject)[1];
|
||||
firstButton.click();
|
||||
@@ -228,6 +450,20 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
|
||||
assert(calibrated, "edited channel is exported");
|
||||
assertEqual(calibrated.sensitivityDb, -2.5, "sensitivity is preserved");
|
||||
assertEqual(calibrated.phaseOffsetDeg, 3.5, "phase is preserved");
|
||||
assertEqual(
|
||||
exported.microphones.indexOf(calibrated),
|
||||
exported.microphones.length - 2,
|
||||
"export keeps the edited physical channel order",
|
||||
);
|
||||
calibrated.frequencyResponse = [
|
||||
{ frequencyHz: 100, gainDb: -2, phaseDeg: -4 },
|
||||
{ frequencyHz: 1000, gainDb: 0.5, phaseDeg: 3 },
|
||||
];
|
||||
assertEqual(
|
||||
(await validateArrayDefinition(exported)).length,
|
||||
0,
|
||||
"response-enhanced round trip fixture contract",
|
||||
);
|
||||
|
||||
dispatchValue(windowObject, documentObject.querySelector("#array-name"), "Temporary name");
|
||||
const exportedFile = new windowObject.File(
|
||||
@@ -240,6 +476,27 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
|
||||
() => documentObject.querySelector("#array-name").value === exported.name,
|
||||
"Array round trip import",
|
||||
);
|
||||
assert(!documentObject.querySelector("#undo-button").disabled, "valid import is undoable");
|
||||
documentObject.querySelector("#undo-button").click();
|
||||
assertEqual(
|
||||
documentObject.querySelector("#array-name").value,
|
||||
"Temporary name",
|
||||
"import undo restores the previous document",
|
||||
);
|
||||
assert(
|
||||
documentObject.querySelector("#save-state").classList.contains("is-dirty"),
|
||||
"document before imported baseline is dirty",
|
||||
);
|
||||
documentObject.querySelector("#redo-button").click();
|
||||
assertEqual(
|
||||
documentObject.querySelector("#array-name").value,
|
||||
exported.name,
|
||||
"import redo restores the loaded document",
|
||||
);
|
||||
assert(
|
||||
!documentObject.querySelector("#save-state").classList.contains("is-dirty"),
|
||||
"loaded baseline is clean after redo",
|
||||
);
|
||||
microphoneButtons(documentObject).find(
|
||||
(button) => button.dataset.microphoneId === "reference-channel",
|
||||
).click();
|
||||
@@ -248,6 +505,62 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
|
||||
-2.5,
|
||||
"import keeps sensitivity",
|
||||
);
|
||||
assert(
|
||||
documentObject.querySelector("#frequency-response-summary").textContent.includes("2개"),
|
||||
"import exposes preserved frequency-response points",
|
||||
);
|
||||
const importedReferenceX = Number(documentObject.querySelector("#microphone-x").value);
|
||||
dispatchValue(
|
||||
windowObject,
|
||||
documentObject.querySelector("#microphone-x"),
|
||||
importedReferenceX + 0.001,
|
||||
);
|
||||
documentObject.querySelector("#export-button").click();
|
||||
await waitFor(
|
||||
() => downloads.filter((item) => item.fileName).length === 2,
|
||||
"edited response Array export",
|
||||
);
|
||||
const editedExport = JSON.parse(
|
||||
await downloads.filter((item) => item.fileName).at(-1).blob.text(),
|
||||
);
|
||||
assertEqual(
|
||||
JSON.stringify(
|
||||
editedExport.microphones.find(
|
||||
(microphone) => microphone.id === "reference-channel",
|
||||
).frequencyResponse,
|
||||
),
|
||||
JSON.stringify(calibrated.frequencyResponse),
|
||||
"position edit preserves every frequency-response value",
|
||||
);
|
||||
|
||||
const responseVariant = structuredClone(editedExport);
|
||||
responseVariant.microphones.find(
|
||||
(microphone) => microphone.id === "reference-channel",
|
||||
).frequencyResponse[0].gainDb -= 1;
|
||||
const responseVariantFile = new windowObject.File(
|
||||
[JSON.stringify(responseVariant)],
|
||||
"response-variant.array.json",
|
||||
{ type: "application/json" },
|
||||
);
|
||||
dispatchFiles(
|
||||
windowObject,
|
||||
documentObject.querySelector("#array-input"),
|
||||
[responseVariantFile],
|
||||
);
|
||||
await waitFor(
|
||||
() => documentObject.querySelector("#save-state").textContent.includes("불러옴"),
|
||||
"same-length response variant import",
|
||||
);
|
||||
documentObject.querySelector("#undo-button").click();
|
||||
assert(
|
||||
documentObject.querySelector("#save-state").classList.contains("is-dirty"),
|
||||
"undo detects changed response values even when the point count is unchanged",
|
||||
);
|
||||
documentObject.querySelector("#redo-button").click();
|
||||
assert(
|
||||
!documentObject.querySelector("#save-state").classList.contains("is-dirty"),
|
||||
"response variant baseline is clean after redo",
|
||||
);
|
||||
|
||||
const countBeforeInvalidImport = microphoneCount(documentObject);
|
||||
const invalidFile = new windowObject.File(
|
||||
@@ -266,12 +579,109 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
|
||||
"invalid import preserves current array",
|
||||
);
|
||||
|
||||
const cameraFrame = document.createElement("iframe");
|
||||
cameraFrame.src = "/apps/acoustic-camera/?integration-test=1&array-builder-handoff=1";
|
||||
cameraFrame.style.cssText =
|
||||
"position:fixed;left:-20000px;top:0;width:1400px;height:1000px;border:0";
|
||||
document.body.append(cameraFrame);
|
||||
await new Promise((resolve, reject) => {
|
||||
cameraFrame.addEventListener("load", resolve, { once: true });
|
||||
cameraFrame.addEventListener(
|
||||
"error",
|
||||
() => reject(new Error("Camera handoff iframe load failed")),
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
const cameraWindow = cameraFrame.contentWindow;
|
||||
const cameraDocument = cameraFrame.contentDocument;
|
||||
const cameraDownloads = [];
|
||||
const originalCameraCreate = cameraWindow.URL.createObjectURL.bind(cameraWindow.URL);
|
||||
const originalCameraRevoke = cameraWindow.URL.revokeObjectURL.bind(cameraWindow.URL);
|
||||
const originalCameraClick = cameraWindow.HTMLAnchorElement.prototype.click;
|
||||
cameraWindow.URL.createObjectURL = (blob) => {
|
||||
const url = "blob:array-camera-test-" + String(cameraDownloads.length + 1);
|
||||
cameraDownloads.push({ blob, url, fileName: null });
|
||||
return url;
|
||||
};
|
||||
cameraWindow.URL.revokeObjectURL = () => {};
|
||||
cameraWindow.HTMLAnchorElement.prototype.click = function captureCameraDownload() {
|
||||
const download = cameraDownloads.find((item) => item.url === this.href);
|
||||
if (download) download.fileName = this.download;
|
||||
};
|
||||
try {
|
||||
await waitFor(
|
||||
() => cameraDocument.documentElement.dataset.appReady === "true",
|
||||
"Camera handoff initialization",
|
||||
);
|
||||
const handoffFile = new cameraWindow.File(
|
||||
[JSON.stringify(editedExport)],
|
||||
"builder-handoff.array.json",
|
||||
{ type: "application/json" },
|
||||
);
|
||||
dispatchFiles(cameraWindow, cameraDocument.querySelector("#array-input"), [handoffFile]);
|
||||
await waitFor(
|
||||
() => cameraDocument.querySelector("#array-preset").value === "custom" &&
|
||||
cameraDocument.querySelector("#microphone-count").textContent.includes(
|
||||
String(editedExport.microphones.length),
|
||||
) &&
|
||||
cameraDocument.documentElement.dataset.cameraValid === "true",
|
||||
"Camera custom Array acceptance",
|
||||
);
|
||||
assertEqual(
|
||||
cameraDocument.querySelector("#array-title").textContent,
|
||||
editedExport.name,
|
||||
"Camera shows the Builder array name",
|
||||
);
|
||||
cameraDocument.querySelector("#export-experiment-button").click();
|
||||
await waitFor(
|
||||
() => cameraDownloads.some((item) => item.fileName),
|
||||
"Camera Experiment export after handoff",
|
||||
);
|
||||
const cameraExperiment = JSON.parse(
|
||||
await cameraDownloads.find((item) => item.fileName).blob.text(),
|
||||
);
|
||||
assertEqual(
|
||||
cameraExperiment.arrayId,
|
||||
editedExport.id,
|
||||
"Camera rebinds Experiment to the Builder Array ID",
|
||||
);
|
||||
} finally {
|
||||
cameraWindow.URL.createObjectURL = originalCameraCreate;
|
||||
cameraWindow.URL.revokeObjectURL = originalCameraRevoke;
|
||||
cameraWindow.HTMLAnchorElement.prototype.click = originalCameraClick;
|
||||
cameraFrame.remove();
|
||||
}
|
||||
|
||||
frame.style.width = "390px";
|
||||
await waitFor(
|
||||
() => documentObject.querySelector("#array-builder-layout").dataset.splitPaneActive === "false",
|
||||
"compact Array Builder layout",
|
||||
);
|
||||
await nextPaint(windowObject);
|
||||
const compactWorkspace = documentObject.querySelector("#array-builder-workspace");
|
||||
const compactControls = documentObject.querySelector("#array-builder-controls");
|
||||
const compactWorkspaceRect = compactWorkspace.getBoundingClientRect();
|
||||
const compactControlsRect = compactControls.getBoundingClientRect();
|
||||
assert(
|
||||
Boolean(
|
||||
compactWorkspace.compareDocumentPosition(compactControls) &
|
||||
windowObject.Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
),
|
||||
"compact canvas workspace also precedes settings in DOM order",
|
||||
);
|
||||
assert(
|
||||
compactWorkspaceRect.bottom <= compactControlsRect.top + 1,
|
||||
"compact canvas workspace appears before the long settings form",
|
||||
);
|
||||
assert(
|
||||
documentObject.querySelector(".array-metadata-section").getBoundingClientRect().top <
|
||||
documentObject.querySelector(".aperture-section").getBoundingClientRect().top &&
|
||||
documentObject.querySelector(".aperture-section").getBoundingClientRect().top <
|
||||
documentObject.querySelector(".preset-section").getBoundingClientRect().top &&
|
||||
documentObject.querySelector(".preset-section").getBoundingClientRect().top <
|
||||
documentObject.querySelector(".microphone-section").getBoundingClientRect().top,
|
||||
"compact controls keep the numbered source and visual order",
|
||||
);
|
||||
assert(
|
||||
documentObject.documentElement.scrollWidth <=
|
||||
documentObject.documentElement.clientWidth + 1,
|
||||
@@ -281,6 +691,16 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
|
||||
documentObject.querySelector("#array-canvas").getBoundingClientRect().height >= 350,
|
||||
"Array canvas remains usable at 390px",
|
||||
);
|
||||
assertEqual(
|
||||
windowObject.getComputedStyle(documentObject.querySelector("#array-canvas")).touchAction,
|
||||
"pan-y",
|
||||
"mobile canvas permits vertical page scrolling",
|
||||
);
|
||||
assert(
|
||||
windowObject.getComputedStyle(documentObject.querySelector("#mobile-export-button")).display
|
||||
!== "none",
|
||||
"compact settings end with a visible save action",
|
||||
);
|
||||
} finally {
|
||||
windowObject.URL.createObjectURL = originalCreate;
|
||||
windowObject.URL.revokeObjectURL = originalRevoke;
|
||||
|
||||
Reference in New Issue
Block a user