testmark-rs

document_test.rs

#[cfg(test)]
mod tests {
	use super::*;

	const DOC: &str = "\
Title\n\
=====\n\
\n\
Some prose.\n\
\n\
[testmark]:# (one/input)\n\
```text\n\
hello\n\
world\n\
```\n\
\n\
More prose, and an untagged block that must be ignored:\n\
\n\
```sh\n\
not a fixture\n\
```\n\
\n\
[testmark]:# (one/output)\n\
```\n\
```\n";

	// ----- existing code-hunk tests (minimally updated for new accessor API) -----

	#[test]
	fn parses_hunks_and_ignores_untagged_blocks() {
		let doc = Document::parse(DOC).expect("parses");
		assert_eq!(doc.hunks.len(), 2);
		assert_eq!(doc.hunk("one/input").unwrap().body().unwrap(), "hello\nworld\n");
		assert_eq!(doc.hunk("one/input").unwrap().block_tag().unwrap(), "text");
		assert_eq!(doc.hunk("one/output").unwrap().body().unwrap(), "");
	}

	#[test]
	fn patch_roundtrip_is_identity() {
		let doc = Document::parse(DOC).expect("parses");
		let same = doc.patched("one/input", "hello\nworld\n").unwrap();
		assert_eq!(same, DOC);
	}

	#[test]
	fn patch_replaces_only_the_target_hunk() {
		let doc = Document::parse(DOC).expect("parses");
		let patched = doc.patched("one/output", "fresh content\n").unwrap();
		let redoc = Document::parse(&patched).expect("reparses");
		assert_eq!(redoc.hunk("one/output").unwrap().body().unwrap(), "fresh content\n");
		assert_eq!(redoc.hunk("one/input").unwrap().body().unwrap(), "hello\nworld\n");
		assert!(patched.contains("not a fixture"));
		assert!(patched.contains("Some prose."));
	}

	#[test]
	fn patch_can_empty_a_hunk() {
		let doc = Document::parse(DOC).expect("parses");
		let patched = doc.patched("one/input", "").unwrap();
		let redoc = Document::parse(&patched).expect("reparses");
		assert_eq!(redoc.hunk("one/input").unwrap().body().unwrap(), "");
	}

	#[test]
	fn patch_of_unknown_hunk_is_none() {
		let doc = Document::parse(DOC).expect("parses");
		assert_eq!(doc.patched("no/such/hunk", "x\n"), None);
	}

	#[test]
	fn comment_lookalikes_inside_fences_are_inert() {
		// A `[testmark]:#` line inside a fenced block is content, not an annotation
		// (this is what lets fixture files document the testmark format itself).
		let doc = Document::parse("```\n[testmark]:# (ghost)\n```\n").expect("parses");
		assert_eq!(doc.hunks.len(), 0);
	}

	#[test]
	fn comment_trailing_content_is_reserved_and_ignored() {
		// The spec reserves everything after the first whitespace inside the parens.
		let doc = Document::parse("[testmark]:# (a future extension)\n```\nx\n```\n")
			.expect("parses");
		assert_eq!(doc.hunks.len(), 1);
		assert_eq!(doc.hunks[0].name, "a");
	}

	#[test]
	fn empty_document_parses() {
		let doc = Document::parse("").expect("parses");
		assert_eq!(doc.hunks.len(), 0);
	}

	#[test]
	fn rejects_dangling_comment() {
		let err = Document::parse("[testmark]:# (a)\nno fence here\n").unwrap_err();
		assert_eq!(
			err,
			ParseError::DanglingComment {
				line: 1,
			}
		);
	}

	#[test]
	fn rejects_comment_at_end_of_file() {
		// Dangling in the harshest way: nothing at all after the comment.
		let err = Document::parse("[testmark]:# (a)").unwrap_err();
		assert_eq!(
			err,
			ParseError::DanglingComment {
				line: 1,
			}
		);
	}

	#[test]
	fn rejects_malformed_comment() {
		let err = Document::parse("[testmark]:# missing-parens\n```\n```\n").unwrap_err();
		assert_eq!(
			err,
			ParseError::MalformedComment {
				line: 1,
			}
		);
	}

