M4.55: a journal replay reproduces File Info's dates

File Info's dates come from the clock, so documents made or saved a second
apart differed: journal_replay_reproduces_document and
rich_fixture_satisfies_invariants_and_undoes_to_blank failed at random.
One approach for every clock-derived date: it is a command input that
defaults to now, and the journal records the value used.

- file.new takes created? and document.save / file.saveAs take modified?
  (Unix seconds, or null for none; default now), through date_param /
  clock_date on the one save path (save_with), so Save, Save As and the
  UI's save stamp alike; Save a Copy still leaves the dates.
- Their journal entries always record the date used, so replaying the
  journal makes the same document whenever it runs. Actions recorded from
  the journal (Session::journal_for_action) leave those dates out, so
  playing an action later dates the document then.
- Test fixtures date their documents fixtures::CREATED, and the MCP
  property test dates its local document as the headless one, so
  documents built at different moments compare equal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-05 11:10:32 +07:00
parent 53f42797f3
commit 851ed15104
10 changed files with 180 additions and 21 deletions

View File

@ -18,7 +18,7 @@ pub fn specs() -> Vec<CommandSpec> {
"New…",
["File"],
Some("Cmd+N"),
"{preset?: a name from file.newPresets (start from it; the other params change it), name?|title?: document name (default Untitled-N), width?: pt=612|\"210 mm\", height?: pt=792, units?: \"Pixels\"|\"Points\"|\"Picas\"|\"Inches\"|\"Millimeters\"|\"Centimeters\"|\"Feet\"|\"Yards\"|\"Meters\"|\"Feet & Inches\" (default: prefs unitsGeneral, as print presets; screen presets: Pixels), orientation?: \"portrait\"|\"landscape\" (swaps width and height to match), artboards?: n (1–1000), artboardLayout?: {layout?: \"gridByRow\"|\"gridByColumn\"|\"row\"|\"column\", columns?: n (default: all in one row), spacing?: pt=20, rightToLeft?: bool}, bleed?: pt|[top, bottom, left, right]|{top?, …} (0–72 pt), backgroundContents?: \"transparent\"|\"white\" (a white artboard background, not an object), colorMode?: \"rgb\"|\"cmyk\" (a CMYK document starts with CMYK swatches and stores the colours applied to it as CMYK), rasterEffectsPpi?: 72|150|300 (1–2400), previewMode?: \"default\"|\"pixel\"|\"overprint\" (overprint turns Overprint Preview on, pixel Pixel Preview in the app; default leaves both)} → {index, previewMode}; the size is remembered in file.newPresets' Recent",
"{preset?: a name from file.newPresets (start from it; the other params change it), name?|title?: document name (default Untitled-N), width?: pt=612|\"210 mm\", height?: pt=792, units?: \"Pixels\"|\"Points\"|\"Picas\"|\"Inches\"|\"Millimeters\"|\"Centimeters\"|\"Feet\"|\"Yards\"|\"Meters\"|\"Feet & Inches\" (default: prefs unitsGeneral, as print presets; screen presets: Pixels), orientation?: \"portrait\"|\"landscape\" (swaps width and height to match), artboards?: n (1–1000), artboardLayout?: {layout?: \"gridByRow\"|\"gridByColumn\"|\"row\"|\"column\", columns?: n (default: all in one row), spacing?: pt=20, rightToLeft?: bool}, bleed?: pt|[top, bottom, left, right]|{top?, …} (0–72 pt), backgroundContents?: \"transparent\"|\"white\" (a white artboard background, not an object), colorMode?: \"rgb\"|\"cmyk\" (a CMYK document starts with CMYK swatches and stores the colours applied to it as CMYK), rasterEffectsPpi?: 72|150|300 (1–2400), previewMode?: \"default\"|\"pixel\"|\"overprint\" (overprint turns Overprint Preview on, pixel Pixel Preview in the app; default leaves both), created?: Unix seconds|null (File Info's created date; default now, recorded in the journal so a replay matches)} → {index, previewMode}; the size is remembered in file.newPresets' Recent",
always,
super::newdoc::file_new
),

