Skip to content

Commit 28f523b

Browse files
committed
attribution pipeline: tighten --attribution=git e2e regression test
Pins the full data-attr-* contract (seconds-unit time, mail-local-part display name, distinct per-actor hsl colours) on the existing two- author cli fixture, refreshes the stale Phase 0 doc-comment, and drops the unix-only ignore so Windows CI exercises the path too.
1 parent f15e0bd commit 28f523b

1 file changed

Lines changed: 106 additions & 19 deletions

File tree

crates/quarto/tests/attribution_cli_e2e.rs

Lines changed: 106 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,23 +3,32 @@
33
* Copyright (c) 2026 Posit, PBC
44
*/
55

6-
//! Phase 0 test #9 — end-to-end CLI fixture with two-author git history.
6+
//! End-to-end regression test for `q2 render --attribution=git`.
77
//!
8-
//! The test builds a temp git repo on every invocation
9-
//! (`tempdir` + `git init` + two scripted commits by distinct
10-
//! authors), with `GIT_AUTHOR_DATE` / `GIT_COMMITTER_DATE` / author
11-
//! identities pinned so the porcelain output and commit hashes are
12-
//! bit-deterministic. It copies
13-
//! `crates/quarto-core/tests/fixtures/attribution-blame/doc.qmd` into
14-
//! the tempdir, then runs
15-
//! `cargo run --bin q2 -- render <tempdir>/doc.qmd --to html
16-
//! --attribution=git`. Asserts the produced HTML contains
17-
//! `data-attr-actor="<email>"` strings matching the two scripted
18-
//! author emails.
8+
//! Builds a temp git repo on every invocation (`tempdir` + `git init`
9+
//! + two scripted commits by distinct authors), with
10+
//! `GIT_AUTHOR_DATE` / `GIT_COMMITTER_DATE` / author identities pinned
11+
//! so the porcelain output and commit hashes are bit-deterministic.
12+
//! Copies `crates/quarto-core/tests/fixtures/attribution-blame/doc.qmd`
13+
//! into the tempdir, then runs
14+
//! `q2 render <tempdir>/doc.qmd --to html --attribution=git` and
15+
//! asserts the full `data-attr-*` contract on the produced HTML:
1916
//!
20-
//! **Phase 0 status: RED.** Until Phase 3c lands the `--attribution`
21-
//! flag and Phase 3a lands `GitBlameProvider`, the binary will reject
22-
//! the flag with a clap usage error and the test will fail.
17+
//! * `data-attr-actor` — the author email (per-commit blame credit).
18+
//! * `data-attr-time` — Unix epoch **seconds** for the git provider.
19+
//! (Automerge / hub-client uses ms; the unit is
20+
//! part of the wire contract — see
21+
//! `docs/authoring/attribution.qmd`.)
22+
//! * `data-attr-name` — derived display name (mail-local-part).
23+
//! * `data-attr-color` — deterministic `hsl(...)` from the email hash.
24+
//!
25+
//! This is the one test that exercises the live `git blame --porcelain`
26+
//! shell-out (`GitBlameProvider::build` in
27+
//! `crates/quarto-core/src/attribution/git_blame.rs`); the fixture-
28+
//! based unit tests in `attribution_gitblame.rs` only cover the
29+
//! parser. Any regression in CLI flag wiring, working-directory
30+
//! resolution, or porcelain handling on real git output should surface
31+
//! here first.
2332
2433
use std::path::{Path, PathBuf};
2534
use std::process::Command;
@@ -123,10 +132,6 @@ fn locate_fixture() -> PathBuf {
123132
}
124133