	#[test]
	fn rejects_empty_name() {
		let err = Document::parse("[testmark]:# ()\n```\n```\n").unwrap_err();
		assert_eq!(
			err,
			ParseError::MalformedComment {
				line: 1,
			}
		);
	}

	#[test]
	fn rejects_unclosed_fence() {
		let err = Document::parse("[testmark]:# (a)\n```\nstuff\n").unwrap_err();
		assert_eq!(
			err,
			ParseError::UnclosedFence {
				line: 2,
			}
		);
	}

	#[test]
	fn rejects_unclosed_untagged_fence() {
		let err = Document::parse("prose\n```\nstuff\n").unwrap_err();
		assert_eq!(
			err,
			ParseError::UnclosedFence {
				line: 2,
			}
		);
	}

	#[test]
	fn rejects_duplicate_names() {
		let doc = "[testmark]:# (a)\n```\nx\n```\n[testmark]:# (a)\n```\ny\n```\n";
		let err = Document::parse(doc).unwrap_err();
		assert_eq!(
			err,
			ParseError::DuplicateName {
				name: "a".into(),
				line: 5,
			}
		);
	}

	#[test]
	fn normalizes_crlf() {
		let doc = Document::parse("[testmark]:# (a)\r\n```\r\nx\r\n```\r\n").expect("parses");
		assert_eq!(doc.hunk("a").unwrap().body().unwrap(), "x\n");
	}

	#[test]
	fn error_display_uses_one_based_lines() {
		let err = Document::parse("prose\n[testmark]:# (a)\nno fence\n").unwrap_err();
		assert_eq!(
			err.to_string(),
			"line 2: testmark comment not followed by a code fence"
		);
	}

	// ----- table hunk tests -----

	#[test]
	fn parses_basic_table_hunk() {
		let src = "[testmark]:# (arith/cases)\n\
			| a | b | sum |\n\
			| --- | --- | --- |\n\
			| 1 | 2 | 3 |\n";
		let doc = Document::parse(src).expect("parses");
		assert_eq!(doc.hunks.len(), 1);
		let h = doc.hunk("arith/cases").unwrap();
		let HunkContent::Table(ref t) = h.content else { panic!("expected table") };
		assert_eq!(t.headers, vec!["a", "b", "sum"]);
		assert_eq!(t.rows.len(), 1);
		assert_eq!(t.rows[0], vec!["1", "2", "3"]);
	}

	#[test]
	fn parses_table_with_multiple_rows() {
		let src = "[testmark]:# (t)\n\
			| x | y |\n\
			| --- | --- |\n\
			| 10 | 20 |\n\
			| 30 | 40 |\n\
			| 50 | 60 |\n";
		let doc = Document::parse(src).expect("parses");
		let HunkContent::Table(ref t) = doc.hunk("t").unwrap().content else { panic!() };
		assert_eq!(t.headers, vec!["x", "y"]);
		assert_eq!(t.rows.len(), 3);
		assert_eq!(t.rows[2], vec!["50", "60"]);
	}

	#[test]
	fn separator_row_not_in_rows() {
		let src = "[testmark]:# (t)\n\
			| h |\n\
			| --- |\n\
			| d |\n";
		let doc = Document::parse(src).expect("parses");
		let HunkContent::Table(ref t) = doc.hunk("t").unwrap().content else { panic!() };
		// Separator must not appear in rows.
		assert_eq!(t.rows.len(), 1);
		assert_eq!(t.rows[0], vec!["d"]);
	}

	#[test]
	fn cells_are_trimmed() {
		let src = "[testmark]:# (t)\n\
			|  spaces  |  around  |\n\
			| --- | --- |\n\
			|  val  |  ue  |\n";
		let doc = Document::parse(src).expect("parses");
		let HunkContent::Table(ref t) = doc.hunk("t").unwrap().content else { panic!() };
		assert_eq!(t.headers, vec!["spaces", "around"]);
		assert_eq!(t.rows[0], vec!["val", "ue"]);
	}

