fix: retain completed results during live editing

This commit is contained in:
2026-07-27 13:05:02 +02:00
parent 7f3a91ad37
commit d2e9b3b569
22 changed files with 576 additions and 117 deletions

View File

@@ -37,6 +37,7 @@ const syntaxHarness = vi.hoisted(() => ({
const engineHarness = vi.hoisted(() => ({
requests: [] as RegexExecutionRequest[],
emitWholeFirstCharacterMatch: false,
}));
vi.mock("../editors/CodeEditor", () => ({
@@ -45,18 +46,35 @@ vi.mock("../editors/CodeEditor", () => ({
onChange,
label,
readOnly,
marks,
selectedRange,
onSelectionChange,
}: {
readonly value: string;
readonly onChange?: (value: string) => void;
readonly label: string;
readonly readOnly?: boolean;
readonly marks?: readonly unknown[];
readonly selectedRange?: { readonly startUtf16: number };
readonly onSelectionChange?: (range: {
readonly startUtf16: number;
readonly endUtf16: number;
}) => void;
}) {
return (
<textarea
aria-label={label}
value={value}
readOnly={readOnly}
data-mark-count={marks?.length ?? 0}
data-selected-start={selectedRange?.startUtf16}
onChange={(event) => onChange?.(event.target.value)}
onSelect={(event) =>
onSelectionChange?.({
startUtf16: event.currentTarget.selectionStart,
endUtf16: event.currentTarget.selectionEnd,
})
}
/>
);
},
@@ -115,7 +133,18 @@ vi.mock("../regex/execution/EngineSupervisor", () => ({
internallyAddedIndicesFlag: false,
internallyAddedGlobalFlag: false,
},
matches: [],
matches: engineHarness.emitWholeFirstCharacterMatch
? [
{
matchNumber: 1,
value: request.subject.slice(0, 1),
valueStatus: "complete" as const,
range: { startUtf16: 0, endUtf16: 1 },
nativeRange: { start: 0, end: 1, unit: "utf16" as const },
captures: [],
},
]
: [],
diagnostics: [],
elapsedMs: 1,
truncated: false,
@@ -231,6 +260,7 @@ afterEach(() => {
syntaxHarness.patterns.length = 0;
syntaxHarness.replacements.length = 0;
engineHarness.requests.length = 0;
engineHarness.emitWholeFirstCharacterMatch = false;
});
describe("Workbench syntax coordination", () => {
@@ -346,6 +376,79 @@ describe("Workbench syntax coordination", () => {
});
});
it("does not revive an old syntax snapshot when rapid edits return to the same text", async () => {
vi.useFakeTimers();
render(<Workbench />);
fireEvent.click(screen.getByRole("checkbox", { name: "Live" }));
await advanceParseDebounce();
await resolvePattern(0, []);
const patternInput = screen.getByRole("textbox", {
name: "Regular expression pattern",
});
const initialPattern = (patternInput as HTMLTextAreaElement).value;
const runButton = screen.getByRole("button", { name: "Run" });
expect(runButton).toBeEnabled();
fireEvent.change(patternInput, { target: { value: "temporary" } });
fireEvent.change(patternInput, { target: { value: initialPattern } });
expect(runButton).toBeDisabled();
fireEvent.click(screen.getByRole("treeitem"));
expect(patternInput).toHaveAttribute("data-mark-count", "0");
await advanceParseDebounce();
await resolvePattern(1, []);
expect(patternInput).toHaveAttribute("data-mark-count", "0");
expect(runButton).toBeEnabled();
});
it("retains one exact result frame until the edited subject has a replacement frame", async () => {
vi.useFakeTimers();
engineHarness.emitWholeFirstCharacterMatch = true;
render(<Workbench />);
fireEvent.click(screen.getByRole("checkbox", { name: "Live" }));
await advanceParseDebounce();
await resolvePattern(0, []);
fireEvent.click(screen.getByRole("button", { name: "Run" }));
await act(async () => {
await Promise.resolve();
});
const subjectInput = screen.getByRole("textbox", { name: "Test text" });
const originalSubject = (subjectInput as HTMLTextAreaElement).value;
expect(subjectInput).toHaveAttribute("data-mark-count", "1");
expect(document.querySelector(".extraction-row")).toHaveTextContent("2");
fireEvent.change(subjectInput, { target: { value: "Z new subject" } });
expect(screen.getByTestId("stale-result-frame")).toHaveTextContent(
"bound to the last completed inputs",
);
expect(subjectInput).toHaveAttribute("data-mark-count", "0");
expect(document.querySelector(".extraction-row")).toHaveTextContent("2");
fireEvent.change(subjectInput, { target: { value: originalSubject } });
expect(screen.getByTestId("stale-result-frame")).toBeInTheDocument();
expect(subjectInput).toHaveAttribute("data-mark-count", "0");
fireEvent.change(subjectInput, { target: { value: "Z new subject" } });
(subjectInput as HTMLTextAreaElement).setSelectionRange(0, 1);
fireEvent.select(subjectInput);
expect(
document.querySelector(".extraction-row.is-selected"),
).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Run" }));
await act(async () => {
await Promise.resolve();
});
expect(screen.queryByTestId("stale-result-frame")).not.toBeInTheDocument();
expect(subjectInput).toHaveAttribute("data-mark-count", "1");
expect(document.querySelector(".extraction-row")).toHaveTextContent("Z");
});
it("captures a complete result and reruns only the selected saved test", async () => {
vi.useFakeTimers();
render(<Workbench />);