Files
hyperframes/packages/studio/src/hooks/domEditTextFieldCommitOps.test.ts
T
Miguel Ángel 22942280b6 fix(studio): per-child patch op builders and persist-seam harness (#1909)
* test(studio): add design-panel QA fixture and triage matrix

Fixture project covering all panel-editable element archetypes,
plus the QA findings matrix from the design-panel bug campaign.

* fix(studio): make canvas selection hit intended elements

- honor author pointer-events:none in hit-testing (was selecting invisible overlays)
- pause playback before mousedown sampling; fall back to hover selection on null resolve
- invalidate committed selection when the active composition changes
- double-click keeps selection and defers to multi-candidate click cycling

* fix(studio): close remaining selection-layer review findings

- hoverSelection fallback now wired at all 3 mousedown call sites (box-click,
  blocked-drag, plain overlay click) instead of just the overlay path
- pointer-events override detection reads computed style, not inline style,
  so a CSS-class opt-in (not just inline style=) on a descendant is honored
- defensively remove the pointer-events override before the group-fallback
  check too, closing a theoretical gap in the no-elementsFromPoint branch
- a click that resolves to nothing (dead-zone / deselect) no longer leaves
  playback paused if it was already playing

* fix(studio-server): child-scoped patch operations with batch abort

- PatchOperation gains optional childSelector/childIndex resolved under the matched parent
- pre-pass resolves every op target; any miss aborts the batch with matched:false, no partial write
- style-decl parsing extracted to sourceStyleMutation to stay under the file-size cap
- new ./source-mutation subpath export (mirrors ./finite-mutation)

* fix(studio): per-child patch op builders and persist-seam harness

- buildTextFieldChildLocator indexes over the parent's full same-tag child list
- buildTextFieldChildOperations emits per-field ops for same-shape multi-field edits
- SDK cutover declines child-scoped batches (hfId mapping would hit the parent)
- persist-seam integration harness drives real client ops through patchElementInHtml

* fix(studio): fail closed on unresolved text-field child index

buildTextFieldChildLocator guessed a synthetic field's position by
counting same-tag "child" fields elsewhere in the array whenever
sourceChildIndex was absent. That heuristic is unreachable today (the
count-mismatch guard in buildTextFieldChildOperations already refuses
add/remove edits before it's reached) but would silently locate the
wrong element for a future caller that wires up synthetic-field
support without also computing a real sourceChildIndex. Return null
instead so the caller falls back to the unsupported-structure path.
2026-07-03 18:14:17 -07:00

112 lines
3.1 KiB
TypeScript

import { describe, expect, it } from "vitest";
import type { DomEditTextField } from "../components/editor/domEditing";
import { buildTextFieldChildOperations } from "./domEditTextFieldCommitOps";
function textField(input: {
key: string;
value: string;
tagName?: string;
inlineStyles?: Record<string, string>;
source?: DomEditTextField["source"];
sourceChildIndex?: number;
}): DomEditTextField {
return {
key: input.key,
label: input.key,
value: input.value,
tagName: input.tagName ?? "span",
attributes: [],
inlineStyles: input.inlineStyles ?? {},
computedStyles: {},
source: input.source ?? "child",
...(input.sourceChildIndex == null ? {} : { sourceChildIndex: input.sourceChildIndex }),
};
}
describe("buildTextFieldChildOperations", () => {
it("builds child-scoped text and style operations for changed child fields", () => {
const originalFields = [
textField({
key: "first",
value: "First",
inlineStyles: { color: "red" },
sourceChildIndex: 0,
}),
textField({
key: "second",
value: "Second",
inlineStyles: { "font-size": "24px" },
sourceChildIndex: 1,
}),
];
const nextFields = [
originalFields[0],
textField({
key: "second",
value: "Second < &",
inlineStyles: { "font-size": "24px", color: "#0000ff" },
sourceChildIndex: 1,
}),
];
expect(buildTextFieldChildOperations(originalFields, nextFields)).toEqual([
{
type: "text-content",
property: "text",
value: "Second < &",
childSelector: ":scope > span",
childIndex: 1,
},
{
type: "inline-style",
property: "color",
value: "#0000ff",
childSelector: ":scope > span",
childIndex: 1,
},
]);
});
it("emits null for a removed inline style", () => {
const originalFields = [
textField({
key: "first",
value: "First",
inlineStyles: { color: "red" },
sourceChildIndex: 0,
}),
];
const nextFields = [
textField({ key: "first", value: "First", inlineStyles: {}, sourceChildIndex: 0 }),
];
expect(buildTextFieldChildOperations(originalFields, nextFields)).toEqual([
{
type: "inline-style",
property: "color",
value: null,
childSelector: ":scope > span",
childIndex: 0,
},
]);
});
it("returns null for structural changes, reordered fields, and text nodes", () => {
const originalFields = [
textField({ key: "first", value: "First" }),
textField({ key: "second", value: "Second" }),
];
expect(buildTextFieldChildOperations(originalFields, [originalFields[0]])).toBeNull();
expect(
buildTextFieldChildOperations(originalFields, [originalFields[1], originalFields[0]]),
).toBeNull();
expect(
buildTextFieldChildOperations(originalFields, [
originalFields[0],
textField({ key: "second", value: "Second", tagName: "#text", source: "text-node" }),
]),
).toBeNull();
});
});