125134
#[test]
126-
#[cfg_attr(
127-
not(any(target_os = "linux", target_os = "macos")),
128-
ignore = "git scripting fixture is unix-only for Phase 0"
129-
)]
130135
fn cli_attribution_git_emits_data_attr_actor_for_both_authors() {
131136
let fixture = locate_fixture();
132137
assert!(
@@ -156,6 +161,8 @@ fn cli_attribution_git_emits_data_attr_actor_for_both_authors() {
156161
// The output html lives next to the input by default; find it.
157162
let html_path = tmp.path().join("doc.html");
158163
let html = std::fs::read_to_string(&html_path).expect("read rendered html");
164+
165+
// data-attr-actor — author email per commit blame credit.
159166
assert!(
160167
html.contains(&format!("data-attr-actor=\"{}\"", ALICE_EMAIL)),
161168
"alice's email must appear as data-attr-actor; html:\n{}",
@@ -166,4 +173,84 @@ fn cli_attribution_git_emits_data_attr_actor_for_both_authors() {
166173
"bob's email must appear as data-attr-actor; html:\n{}",
167174
html
168175
);
176+
177+
// data-attr-time — Unix epoch SECONDS for the git provider. The
178+
// scripted commit times (@1700000000, @1700100000) flow through
179+
// `git blame --porcelain`'s author-time and must arrive verbatim.
180+
// A regression to milliseconds would shift to 13-digit values
181+
// (1_700_000_000_000) and fail this assertion.
182+
assert!(
183+
html.contains("data-attr-time=\"1700000000\""),
184+
"alice's commit time (seconds) must appear as data-attr-time; html:\n{}",
185+
html
186+
);
187+
assert!(
188+
html.contains("data-attr-time=\"1700100000\""),
189+
"bob's commit time (seconds) must appear as data-attr-time; html:\n{}",
190+
html
191+
);
192+
193+
// data-attr-name — display name derived from the email
194+
// local-part. Pins the derivation that
195+
// `docs/authoring/attribution.qmd` advertises ("mail-local-part
196+
// plus a deterministic HSL colour").
197+
assert!(
198+
html.contains("data-attr-name=\"alice\""),
199+
"alice's display name must appear; html:\n{}",
200+
html
201+
);
202+
assert!(
203+
html.contains("data-attr-name=\"bob\""),
204+
"bob's display name must appear; html:\n{}",
205+
html
206+
);
207+
208+
// data-attr-color — deterministic hsl() from the email hash. We
209+
// don't pin specific hue values (the palette function may evolve)
210+
// but the wire format is part of the contract: it must be an
211+
// `hsl(...)` triple, distinct between the two authors so the
212+
// per-actor derivation is exercised end-to-end.
213+
let alice_color =
214+
extract_attr_value(&html, ALICE_EMAIL, "data-attr-color").expect("alice color present");
215+
let bob_color =
216+
extract_attr_value(&html, BOB_EMAIL, "data-attr-color").expect("bob color present");
217+
assert!(
218+
alice_color.starts_with("hsl("),
219+
"alice's data-attr-color must be hsl(); got {alice_color}"
220+
);
221+
assert!(
222+
bob_color.starts_with("hsl("),
223+
"bob's data-attr-color must be hsl(); got {bob_color}"
224+
);
225+
assert_ne!(
226+
alice_color, bob_color,
227+
"per-actor color derivation must yield distinct hues"
228+
);
229+
}
230+
231+
/// Look up the value of `attr` on the same element that carries
232+
/// `data-attr-actor="<email>"`. Returns the substring between the
233+
/// quotes after `attr=`, or `None` if the pairing isn't found.
234+
///
235+
/// Used by the color assertions to extract values for comparison
236+
/// without hard-coding the palette function's output — keeps the test
237+
/// stable across deterministic palette tweaks while still pinning the
238+
/// per-actor distinctness contract.
239+
fn extract_attr_value(html: &str, actor_email: &str, attr: &str) -> Option<String> {
240+
let actor_marker = format!("data-attr-actor=\"{}\"", actor_email);
241+
let needle = format!("{}=\"", attr);
242+
// Walk every occurrence of the actor marker; the matching attr
243+
// sits on the same tag, which in this writer means within the
244+
// same `<...>` element opener.
245+
for actor_at in html.match_indices(&actor_marker).map(|(i, _)| i) {
246+
let tag_start = html[..actor_at].rfind('<')?;
247+
let tag_end = html[tag_start..].find('>')? + tag_start;
248+
let tag = &html[tag_start..=tag_end];
249+
if let Some(attr_at) = tag.find(&needle) {
250+
let value_start = attr_at + needle.len();
251+
let value_end = tag[value_start..].find('"')? + value_start;
252+
return Some(tag[value_start..value_end].to_string());
253+
}
254+
}
255+
None
169256
}

0 commit comments

Comments
 (0)