diff --git a/docs/desktop-application-tasks.md b/docs/desktop-application-tasks.md index c08117aaf0..855c25c8c1 100644 --- a/docs/desktop-application-tasks.md +++ b/docs/desktop-application-tasks.md @@ -96,8 +96,8 @@ operation `describe_desktop_application_task` and predicate type in `.log` remain eligible for checks. A text file gains `lines_present` for its new lines and `lines_absent` for lines it lost (when either is at most ten), and `line_count` for each line - holding a letter whose count changed; a new line holding a value REA - recognizes as generated, such as a named epoch time or `ID_329023692`, gets a + holding a letter whose count changed; a new line holding an XML attribute + REA recognizes as generated, such as a named epoch time or `ID_329023692`, gets a `line_count` of the lines `contains` its text up to that value, as ` { }; describe("draftTaskFiles for lines holding values the app generates", () => { + it("keeps an unquoted assignment in ordinary prose exact", () => { + const line = "Set created=2025550123 and save"; + const file = drafted("# Instructions", `# Instructions\n${line}`); + expect(file.suggested).toEqual([ + { kind: "lines_present", file: FILE, lines: [line] }, + ]); + const checks = file.suggested.map((check, index) => ({ + ...check, + id: `c${index}`, + })); + const wrong = new Map([ + [ + FILE, + { + format: "text" as const, + view: textView("# Instructions\nSet created=WRONG and save"), + }, + ], + ]); + expect( + evaluateTaskChecks(checks, wrong).map(({ passed }) => passed), + ).toEqual([false]); + }); it("keeps assignment-like text quoted in a translation", () => { const translation = 'msgstr "Set created=2025550123 and save"'; const file = drafted(lines(), lines(translation)); @@ -304,21 +327,20 @@ describe("draftTaskFiles for added lines", () => { { kind: "lines_present", file: FILE, lines: [text] }, ]); }); - it("keeps phone numbers but counts a stable prefix before a named creation time", () => { + it("keeps assignment-like text exact when no XML structure identifies metadata", () => { const phone = 'topic TEXT="Call 2025550123"'; const timed = 'topic TEXT="Water filter" CREATED="1793955250671"'; const file = drafted(lines(), lines(phone, timed)); expect(file.suggested).toEqual( expect.arrayContaining([ - expect.objectContaining({ kind: "lines_present", lines: [phone] }), expect.objectContaining({ - kind: "line_count", - contains: 'topic TEXT="Water filter" CREATED="', + kind: "lines_present", + lines: [phone, timed], }), ]), ); }); - it("counts the lines holding a new line's text up to its first generated value, rather than the line whole, which another save would not repeat", () => { + it("keeps a non-XML line with ID-like assignments exact", () => { const file = drafted( lines(), lines( @@ -328,10 +350,11 @@ describe("draftTaskFiles for added lines", () => { expect(file.suggested).toEqual([ { - kind: "line_count", + kind: "lines_present", file: FILE, - contains: 'topic TEXT="Water filter" ID="', - equals: 1, + lines: [ + 'topic TEXT="Water filter" ID="ID_329023692" CREATED="1793955250671" MODIFIED="1793955252979"', + ], }, ]); expect(file.notes).toEqual([]); @@ -555,6 +578,29 @@ describe("draftTaskFiles for the app's log beside its journal", () => { }); }); +describe("draftTaskFiles for a lock the export leaves out of a state directory", () => { + it("drafts no check or guard on a lock directly in a state directory, which the export leaves out of the seed", () => { + const [lock] = draftOf( + { + ".config/notes/sync.lock": statePath( + "application_state", + "text", + textView("pid 1\n"), + textView("pid 2\n"), + ), + }, + [], + [".config/notes"], + ); + + expect(lock).toMatchObject({ + file: ".config/notes/sync.lock", + suggested: [], + guards: [], + }); + }); +}); + describe("draftTaskFiles guards of a line-keyed catalogue, as Poedit saves one", () => { it("guards the other records' lines of the changed line's keyword, never its own record's source reference or msgid", () => { const entry = (line: number, msgid: string, msgstr: string) => [ diff --git a/src/domain/desktop/appTaskDraftLineGuards.ts b/src/domain/desktop/appTaskDraftLineGuards.ts index bf354f22d2..481b2f34dc 100644 --- a/src/domain/desktop/appTaskDraftLineGuards.ts +++ b/src/domain/desktop/appTaskDraftLineGuards.ts @@ -141,11 +141,12 @@ const volatileAt = ( } } if ( - (isVolatileValue(token, key !== undefined && isBookkeepingName(key)) && + xmlAttribute && + ((isVolatileValue(token, key !== undefined && isBookkeepingName(key)) && !isUuid(token)) || - (key !== undefined && - /^(?:[^:]+:)?id$/iu.test(key) && - /^\p{L}+_\d{6,}$/u.test(token)) + (key !== undefined && + /^(?:[^:]+:)?id$/iu.test(key) && + /^\p{L}+_\d{6,}$/u.test(token))) ) return index; previous = token; diff --git a/src/domain/desktop/appTaskDraftModel.test.ts b/src/domain/desktop/appTaskDraftModel.test.ts index 40efc3b3d9..a545c8a76c 100644 --- a/src/domain/desktop/appTaskDraftModel.test.ts +++ b/src/domain/desktop/appTaskDraftModel.test.ts @@ -184,3 +184,49 @@ describe("draftTaskFiles for a zipped UML model", () => { ).toBe(false); }); }); + +describe("draftTaskFiles beside wide archive entries", () => { + it("drafts the zipped model's edit beside an entry holding a JSON array of 2,000,000 numbers, within the 4 MiB view limit", () => { + const ZIP = "Documents/library.zip"; + /** The model zipped as `library.xmi`, with `others` beside it. */ + const zipped = ( + text: string, + others: readonly JsonValue[] = [], + ): JsonValue => ({ + entries: [ + { + name: "library.xmi", + size: text.length, + sha256: "a".repeat(64), + format: "xml", + view: xml(text), + view_error: null, + }, + ...others, + ], + entry_count: 1 + others.length, + truncated: false, + }); + const wide = { + name: "samples.json", + size: 4_000_001, + sha256: "b".repeat(64), + format: "json", + view: Array.from({ length: 2_000_000 }, () => 0), + view_error: null, + }; + + const [file] = draftOf({ + [ZIP]: statePath( + "document", + "zip", + zipped(SEED, [wide]), + zipped(email(STRING), [wide]), + ), + }); + + expect(file?.suggested.map(pointerOf)).toContain( + `/entries/[name=library.xmi]/view${MEMBER}/children/[name=ownedAttribute][@name=email]/attributes/type`, + ); + }); +}); diff --git a/src/domain/desktop/stateFormats.ts b/src/domain/desktop/stateFormats.ts index ade457a55d..651710dfb3 100644 --- a/src/domain/desktop/stateFormats.ts +++ b/src/domain/desktop/stateFormats.ts @@ -34,7 +34,7 @@ const GVDB_HEADER = "GVariant"; const ZIP_HEADER = "PK\u0003\u0004"; const XML_EXTENSIONS = /\.(?:xml|xbel|plist|svg|ui)$/iu; /** A DOCTYPE that only names its DTD reads as XML; one with an internal subset, which can declare entities, does not. */ -const XML_POLICY = { +export const XML_POLICY = { doctype: "external", lineEndings: "normalize", nameSupported: expatXmlName,