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
2433use std:: path:: { Path , PathBuf } ;
2534use 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- ) ]
130135fn 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