mirror of
https://github.com/morluto/rea.git
synced 2026-11-08 07:46:28 +00:00
* 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>
187 lines
6.2 KiB
TypeScript
187 lines
6.2 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
||
|
||
import type { DesktopTaskCheck } from "../../../src/domain/desktop/appTask.js";
|
||
import { xmlView } from "../../../src/domain/desktop/stateFormats.js";
|
||
import {
|
||
expectSameCheckResults,
|
||
expectSameFileViews,
|
||
odtOf,
|
||
pythonXmlViews,
|
||
savedEnds,
|
||
typescriptFileView,
|
||
} from "../../support/desktopExportPython.js";
|
||
|
||
/** Inkscape's saves of the poster, before and after the headline was changed. */
|
||
const posters = () => savedEnds("inkscape", "poster.svg");
|
||
|
||
/** LibreOffice Writer's saves of the flyer, before and after the date was changed, zipped with their mimetype. */
|
||
const flyers = () =>
|
||
savedEnds("libreoffice", "content.xml", "flyer.odt", odtOf);
|
||
|
||
const POSTER = "Pictures/poster.svg";
|
||
const FLYER = "Documents/flyer.odt";
|
||
|
||
/** The poster's checks: its headline's text, then a setting Inkscape keeps in a namespaced attribute. */
|
||
const POSTER_CHECKS: DesktopTaskCheck[] = [
|
||
{
|
||
id: "headline",
|
||
kind: "value_equals",
|
||
file: POSTER,
|
||
pointer:
|
||
"/root/children/[name=g][@inkscape:label=Layer 1]/children/[name=text][@id=headline]/children/[name=tspan]/children/0",
|
||
value: "Summer Bake Sale",
|
||
},
|
||
{
|
||
id: "units",
|
||
kind: "value_equals",
|
||
file: POSTER,
|
||
pointer:
|
||
"/root/children/[name=sodipodi:namedview]/attributes/inkscape:document-units",
|
||
value: "mm",
|
||
},
|
||
];
|
||
|
||
const DATE: DesktopTaskCheck = {
|
||
id: "date",
|
||
kind: "value_equals",
|
||
file: FLYER,
|
||
pointer:
|
||
"/entries/[name=content.xml]/view/root/children/[name=office:body]/children/[name=office:text]/children/[name=text:p][@text:style-name=Subtitle]/children/0",
|
||
value: "Monday 24 November",
|
||
};
|
||
|
||
const passes = (outcomes: readonly { passed: boolean }[] | undefined) =>
|
||
outcomes?.map(({ passed }) => passed);
|
||
|
||
describe("namespaced XML in the exported module", () => {
|
||
it("reads an Inkscape SVG as REA does: prefixed names, the default namespace and its declarations", async () => {
|
||
const files = await posters();
|
||
await expectSameFileViews(files);
|
||
|
||
const [, after] = files;
|
||
if (after === undefined) throw new Error("fixture");
|
||
const { view } = await typescriptFileView(after);
|
||
expect(view).toMatchObject({
|
||
root: {
|
||
name: "svg",
|
||
attributes: {
|
||
xmlns: "http://www.w3.org/2000/svg",
|
||
"xmlns:svg": "http://www.w3.org/2000/svg",
|
||
"xmlns:sodipodi":
|
||
"http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd",
|
||
"sodipodi:docname": "poster.svg",
|
||
},
|
||
children: [{ name: "defs" }, { name: "sodipodi:namedview" }, {}],
|
||
},
|
||
truncated: false,
|
||
});
|
||
});
|
||
|
||
it("checks the poster's headline by name: failing before the edit and passing after, in both evaluators", async () => {
|
||
const [before, after] = await expectSameCheckResults(
|
||
POSTER,
|
||
POSTER_CHECKS,
|
||
await posters(),
|
||
);
|
||
|
||
expect(passes(before)).toEqual([false, true]);
|
||
expect(before?.[0]?.description).toContain(
|
||
'is "Spring Bake Sale", not "Summer Bake Sale"',
|
||
);
|
||
expect(passes(after)).toEqual([true, true]);
|
||
});
|
||
|
||
it("reads a Writer document's content.xml as REA does, in its archive", async () => {
|
||
const files = await flyers();
|
||
await expectSameFileViews(files);
|
||
|
||
const [, after] = files;
|
||
if (after === undefined) throw new Error("fixture");
|
||
const { view } = await typescriptFileView(after);
|
||
expect(view).toMatchObject({
|
||
entries: [
|
||
{ name: "mimetype", format: "text" },
|
||
{
|
||
name: "content.xml",
|
||
format: "xml",
|
||
view: {
|
||
root: {
|
||
name: "office:document-content",
|
||
attributes: {
|
||
"xmlns:text": "urn:oasis:names:tc:opendocument:xmlns:text:1.0",
|
||
"office:version": "1.3",
|
||
},
|
||
},
|
||
},
|
||
},
|
||
],
|
||
});
|
||
});
|
||
|
||
it("checks the flyer's date by name: failing before the edit and passing after, in both evaluators", async () => {
|
||
const [before, after] = await expectSameCheckResults(
|
||
FLYER,
|
||
[DATE],
|
||
await flyers(),
|
||
);
|
||
|
||
expect(passes(before)).toEqual([false]);
|
||
expect(before?.[0]?.description).toContain(
|
||
'is "Monday 17 November", not "Monday 24 November"',
|
||
);
|
||
expect(passes(after)).toEqual([true]);
|
||
});
|
||
|
||
it("splits text, CDATA and comments, spells xml: and xmlns names, and refuses documents, as REA does", () => {
|
||
const texts = [
|
||
'<a b="1" xmlns="urn:u" c="2" xmlns:p="urn:v" p:d="3"><e xmlns=""/><p:f xml:lang="en"/></a>',
|
||
"<a>x<![CDATA[y]]>z<![CDATA[]]>w<!--c-->v<?pi d?><![CDATA[q]]><![CDATA[r]]></a>",
|
||
'<a xml:space="preserve"><b/> <c xml:space=""><d/> </c></a>',
|
||
"<a><b/><![CDATA[ ]]><c/></a>",
|
||
"<p:a/>",
|
||
'<a xmlns:p="urn:u" xmlns:q="urn:u" p:x="1" q:x="2"/>',
|
||
'<a xmlns:p=""/>',
|
||
'<a:b:c xmlns:a="urn:u"/>',
|
||
"<a><?p:x y?></a>",
|
||
'<!DOCTYPE a [<!ENTITY e "x">]><a/>',
|
||
];
|
||
|
||
const ours = texts.map((text) => xmlView(text) ?? null);
|
||
|
||
expect(pythonXmlViews(texts)).toEqual(ours);
|
||
expect(ours.slice(4)).toEqual([null, null, null, null, null, null]);
|
||
expect(ours[1]).toMatchObject({
|
||
root: { children: ["x", "y", "zw", "v", "q", "r"] },
|
||
});
|
||
});
|
||
|
||
it("keeps whitespace between runs of mixed content, and drops only XML whitespace, as REA does", () => {
|
||
const texts = [
|
||
"<p>Meet on <s>Sunday</s> <s>evening</s>.<q>\n <s/>\n</q></p>",
|
||
"<p><s>Sunday</s> <s>evening</s></p>",
|
||
"<a>x<b/><![CDATA[ ]]><c/></a>",
|
||
"<r><a><b/> <b/></a><a><b/>\u0085<b/></a><a><b/><b/></a><a><b/>\t\r\n <b/></a></r>",
|
||
];
|
||
|
||
const ours = texts.map((text) => xmlView(text) ?? null);
|
||
|
||
expect(pythonXmlViews(texts)).toEqual(ours);
|
||
expect(ours[1]).toMatchObject({
|
||
root: { children: [{ name: "s" }, { name: "s" }] },
|
||
});
|
||
});
|
||
|
||
it("keeps REA's node and depth limits on prefixed names", () => {
|
||
const declared = 'xmlns:p="urn:u"';
|
||
const texts = [
|
||
`<p:a ${declared}>${"<p:b/>".repeat(5000)}</p:a>`,
|
||
`<p:a ${declared}>${"<p:d>".repeat(70)}${"</p:d>".repeat(70)}<p:e/></p:a>`,
|
||
];
|
||
|
||
const ours = texts.map((text) => xmlView(text) ?? null);
|
||
|
||
expect(pythonXmlViews(texts)).toEqual(ours);
|
||
expect(ours).toMatchObject([{ truncated: true }, { truncated: true }]);
|
||
});
|
||
});
|