Files
reverse-engineering-anything/tests/boundary/providers/desktopXmlTextPython.test.ts
T
Edgar ArakelyanandClaude Opus 5.5 ac23dc8077 feat(desktop): read XML with a plain DOCTYPE, and check an element's whole text (#9050)
* feat(desktop): read XML with a plain DOCTYPE, and check an element's whole text

- Both readers accept a DOCTYPE with only a name and an optional PUBLIC
  or SYSTEM identifier, record it as the view's doctype, and never fetch
  the DTD or expand entities beyond XML's five; an internal subset, an
  external entity and a parameter entity stay refused alike.
- text_equals and text_contains compare the concatenated text of the
  element (or text node or attribute) a pointer reaches, as XPath
  string() does, with optional normalize_whitespace; both evaluators
  agree, and a text check on a truncated element is undecided.
- Whitespace-only text between elements is dropped only when the element
  holds no other text, so text split into runs keeps its spaces.
- The draft suggests text_equals for an element whose text changed.

So an AbiWord .abw reads as XML, and its paragraph's text is checked
whole however AbiWord splits it into <c> runs, as Writer's spans are.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(desktop): draft whole-text checks on the paragraph, read only text as text, and skip asctime stamps

- A changed value is read at both ends, so text that became a run still
  marks its paragraph, and the drafted text_equals moves up out of mixed
  content to the nearest ancestor named by an attribute, such as
  AbiWord's p by its xid.
- Text checks accept only a text node or an attribute value; element
  names and doctype fields fail, in both evaluators.
- isTimestamp also recognises asctime dates (Www Mmm dd hh:mm:ss yyyy).
- XML files are drafted with the keyed diff the archive entries use, so
  no suggestion reaches an element by position; the shared logic moves to
  appTaskDraftNamed.ts.
- Text-check tests assert facts, not whole sentences.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(desktop): keep checks on a changed attribute inside a paragraph whose text check replaces its runs'

A drafted whole-text check on a paragraph hid every change inside it, so a
link whose label and href both changed drafted only the paragraph's text and
the new destination went unchecked. The text check now covers text and runs
added or removed whole; a changed name or attribute of an element both
versions hold keeps its own check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(desktop): read an attribute called name or attributes as an attribute when the draft keeps its check

The owner of `.../attributes/name` was taken as `.../attributes`, so a
changed `name` attribute inside a paragraph with a text check was dropped
again. The owner is now read by segment, attributes first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(desktop): draft text checks from text in runs added whole, and name a text element by its own id before its container

Text typed into a run the diff lists whole was never read, so the draft kept
only a presence check on the run, which wrong text also passed. Such text now
marks the nearest element both ends hold, or, when none can take the check,
the added element's own text. The climb also names an element by an
identifier of its own (id, xml:id, xid) before a larger container, so a
headline under a long layer keeps a whole-text check that survives re-split
runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(desktop): expect the whole-text check where #8988's pairing test meets text checks

On this branch a changed element's text is drafted as `text_equals` on the
element, so the identity pairing test now expects that check, under the
same `[@role=body]` key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(desktop): keep checks for elements without text beside a text edit, and normalize DOCTYPE system id line endings

A review found two gaps.

- A paragraph's whole-text check covered every element added or removed
  inside it, including ones holding no text: an image frame, a line break.
  A solver who typed the text but never inserted the image passed. Such an
  element now keeps its own presence or absence check.
- The Python reader kept a DOCTYPE system literal's CRLF where TypeScript
  normalizes it to LF. Both now report LF.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(desktop): import textSyntaxFormat once in the state format tests

main's mixed-case JSON suffix fix and this branch both added the import,
and the merge kept both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 03:20:12 +00:00

213 lines
7.1 KiB
TypeScript