View File

@ -74,6 +74,9 @@ pub struct SavePlan {
pub name: String,
/// Suggested folder: the document's own, or the Templates folder for a template.
pub folder: Option<String>,
/// The `modified` param (as `date_param` reads it): the File Info date a Save or Save As to a file
/// stamps; `None`: the time it is written.
pub modified: Option<Option<i64>>,
}
impl SavePlan {
@ -85,12 +88,11 @@ impl SavePlan {
}
/// File Info's dates for a save to a file: the modified date (and the created date, when it has
/// none) become now. Not an undo step; the web build, without a clock, leaves them.
pub fn stamp_save_dates(st: &mut DocState) {
let Some(now) = vectorcraft_doc::metadata::now_unix() else { return };
/// none) become `at`. Not an undo step.
pub fn stamp_save_dates(st: &mut DocState, at: i64) {
let d = std::sync::Arc::make_mut(&mut st.doc);
d.metadata.created.get_or_insert(now);
d.metadata.modified = Some(now);
d.metadata.created.get_or_insert(at);
d.metadata.modified = Some(at);
}
/// The Templates folder: the `templatesFolder` preference, else `Documents/VectorCraft Templates`
@ -173,7 +175,8 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result<SavePlan> {
SaveMode::Template => templates_folder(&s.prefs),
_ => st.path.as_deref().and_then(parent_folder),
};
Ok(SavePlan { mode, path, format, options, name, folder })
let modified = date_param(p, "modified", cmd)?;
Ok(SavePlan { mode, path, format, options, name, folder, modified })
}
/// Does a save in `f` read option `key`? PDF (and a .ai file, a PDF) also reads its General
@ -228,8 +231,12 @@ fn fidelity_warning(f: &Format) -> Option<String> {
pub fn save_with(s: &mut Session, plan: SavePlan, mut write: impl FnMut(&str, &[u8]) -> Result<()>) -> Result<Value> {
let cmd = plan.mode.command();
let retargets = plan.retargets();
if plan.path.is_some() && retargets {
stamp_save_dates(s.doc_mut()?);
// The web build, without a clock, leaves the dates unless given one.
if plan.path.is_some()
&& retargets
&& let Some(at) = clock_date(s, "modified", plan.modified)
{
stamp_save_dates(s.doc_mut()?, at);
}
let own = doc_to_save(s.doc()?, plan.format);
// A native file records its links' paths relative to where it is written.
@ -339,7 +346,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {
"Save Document",
[],
None,
"{path?, format?: vectorcraft|template|pdf|svg|svgz|ai (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (SVG options may also be given flat; an SVG save keeps hidden layers, display:none, unless hiddenLayers is false)} → {path, format, bytes, warnings, linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified",
"{path?, format?: vectorcraft|template|pdf|svg|svgz|ai (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (SVG options may also be given flat; an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified",
has_doc,
|s, p| save(s, SaveMode::Save, p)
),
@ -348,7 +355,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {
"Save As…",
["File"],
Some("Cmd+Shift+S"),
"{path?, format?: vectorcraft|template|pdf|svg|svgz|ai (default: the path's extension, else the document's own), options?, svg?} the document takes on the new path, name and format (except a template, which is always a copy) → {path, format, bytes, warnings}; no path → {dataBase64, format, name, folder?, warnings}",
"{path?, format?: vectorcraft|template|pdf|svg|svgz|ai (default: the path's extension, else the document's own), options?, svg?, modified? (as document.save)} the document takes on the new path, name and format (except a template, which is always a copy) → {path, format, bytes, warnings}; no path → {dataBase64, format, name, folder?, warnings}",
has_doc,
|s, p| save(s, SaveMode::SaveAs, p)
),

View File

@ -380,6 +380,25 @@ pub(crate) fn parse_range(s: &str, count: usize) -> std::result::Result<Vec<usiz
Ok(out)
}
/// A date param in Unix seconds that defaults to the clock: `None` when absent (now, see
/// [`clock_date`]), `Some(None)` for null (no date), else `Some(Some(seconds))`.
pub(crate) fn date_param(p: &Value, key: &str, cmd: &str) -> Result<Option<Option<i64>>> {
match p.get(key) {
None => Ok(None),
Some(Value::Null) => Ok(Some(None)),
Some(v) => v.as_i64().map(|t| Some(Some(t))).ok_or_else(|| bad(cmd, format!("{key} must be Unix seconds (an integer) or null"))),
}
}
/// The date a command stamps: `given` (see [`date_param`]), else now (none on the web, which has
/// no clock). It joins the running command's journal entry as `key`, so a replay of the journal
/// gives the same date whenever it runs (an action leaves it out: [`crate::Session::journal_for_action`]).
pub(crate) fn clock_date(s: &mut Session, key: &str, given: Option<Option<i64>>) -> Option<i64> {
let t = given.unwrap_or_else(vectorcraft_doc::metadata::now_unix);
s.note_journal(key, serde_json::json!(t));
t
}
/// The Transparency panel's state ([`Session::transparency_info`]).
pub use opacitymask::TransparencyInfo;

View File

@ -558,7 +558,7 @@ pub(crate) fn file_new(s: &mut Session, p: &Value) -> Result<Value> {
Some(t) => t.to_string(),
None => s.next_untitled(),
};
d.metadata.created = vectorcraft_doc::metadata::now_unix();
d.metadata.created = clock_date(s, "created", date_param(p, "created", C)?);
let i = s.add_document(d, None);
// Overprint Preview is the engine's (the UI turns Pixel Preview on from `previewMode`). Both
// are views of the whole app, so Default leaves them as they are.

View File

@ -197,6 +197,11 @@ impl DocState {
/// Coordinates beyond this (points) are rejected: ~1,400 m, far past Illustrator's large canvas.
pub const MAX_COORD: f64 = 4.0e6;
/// `(command, param)` pairs the journal records only so a replay reproduces the original run
/// (dates from the clock: `cmd::clock_date`); an action leaves them out
/// ([`Session::journal_for_action`]).
const REPLAY_ONLY: [(&str, &str); 3] = [("file.new", "created"), ("document.save", "modified"), ("file.saveAs", "modified")];
/// Cheap sanity check after an edit: artboards and the objects just touched (the selection) must
/// have finite, in-range geometry, so saved files always reload and renderers never see NaN/∞.
fn doc_sane(d: &Document, sel: &Selection) -> bool {
@ -570,8 +575,8 @@ pub struct Session {
pub style_libraries: cmd::stylelib::Libraries,
/// URLs recently given in the Attributes panel (`attributes.set {url}`), newest first; not saved.
pub recent_urls: Vec<String>,
/// Parameters the running top-level command resolved from the preferences, added to its
/// journal entry so a replay does the same ([`Session::note_journal`]).
/// Parameters the running top-level command resolved from the preferences or the clock, added
/// to its journal entry so a replay does the same ([`Session::note_journal`]).
journal_note: serde_json::Map<String, Value>,
}
@ -748,13 +753,31 @@ impl Session {
}
/// Record `key: value` in the running top-level command's journal entry (or its interaction's
/// preview) unless its params give `key`: a value it resolved from the preferences.
/// preview) unless its params give `key`: a value it resolved from the preferences or the clock.
pub fn note_journal(&mut self, key: &str, value: Value) {
if self.depth == 1 {
self.journal_note.insert(key.to_string(), value);
}
}
/// The journal from entry `start` on, as an action records it: without the params that only
/// pin a replay to the original run ([`REPLAY_ONLY`]), so playing the action later acts now.
pub fn journal_for_action(&self, start: usize) -> Vec<(String, Value)> {
self.journal
.iter()
.skip(start)
.cloned()
.map(|(id, mut p)| {
if let Value::Object(m) = &mut p {
for (_, key) in REPLAY_ONLY.iter().filter(|(c, _)| *c == id) {
m.remove(*key);
}
}
(id, p)
})
.collect()
}
/// `params` with the noted values added (see [`Session::note_journal`]).
fn noted(&mut self, params: &Value) -> Value {
let mut note = std::mem::take(&mut self.journal_note);
@ -1012,6 +1035,8 @@ mod tests_gradient;
#[cfg(test)]
mod tests_gradpanel;
#[cfg(test)]
mod tests_journal;
#[cfg(test)]
mod tests_knockout;
#[cfg(test)]
mod tests_labspots;

View File

@ -0,0 +1,99 @@
//! The journal replays to the same document whenever it runs: values a command takes from the
//! clock (File ▸ New's created date, a save's modified date) are recorded in its entry, and left out
//! of recorded actions.
use serde_json::json;
use super::*;
fn created(s: &Session) -> Option<i64> {
s.doc().unwrap().doc.metadata.created
}
#[test]
fn file_new_records_its_created_date_in_the_journal() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();
let (id, p) = s.journal.last().unwrap().clone();
assert_eq!(id, "file.new");
assert!(created(&s).is_some(), "a new document is dated now");
assert_eq!(p, json!({"width": 200, "height": 100, "created": created(&s)}));
}
#[test]
fn file_new_takes_a_given_created_date() {
let mut s = Session::new();
s.execute("file.new", &json!({"created": 1_000_000_000})).unwrap();
assert_eq!(created(&s), Some(1_000_000_000));
s.execute("file.new", &json!({"created": null})).unwrap();
assert_eq!(created(&s), None);
// Given values are journaled as given.
assert_eq!(s.journal.last().unwrap().1, json!({"created": null}));
for bad in [json!("2026-01-01"), json!(1.5), json!(true)] {
assert!(s.execute("file.new", &json!({"created": bad})).is_err(), "{bad}");
}
assert_eq!(s.documents().len(), 2, "a bad date makes no document");
}
#[test]
fn replay_reproduces_the_created_date_of_an_earlier_run() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 300, "height": 200})).unwrap();
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap();
// As if the original run happened long ago: a replay now must still land on its date.
s.journal[0].1["created"] = json!(1_234_567_890);
let mut r = Session::new();
for (id, p) in &s.journal {
r.execute(id, p).unwrap();
}
assert_eq!(created(&r), Some(1_234_567_890));
assert_eq!(r.doc().unwrap().doc.node_count(), s.doc().unwrap().doc.node_count());
}
#[test]
fn recorded_actions_leave_out_replay_only_values() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 300, "height": 200})).unwrap();
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap();
s.execute("object.scale", &json!({"sx": 200})).unwrap();
let steps = s.journal_for_action(0);
assert_eq!(steps.len(), s.journal.len());
// Playing the action later dates its new document then…
assert_eq!(steps[0], ("file.new".to_string(), json!({"width": 300, "height": 200})));
// …while values resolved from the preferences stay recorded.
let scale = steps.iter().find(|(id, _)| id == "object.scale").unwrap();
assert!(scale.1.get("strokes").is_some(), "{scale:?}");
assert_eq!(s.journal_for_action(1), s.journal[1..].to_vec());
}
#[test]
fn a_save_records_its_date_and_replays_it() {
let path = vectorcraft_testkit::temp_dir("journal-save").join("a.vectorcraft").to_string_lossy().into_owned();
let mut s = Session::new();
s.execute("file.new", &json!({"created": null})).unwrap();
s.execute("document.save", &json!({"path": path})).unwrap();
let m = &s.doc().unwrap().doc.metadata;
assert!(m.modified.is_some() && m.created == m.modified, "a save dates a document without one");
let (id, p) = s.journal.last().unwrap().clone();
assert_eq!((id.as_str(), &p["modified"]), ("document.save", &json!(m.modified)));
// A given date is used as is; null leaves the dates alone.
s.execute("document.save", &json!({"path": path, "modified": 2_000_000_000})).unwrap();
assert_eq!(s.doc().unwrap().doc.metadata.modified, Some(2_000_000_000));
s.execute("document.save", &json!({"path": path, "modified": null})).unwrap();
assert_eq!(s.doc().unwrap().doc.metadata.modified, Some(2_000_000_000));
assert!(s.execute("document.save", &json!({"path": path, "modified": "today"})).is_err());
// Save As stamps the same way; Save a Copy leaves the document's dates (and records none).
let dir = vectorcraft_testkit::temp_dir("journal-save");
s.execute("file.saveAs", &json!({"path": dir.join("b.vectorcraft").to_string_lossy(), "modified": 2_000_000_100})).unwrap();
assert_eq!(s.journal.last().unwrap().1["modified"], json!(2_000_000_100));
s.execute("file.saveCopy", &json!({"path": dir.join("c.vectorcraft").to_string_lossy()})).unwrap();
assert!(s.journal.last().unwrap().1.get("modified").is_none());
assert_eq!(s.doc().unwrap().doc.metadata.modified, Some(2_000_000_100));
// Replayed later, the saves stamp the recorded dates; an action stamps the time it plays.
let mut r = Session::new();
for (id, p) in &s.journal {
r.execute(id, p).unwrap();
}
assert_eq!(r.doc().unwrap().doc.metadata, s.doc().unwrap().doc.metadata);
assert!(s.journal_for_action(0).iter().all(|(_, p)| p.get("modified").is_none() && p.get("created").is_none()));
}