	#[test]
	fn table_and_code_hunk_coexist() {
		let src = "[testmark]:# (tbl)\n\
			| a | b |\n\
			| --- | --- |\n\
			| 1 | 2 |\n\
			\n\
			[testmark]:# (code)\n\
			```\n\
			hello\n\
			```\n";
		let doc = Document::parse(src).expect("parses");
		assert_eq!(doc.hunks.len(), 2);
		assert!(matches!(doc.hunk("tbl").unwrap().content, HunkContent::Table(_)));
		assert!(matches!(doc.hunk("code").unwrap().content, HunkContent::Code { .. }));
		assert_eq!(doc.hunk("code").unwrap().body().unwrap(), "hello\n");
	}

	#[test]
	fn rejects_table_missing_separator() {
		// Only a header row, no separator.
		let src = "[testmark]:# (t)\n| a | b |\n";
		let err = Document::parse(src).unwrap_err();
		assert_eq!(err, ParseError::MissingTableSeparator { line: 2 });
	}

	#[test]
	fn rejects_table_bad_separator() {
		// Second row present but not a valid separator.
		let src = "[testmark]:# (t)\n| a | b |\n| x | y |\n| 1 | 2 |\n";
		let err = Document::parse(src).unwrap_err();
		assert_eq!(err, ParseError::MissingTableSeparator { line: 3 });
	}

	#[test]
	fn rejects_table_column_mismatch_in_data_row() {
		let src = "[testmark]:# (t)\n\
			| a | b |\n\
			| --- | --- |\n\
			| 1 | 2 | 3 |\n";
		let err = Document::parse(src).unwrap_err();
		assert_eq!(
			err,
			ParseError::TableColumnMismatch {
				line: 4,
				expected: 2,
				got: 3,
			}
		);
	}

	#[test]
	fn rejects_table_column_mismatch_in_separator() {
		let src = "[testmark]:# (t)\n\
			| a | b |\n\
			| --- |\n\
			| 1 | 2 |\n";
		let err = Document::parse(src).unwrap_err();
		assert_eq!(
			err,
			ParseError::TableColumnMismatch {
				line: 3,
				expected: 2,
				got: 1,
			}
		);
	}

	#[test]
	fn patched_table_roundtrip() {
		let src = "prose\n\
			[testmark]:# (t)\n\
			| a | b |\n\
			| --- | --- |\n\
			| 1 | 2 |\n\
			more prose\n";
		let doc = Document::parse(src).expect("parses");
		let new_table = ParsedTable {
			headers: vec!["a".into(), "b".into()],
			rows: vec![vec!["1".into(), "2".into()]],
		};
		let patched = doc.patched("t", NewContent::Table(&new_table)).unwrap();
		let redoc = Document::parse(&patched).expect("reparses");
		let HunkContent::Table(ref t) = redoc.hunk("t").unwrap().content else { panic!() };
		assert_eq!(t.headers, vec!["a", "b"]);
		assert_eq!(t.rows, vec![vec!["1", "2"]]);
		// Prose preserved.
		assert!(patched.contains("prose"));
		assert!(patched.contains("more prose"));
	}

	#[test]
	fn patch_with_explicit_code_changes_block_tag() {
		let doc = Document::parse(DOC).expect("parses");
		let patched = doc
			.patched("one/input", NewContent::Code { block_tag: "rust", body: "let x = 1;\n" })
			.unwrap();
		let redoc = Document::parse(&patched).expect("reparses");
		assert_eq!(redoc.hunk("one/input").unwrap().block_tag().unwrap(), "rust");
		assert_eq!(redoc.hunk("one/input").unwrap().body().unwrap(), "let x = 1;\n");
	}

	#[test]
	fn body_and_block_tag_return_none_for_table_hunk() {
		let src = "[testmark]:# (t)\n| a |\n| --- |\n| 1 |\n";
		let doc = Document::parse(src).expect("parses");
		let h = doc.hunk("t").unwrap();
		assert_eq!(h.body(), None);
		assert_eq!(h.block_tag(), None);
	}
}

crates/testmark/src/document_test.rs · 9.2 KiB · 9049dd4f1c