import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import { describe, expect, it } from "vitest";
import type { DesktopTaskCheck } from "../../../src/domain/desktop/appTask.js";
import { xmlView } from "../../../src/domain/desktop/stateFormats.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import {
expectSameCheckResults,
expectSameFileViews,
odtOf,
pythonXmlViews,
savedEnds,
typescriptFileView,
} from "../../support/desktopExportPython.js";
/** The meeting note as AbiWord saves it, before and after its time was changed. */
const notes = () => savedEnds("abiword", "meeting.abw");
/** Writer's flyer before and after an edit split its body paragraph into runs, zipped with its mimetype. */
const flyers = () =>
savedEnds("libreoffice-runs", "content.xml", "flyer.odt", odtOf);
const NOTE = "Documents/meeting.abw";
const PARAGRAPH = "/root/children/[name=section]/children/[name=p][@xid=3]";
const MEETING = "The team meeting starts at 3:30 in room 4.";
/** The paragraph's whole text, a time within it, and its first text node, which the edit split. */
const NOTE_CHECKS: DesktopTaskCheck[] = [
{
id: "time",
kind: "text_equals",
file: NOTE,
pointer: PARAGRAPH,
value: MEETING,
},
{
id: "within",
kind: "text_contains",
file: NOTE,
pointer: PARAGRAPH,
value: "at 3:30 in",
},
{
id: "first-node",
kind: "value_equals",
file: NOTE,
pointer: `${PARAGRAPH}/children/0`,
value: MEETING,
},
];
const FLYER = "Documents/flyer.odt";
const BODY =
"/entries/[name=content.xml]/view/root/children/[name=office:body]/children/[name=office:text]/children/[name=text:p][@text:style-name=Standard]";
const CAKES = "Cakes, biscuits and bread from 9:30 in the village green.";
const FLYER_CHECKS: DesktopTaskCheck[] = [
{ id: "body", kind: "text_equals", file: FLYER, pointer: BODY, value: CAKES },
{
id: "time",
kind: "text_contains",
file: FLYER,
pointer: BODY,
value: " 9:30\n",
normalize_whitespace: true,
},
{
id: "reflowed",
kind: "text_equals",
file: FLYER,
pointer: BODY,
value: " Cakes, biscuits and bread\n from 9:30 in the village green. ",
normalize_whitespace: true,
},
{
id: "first-run",
kind: "value_equals",
file: FLYER,
pointer: `${BODY}/children/0`,
value: CAKES,
},
];
const passes = (outcomes: readonly { passed: boolean }[] | undefined) =>
outcomes?.map(({ passed }) => passed);
describe("XML with a DOCTYPE in the exported module", () => {
it("reads an AbiWord document, which names its DTD, as REA does: the element tree and the declaration", async () => {
const files = await notes();
await expectSameFileViews(files);
const [, after] = files;
if (after === undefined) throw new Error("fixture");
const { format, view } = await typescriptFileView(after);
expect(format).toBe("xml");
expect(view).toMatchObject({
doctype: {
name: "abiword",
public_id: "-//ABISOURCE//DTD AWML 1.0 Strict//EN",
system_id: "http://www.abisource.com/awml.dtd",
},
root: { name: "abiword", attributes: { "xml:space": "preserve" } },
truncated: false,
});
});
it("reads a DOCTYPE that only names a DTD alike, and refuses an internal subset or an undeclared entity in both", () => {
const accepted = [
"<!DOCTYPE a><a/>",
'<?xml version="1.0"?><!--c--><!DOCTYPE b SYSTEM "x[1].dtd"><a>t</a>',
"<!DOCTYPE a PUBLIC ' -//A\r\n B//EN ' 's y'><a/>",
'<!DOCTYPE a PUBLIC "" ""><a b="&lt;&#65;">&amp;&quot;&apos;&gt;</a>',
'<!DOCTYPE svg:svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg:svg xmlns:svg="http://www.w3.org/2000/svg"/>',
'<!DOCTYPE a SYSTEM "x\r\ny\rz\n"><a/>',
];
const refused = [
"<!DOCTYPE a []><a/>",
'<!DOCTYPE a [<!ENTITY e "boom">]><a/>',
'<!DOCTYPE a [<!ENTITY e "boom">]><a>&e;</a>',
'<!DOCTYPE a SYSTEM "a.dtd" [<!ENTITY e SYSTEM "file:///etc/hostname">]><a>&e;</a>',
'<!DOCTYPE a [<!ENTITY % p SYSTEM "file:///etc/hostname"> %p;]><a/>',
'<!DOCTYPE a SYSTEM "a.dtd"><a>&e;</a>',
'<!DOCTYPE a SYSTEM "a.dtd"><a><b c="x &e; y"/></a>',
'<!DOCTYPE a PUBLIC "-//A//EN"><a/>',
'<!DOCTYPE a PUBLIC "x{" "y"><a/>',
"<a/><!DOCTYPE a>",
"<!DOCTYPE a><!DOCTYPE a><a/>",
];
const texts = [...accepted, ...refused];
const ours = texts.map((text) => xmlView(text) ?? null);
expect(pythonXmlViews(texts)).toEqual(ours);
expect(ours.slice(0, accepted.length)).not.toContain(null);
expect(ours.slice(accepted.length)).toEqual(refused.map(() => null));
expect(ours[2]).toMatchObject({
doctype: { name: "a", public_id: "-//A B//EN", system_id: "s y" },
});
expect(ours[5]).toMatchObject({
doctype: { name: "a", public_id: null, system_id: "x\ny\nz\n" },
});
});
it("never reads the DTD a DOCTYPE names: neither its entities nor its default attributes apply", async () => {
const folder = await createTestTempDirectory("desktop-xml-dtd-");
const dtd = join(folder, "a.dtd");
await writeFile(
dtd,
'<!ENTITY e "from the DTD"><!ATTLIST a injected CDATA "from the DTD">',
);
const system = pathToFileURL(dtd).href;
const texts = [
`<!DOCTYPE a SYSTEM "${system}"><a/>`,
`<!DOCTYPE a SYSTEM "${system}"><a>&e;</a>`,
`<!DOCTYPE a SYSTEM "${system}"><a b="&e;"/>`,
];
const ours = texts.map((text) => xmlView(text) ?? null);
expect(pythonXmlViews(texts)).toEqual(ours);
expect(ours).toEqual([
{
doctype: { name: "a", public_id: null, system_id: system },
root: { name: "a", attributes: {}, children: [] },
truncated: false,
},
null,
null,
]);
});
});
describe("whole-text checks in both evaluators", () => {
it("checks the meeting time across AbiWord's runs: the whole text fails before and passes after, its first node does not", async () => {
const [before, after] = await expectSameCheckResults(
NOTE,
NOTE_CHECKS,
await notes(),
);
expect(passes(before)).toEqual([false, false, false]);
for (const fact of [
PARAGRAPH,
JSON.stringify("The team meeting starts at 2:00 in room 4."),
])
expect(before?.[0]?.description).toContain(fact);
expect(passes(after)).toEqual([true, true, false]);
// The first text node holds only the text before the first run.
expect(after?.[2]?.description).toContain(
JSON.stringify("The team meeting starts at "),
);
});
it("reads Writer's runs in its archive alike, the space between two runs included", async () => {
const files = await flyers();
await expectSameFileViews(files);
const [before, after] = await expectSameCheckResults(
FLYER,
FLYER_CHECKS,
files,
);
expect(passes(before)).toEqual([false, false, false, false]);
expect(passes(after)).toEqual([true, true, true, false]);
// The value searched for is the collapsed one, " 9:30\n" read as "9:30".
expect(after?.[1]?.description).toContain(JSON.stringify("9:30"));
});
});