View File

@ -212,9 +212,11 @@ proptest! {
#[test]
fn run_command_sequences_match_local_session(ops in arb_ops(5..40)) {
let mut srv = server();
let mut local = vectorcraft_testkit::fixtures::session_with(612.0, 792.0);
// Match the headless default document.
// Match the headless default document, dated as it is (File Info dates come from the clock).
let d_remote = doc_json(&document(&mut srv));
let mut local = vectorcraft_engine::Session::new();
let created = &d_remote["metadata"]["created"];
vectorcraft_testkit::fixtures::exec(&mut local, "file.new", json!({"width": 612, "height": 792, "created": created}));
let d_local = doc_json(&local.doc().unwrap().doc);
prop_assume!(d_remote == d_local);
for op in &ops {

View File

@ -23,10 +23,14 @@ pub fn id_of(v: &Value) -> NodeId {
NodeId(v["id"].as_u64().unwrap_or_else(|| panic!("no id in {v}")))
}
/// A session with one new `w`×`h` document.
/// The created date (Unix seconds) of every fixture document: File Info dates come from the clock
/// otherwise, and documents made a second apart would not compare equal.
pub const CREATED: i64 = 1_767_225_600;
/// A session with one new `w`×`h` document, dated [`CREATED`].
pub fn session_with(w: f64, h: f64) -> Session {
let mut s = Session::new();
exec(&mut s, "file.new", json!({"width": w, "height": h}));
exec(&mut s, "file.new", json!({"width": w, "height": h, "created": CREATED}));
s
}

View File

@ -97,7 +97,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) {
if widgets::icon_button(ui, "square", "Stop Playing/Recording", false, 24.0).clicked()
&& let Some((set, name, start)) = app.ui.recording.take()
{
let steps: Vec<(String, Value)> = app.session.journal.iter().skip(start).cloned().collect();
let steps = app.session.journal_for_action(start);
if let Some(s) = app.ui.action_sets.get_mut(set) {
s.actions.push(Action { name, steps });
}

View File

@ -920,7 +920,10 @@ ISO 8601 UTC. Pass any of the editable keys to change them in one undo step (`nu
list or a comma-separated string and keeps each word once); bad values are refused. PDF export writes the title,
author, description and keywords to the document info and XMP, PNG export writes them (with the copyright and
creation date) as text chunks, and SVG export writes the description as `<desc>` and, with the `metadata` option,
all of it as Dublin Core.
all of it as Dublin Core. `file.new {created}` and a save to a file (`document.save` or `file.saveAs` with
`{path, modified}`) use the given date (Unix seconds, or `null` for none) instead of now. Their journal entries always
record the date they used, so replaying the journal makes the same document whenever it runs (an action recorded in
the Actions panel leaves the date out: playing it later dates the document then).
```json
{"name":"run_command","arguments":{"command":"file.info","params":{"author":"Ada","keywords":"poster, fair","rating":4,"copyrightStatus":"copyrighted","copyrightNotice":"© 2026 Ada"}}}