Files
Marcos Paulo 5e8020aa38
ci / ci (macos-latest) (push) Has been cancelled
ci / ci (ubuntu-latest) (push) Has been cancelled
ci / ci (windows-latest) (push) Has been cancelled
feat: Obsidian basics, editor previews, graph rework, query blocks, stacked tabs, pop-out windows
Editor previews (colour swatches, arrows, embeds), the 18 obsidian-basics tasks (find and replace
with regex, back/forward, auto-pair, footnotes, folding, search operators, split view, PDF/audio/
video embeds, spelling, drag and drop, pinned tabs, settings) and round 2: ```query blocks, stacked
tabs, pop-out windows, tasks ticked inside embeds, text around an embed, embeds that follow edits,
block menu shortcut. New crates: notescraft-regex (linear-time engine) and notescraft-syntax.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 11:21:19 -03:00

148 lines
8.5 KiB
Rust

//! Acceptance: a JSON format or minify rewrite is ONE undo step.
//!
//! `json.format_block` and `json.minify_block` go through `Session::lint_commit`, which edits
//! the open buffer as a single replacement: one `buffer.undo` takes the whole format back,
//! `buffer.redo` re-applies it, and unsaved edits elsewhere in the note survive. Without an
//! open buffer the same edit is one atomic write, and formatting an already formatted block
//! reports `changed: false` and leaves the file untouched. For comparison, `block.turn_into`
//! and `block.move` are checked to be one undo step as well.
mod common;
use common::{TempVault, on_disk, seed, temp_vault};
use notescraft_engine::{Registry, Session, register_core};
use serde_json::{Value, json};
/// The fixture note: one fenced JSON block with compact JSON.
const NOTE: &str = "# T\n\n```json\n{\"b\":1,\"a\":[1e5]}\n```\n";
/// The same note with the JSON pretty-printed; the ONLY change a format makes.
const FORMATTED: &str = "# T\n\n```json\n{\n \"b\": 1,\n \"a\": [\n 1e5\n ]\n}\n```\n";
/// A vault with `n.md` holding `note`, a registry with the core commands, and an open session.
fn setup(name: &str, note: &str) -> (Registry<Session>, Session, TempVault) {
let vault = temp_vault(name);
seed(vault.path(), &[("n.md", note)]);
let mut registry = Registry::new();
register_core(&mut registry).expect("register");
let session = Session::open(vault.path()).expect("open");
(registry, session, vault)
}
/// Runs `id` on the session, panicking with the error instead of returning a bare `Err`.
fn run(registry: &Registry<Session>, session: &mut Session, id: &str, params: &Value) -> Value {
registry.run(id, session, params).unwrap_or_else(|error| panic!("{id}: {error:?}"))
}
/// The current text of the `n.md` buffer.
fn text(session: &Session) -> String {
session.buffer("n.md").expect("the buffer is open").text().to_owned()
}
/// One `json.format_block` is one undo step: undo restores the exact original, redo re-applies
/// it, and a later `json.minify_block` is one undo step too.
#[test]
fn json_format_and_minify_are_one_undo_step_each() {
let (registry, mut session, vault) = setup("json-undo", NOTE);
run(&registry, &mut session, "buffer.open", &json!({ "path": "n.md" }));
let result = run(&registry, &mut session, "json.format_block", &json!({ "path": "n.md", "index": 1 }));
assert_eq!(result["changed"], json!(true));
assert_eq!(text(&session), FORMATTED);
assert_eq!(on_disk(vault.path(), "n.md"), NOTE, "a buffer edit is not a save");
let result = run(&registry, &mut session, "buffer.undo", &json!({ "path": "n.md" }));
assert_eq!(result["undone"], json!(true));
assert_eq!(text(&session), NOTE, "ONE undo restores the exact original text");
assert_eq!(result["dirty"], json!(false), "the buffer is back to the saved bytes");
let result = run(&registry, &mut session, "buffer.undo", &json!({ "path": "n.md" }));
assert_eq!(result["undone"], json!(false), "the format was ONE step: nothing more to undo");
let result = run(&registry, &mut session, "buffer.redo", &json!({ "path": "n.md" }));
assert_eq!(result["redone"], json!(true));
assert_eq!(text(&session), FORMATTED, "ONE redo re-applies the format");
let result = run(&registry, &mut session, "json.minify_block", &json!({ "path": "n.md", "index": 1 }));
assert_eq!(result["changed"], json!(true));
assert_eq!(text(&session), NOTE, "the minified body is the original compact body");
run(&registry, &mut session, "buffer.undo", &json!({ "path": "n.md" }));
assert_eq!(text(&session), FORMATTED, "the minify is ONE undo step, not more");
}
/// For comparison: `block.turn_into` and `block.move` are one undo step each in an open buffer.
#[test]
fn turn_into_and_move_are_one_undo_step_each() {
const PROSE: &str = "# Title\n\nFirst para\n\nSecond para\n";
let (registry, mut session, _vault) = setup("block-undo-turn", PROSE);
run(&registry, &mut session, "buffer.open", &json!({ "path": "n.md" }));
run(&registry, &mut session, "block.turn_into", &json!({ "path": "n.md", "index": 1, "target": "heading2" }));
assert_eq!(text(&session), "# Title\n\n## First para\n\nSecond para\n");
run(&registry, &mut session, "buffer.undo", &json!({ "path": "n.md" }));
assert_eq!(text(&session), PROSE, "ONE undo restores the original");
let result = run(&registry, &mut session, "buffer.undo", &json!({ "path": "n.md" }));
assert_eq!(result["undone"], json!(false), "the turn into was ONE step");
let (registry, mut session, _vault) = setup("block-undo-move", PROSE);
run(&registry, &mut session, "buffer.open", &json!({ "path": "n.md" }));
run(&registry, &mut session, "block.move", &json!({ "path": "n.md", "index": 2, "direction": "up" }));
assert_eq!(text(&session), "# Title\n\nSecond para\n\nFirst para\n");
run(&registry, &mut session, "buffer.undo", &json!({ "path": "n.md" }));
assert_eq!(text(&session), PROSE, "ONE undo restores the original");
}
/// A buffer with unsaved user edits elsewhere keeps them: the format touches only the JSON
/// block, and one undo removes only the format.
#[test]
fn format_keeps_unsaved_edits_elsewhere_in_the_buffer() {
const ORIGINAL: &str = "# T\n\nIntro\n\n```json\n{\"b\":1,\"a\":[1e5]}\n```\n";
const EDITED: &str = "# T\n\nOutro\n\n```json\n{\"b\":1,\"a\":[1e5]}\n```\n";
let (registry, mut session, vault) = setup("json-undo-dirty", ORIGINAL);
run(&registry, &mut session, "buffer.open", &json!({ "path": "n.md" }));
let start = text(&session).find("Intro").unwrap_or_default();
let end = start.saturating_add("Intro".len());
session.buffer_mut("n.md").expect("the buffer is open").edit(start..end, "Outro").expect("user edit");
assert_eq!(text(&session), EDITED);
let result = run(&registry, &mut session, "json.format_block", &json!({ "path": "n.md", "index": 2 }));
assert_eq!(result["changed"], json!(true));
assert_eq!(text(&session), "# T\n\nOutro\n\n```json\n{\n \"b\": 1,\n \"a\": [\n 1e5\n ]\n}\n```\n");
assert_eq!(on_disk(vault.path(), "n.md"), ORIGINAL, "nothing is saved behind the user's back");
run(&registry, &mut session, "buffer.undo", &json!({ "path": "n.md" }));
assert_eq!(text(&session), EDITED, "undo removes only the format, the user edit stays");
run(&registry, &mut session, "buffer.redo", &json!({ "path": "n.md" }));
assert_eq!(text(&session), "# T\n\nOutro\n\n```json\n{\n \"b\": 1,\n \"a\": [\n 1e5\n ]\n}\n```\n");
}
/// Without an open buffer the format is one write: an already formatted block reports
/// `changed: false` and leaves the file bytes and mtime untouched.
#[test]
fn format_without_an_open_buffer_writes_the_file_once() {
let (registry, mut session, vault) = setup("json-no-buffer", NOTE);
let result = run(&registry, &mut session, "json.format_block", &json!({ "path": "n.md", "index": 1 }));
assert_eq!(result["changed"], json!(true));
assert_eq!(on_disk(vault.path(), "n.md"), FORMATTED, "the format reached the disk");
let mtime = std::fs::metadata(vault.path().join("n.md")).and_then(|meta| meta.modified()).expect("mtime");
let result = run(&registry, &mut session, "json.format_block", &json!({ "path": "n.md", "index": 1 }));
assert_eq!(result["changed"], json!(false), "the block is already formatted");
assert_eq!(on_disk(vault.path(), "n.md"), FORMATTED, "the bytes are identical");
let again = std::fs::metadata(vault.path().join("n.md")).and_then(|meta| meta.modified()).expect("mtime");
assert_eq!(again, mtime, "an unchanged format does not rewrite the file");
}
/// A CRLF note keeps CRLF everywhere, also inside the formatted JSON (no mixed line endings).
#[test]
fn format_in_a_crlf_note_keeps_crlf_outside_the_json() {
const CRLF: &str = "# T\r\n\r\n```json\r\n{\"b\":1,\"a\":[1e5]}\r\n```\r\n";
let (registry, mut session, _vault) = setup("json-crlf", CRLF);
run(&registry, &mut session, "buffer.open", &json!({ "path": "n.md" }));
let result = run(&registry, &mut session, "json.format_block", &json!({ "path": "n.md", "index": 1 }));
assert_eq!(result["changed"], json!(true));
let after = text(&session);
assert_eq!(after, "# T\r\n\r\n```json\r\n{\r\n \"b\": 1,\r\n \"a\": [\r\n 1e5\r\n ]\r\n}\r\n```\r\n");
assert!(after.starts_with("# T\r\n\r\n```json\r\n"), "the lines outside the JSON keep CRLF");
run(&registry, &mut session, "buffer.undo", &json!({ "path": "n.md" }));
assert_eq!(text(&session), CRLF, "ONE undo restores the CRLF original");
}