1use std::{cmp::Ordering, collections::BTreeMap, time::Duration};
4
5use base64::{Engine, engine::general_purpose::STANDARD};
6use comrak::{
7 Options, markdown_to_html_with_plugins, options::Plugins, plugins::syntect::SyntectAdapter,
8};
9use jiff::{Timestamp, Zoned, tz::TimeZone};
10use maud::{DOCTYPE, Markup, PreEscaped, html};
11use rayon::prelude::*;
12use serde_json::Value;
13
14use crate::{
15 tools,
16 transcript::{Block, Folio, ImageSource, Known, Panel, PanelKind, Usage},
17};
18
19const FONT_NOTICES: [(&str, &str); 3] = [
24 ("Junicode", "Copyright 2025 Peter S. Baker"),
25 ("Fira Code", "Copyright 2014 The Fira Code Project Authors"),
26 (
27 "UnifrakturCook",
28 "Copyright 2010 j. 'mach' wust (Reserved Font Name UnifrakturCook), Copyright 2009 Peter Wiegel",
29 ),
30];
31
32fn font_notice() -> String {
34 let mut notice = String::from(
35 "<!-- Embedded fonts, SIL Open Font License 1.1 (https://openfontlicense.org):",
36 );
37 for (family, copyright) in FONT_NOTICES {
38 notice.push_str(&format!("\n {family}: {copyright}"));
39 }
40 notice.push_str("\n-->");
41 notice
42}
43
44const CUT_FACES: &str = include_str!(concat!(env!("OUT_DIR"), "/font-faces-cut.css"));
53const WHOLE_FACES: &str = include_str!(concat!(env!("OUT_DIR"), "/font-faces-whole.css"));
54
55include!(concat!(env!("OUT_DIR"), "/dropped.rs"));
56
57#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
59pub enum Fonts {
60 #[default]
65 Fitted,
66 Whole,
68}
69
70pub fn beyond_cut(text: &str) -> BTreeMap<char, usize> {
77 let mut tally = BTreeMap::new();
78 let bytes = text.as_bytes();
79 let mut index = 0;
80
81 while index < bytes.len() {
82 let Some(offset) = bytes[index..].iter().position(|byte| *byte >= 0x80) else {
88 break;
89 };
90 index += offset;
91 let character = text[index..]
92 .chars()
93 .next()
94 .expect("index lands on a character boundary");
95 if dropped(character) {
96 *tally.entry(character).or_insert(0) += 1;
97 }
98 index += character.len_utf8();
99 }
100 tally
101}
102
103fn dropped(character: char) -> bool {
104 let codepoint = character as u32;
105 DROPPED
106 .binary_search_by(|(low, high)| {
107 if codepoint < *low {
108 Ordering::Greater
109 } else if codepoint > *high {
110 Ordering::Less
111 } else {
112 Ordering::Equal
113 }
114 })
115 .is_ok()
116}
117
118pub struct Colophon {
120 pub generated: Timestamp,
121 pub tool: &'static str,
122 pub version: &'static str,
123 pub home: &'static str,
124}
125
126pub struct Labour {
130 pub took: Duration,
131 pub bytes: usize,
132}
133
134const TOOK_MARK: &str = "<!--folio:took-->";
139const SIZE_MARK: &str = "<!--folio:size-->";
140
141pub fn inscribe(markup: String, labour: &Labour) -> String {
148 markup
149 .replace(TOOK_MARK, &elapsed(labour.took))
150 .replace(SIZE_MARK, &size(labour.bytes))
151}
152
153pub fn elapsed(took: Duration) -> String {
156 let seconds = took.as_secs_f64();
157 if seconds < 1.0 {
158 format!("{:.0} ms", seconds * 1_000.0)
159 } else {
160 format!("{seconds:.1} s")
161 }
162}
163
164pub fn size(bytes: usize) -> String {
167 match bytes {
168 ..1_000 => format!("{bytes} B"),
169 1_000..1_000_000 => format!("{:.0} kB", bytes as f64 / 1_000.0),
170 _ => format!("{:.1} MB", bytes as f64 / 1_000_000.0),
171 }
172}
173
174pub struct Scribe<'a> {
177 options: Options<'a>,
178 plugins: Plugins<'a>,
179 timezone: TimeZone,
180 fonts: Fonts,
181}
182
183impl<'a> Scribe<'a> {
184 pub fn new(highlighter: &'a SyntectAdapter, timezone: TimeZone, fonts: Fonts) -> Self {
185 let mut options = Options::default();
186 options.extension.strikethrough = true;
187 options.extension.table = true;
188 options.extension.tasklist = true;
189 options.extension.autolink = true;
190 options.extension.footnotes = true;
191 options.render.github_pre_lang = true;
192
193 let mut plugins = Plugins::default();
194 plugins.render.codefence_syntax_highlighter = Some(highlighter);
195
196 Self {
197 options,
198 plugins,
199 timezone,
200 fonts,
201 }
202 }
203
204 pub(crate) fn markdown(&self, source: &str) -> Markup {
205 PreEscaped(markdown_to_html_with_plugins(
206 source,
207 &self.options,
208 &self.plugins,
209 ))
210 }
211
212 fn zoned(&self, timestamp: Timestamp) -> Zoned {
213 timestamp.to_zoned(self.timezone.clone())
214 }
215
216 pub fn folio(&self, folio: &Folio, colophon: &Colophon) -> (Markup, BTreeMap<char, usize>) {
219 let title = format!("folio {}", folio.session_id());
220 let panels = folio.panels();
221 let source = folio.source.display().to_string();
222 let (rendered_panels, reaches): (Vec<Markup>, Vec<BTreeMap<char, usize>>) = panels
232 .par_iter()
233 .map(|panel| {
234 let markup = self.panel(panel);
235 let reach = beyond_cut(&markup.0);
236 (markup, reach)
237 })
238 .unzip();
239
240 let mut reached = beyond_cut(&source);
244 for reach in reaches {
245 for (character, count) in reach {
246 *reached.entry(character).or_insert(0) += count;
247 }
248 }
249 let faces = match self.fonts {
250 Fonts::Whole => WHOLE_FACES,
251 Fonts::Fitted if !reached.is_empty() => WHOLE_FACES,
252 Fonts::Fitted => CUT_FACES,
253 };
254
255 let left_border = margin_strip(border_seed(folio.session_id(), "left"));
256 let right_border = margin_strip(border_seed(folio.session_id(), "right"));
257 let document = html! {
258 (DOCTYPE)
259 (PreEscaped(font_notice()))
260 html lang="en" {
261 head {
262 meta charset="utf-8";
263 meta name="viewport" content="width=device-width, initial-scale=1";
264 title { (title) }
265 style {
266 (PreEscaped(faces))
267 (PreEscaped(include_str!("illumination.css")))
268 }
269 script { (PreEscaped(include_str!("illumination.js"))) }
273 }
274 body {
275 div .margin.margin--left style=(format!("background-image:url({left_border})")) aria-hidden="true" {}
280 div .margin.margin--right style=(format!("background-image:url({right_border})")) aria-hidden="true" {}
281 div .plaque {
287 button .plaque__seal type="button" aria-label="folio details" title="folio details" { "❦" }
288 div .plaque__panel {
289 h1 .plaque__title { (title) }
290 dl .plaque__facts {
291 dt { "source" } dd { code { (source) } }
292 dt { "turns" } dd { (panels.len()) }
293 @if let Some(first) = panels.first() {
294 dt { "opened" } dd { (self.stamp(first.timestamp)) }
295 }
296 @if let (Some(input), Some(output)) = (folio.largest_input(), folio.output()) {
302 dt { "tokens" } dd title=(folio_flux(input, output)) { (tally(input, output)) }
303 }
304 }
305 p .plaque__colophon {
310 "Written by " a href=(colophon.home) { (colophon.tool) } " " (colophon.version)
311 " on " (self.stamp(colophon.generated)) ", taking "
312 (PreEscaped(TOOK_MARK)) " to set " (PreEscaped(SIZE_MARK)) "."
313 }
314 p .plaque__colophon {
315 "Set in Junicode, Fira Code, and UnifrakturCook, under the "
316 a href="https://openfontlicense.org" { "SIL Open Font License" } "."
317 }
318 }
319 }
320 div .search role="search" {
323 div .search__bar {
324 input .search__input type="search" placeholder="search folio" aria-label="search folio";
325 span .search__count aria-live="polite" {}
326 button .search__nav type="button" data-search-nav="prev" aria-label="previous match" { "‹" }
327 button .search__nav type="button" data-search-nav="next" aria-label="next match" { "›" }
328 }
329 div .search__scopes role="group" aria-label="search in" {
332 button .search__scope.search__scope--user type="button" data-scope="user" aria-pressed="true" { "user" }
333 button .search__scope.search__scope--assistant type="button" data-scope="assistant" aria-pressed="true" { "assistant" }
334 button .search__scope type="button" data-scope="tool" aria-pressed="true" { "tool" }
335 button .search__scope type="button" data-scope="thinking" aria-pressed="true" { "thinking" }
336 }
337 }
338 nav .dock aria-label="folio navigation" {
345 div .dock__nav {
346 button .dock__btn .dock__btn--user type="button" data-nav="prev" data-role="user" aria-label="previous user message" title="previous user message" { "▲" }
347 button .dock__btn type="button" data-nav="prev" aria-label="previous message" title="previous message" { "▲" }
348 button .dock__btn .dock__btn--assistant type="button" data-nav="prev" data-role="assistant" aria-label="previous assistant message" title="previous assistant message" { "▲" }
349 button .dock__btn .dock__btn--user type="button" data-nav="next" data-role="user" aria-label="next user message" title="next user message" { "▼" }
350 button .dock__btn type="button" data-nav="next" aria-label="next message" title="next message" { "▼" }
351 button .dock__btn .dock__btn--assistant type="button" data-nav="next" data-role="assistant" aria-label="next assistant message" title="next assistant message" { "▼" }
352 }
353 div .dock__leap {
357 button .dock__btn type="button" data-nav="top" aria-label="jump to top" title="jump to top" { "⤒" }
358 button .dock__btn type="button" data-nav="end" aria-label="jump to end" title="jump to end" { "⤓" }
359 button .dock__btn .dock__btn--tail type="button" data-tail="toggle" aria-pressed="false" aria-label="follow new messages" title="follow new messages, like tail -f" { "⇊" }
360 }
361 div .dock__fold {
362 button .dock__btn .dock__btn--fold type="button" data-fold="expand" aria-label="expand all" title="expand all" { span .dock__chevron { "⌃" } span .dock__chevron { "⌄" } }
363 button .dock__btn .dock__btn--fold type="button" data-fold="collapse" aria-label="collapse all" title="collapse all" { span .dock__chevron { "⌄" } span .dock__chevron { "⌃" } }
364 }
365 }
366 div .controls {
370 div .theme-toggle role="group" aria-label="colour theme" {
371 button type="button" data-theme-choice="light" { "light" }
372 button type="button" data-theme-choice="system" aria-pressed="true" { "system" }
373 button type="button" data-theme-choice="dark" { "dark" }
374 }
375 }
376 main .folio {
377 @for panel in &rendered_panels {
378 (panel)
379 }
380 }
381 }
382 }
383 };
384 (document, reached)
385 }
386
387 fn stamp(&self, timestamp: Timestamp) -> String {
388 self.zoned(timestamp)
389 .strftime("%Y-%m-%d %H:%M:%S %Z")
390 .to_string()
391 }
392
393 fn panel(&self, panel: &Panel) -> Markup {
394 let kind = panel.kind();
395 let opening = matches!(kind, PanelKind::User | PanelKind::Assistant)
400 .then(|| panel.blocks.iter().position(Block::is_visible_text))
401 .flatten();
402 html! {
403 article id={ "turn-" (panel.turn_number) }
404 class={ "turn turn--" (panel.role.as_str()) } data-kind=(kind.label())
405 data-turn=(panel.turn_number)
406 data-sidechain[panel.is_sidechain] data-meta[panel.is_meta] {
407 header .turn__meta {
408 span .turn__role { (kind.label()) }
409 @if let Some(model) = &panel.model {
410 span .turn__model {
411 (model)
412 @if let Some(effort) = &panel.effort {
413 " " span .turn__effort { "(" (effort) ")" }
414 }
415 }
416 }
417 @if let Some(usage) = &panel.usage {
418 span .turn__usage title=(turn_flux(usage)) {
419 (tally(usage.uncached_input(), usage.output_tokens))
420 }
421 }
422 time .turn__time datetime=(panel.timestamp.to_string()) { (self.stamp(panel.timestamp)) }
423 a .turn__index href={ "#turn-" (panel.turn_number) } { "#" (panel.turn_number) }
424 }
425 @for (index, block) in panel.blocks.iter().enumerate() {
426 (self.block(block, Some(index) == opening))
427 }
428 }
429 }
430 }
431
432 pub(crate) fn block(&self, block: &Block, versal: bool) -> Markup {
433 let known = match block {
434 Block::Known(known) => known,
435 Block::Unknown(value) => return unknown(value),
436 };
437 match known {
438 Known::Text { text } => {
439 html! { div .block.block--text data-versal[versal] { (self.markdown(text)) } }
440 }
441 Known::Thinking { thinking } if thinking.trim().is_empty() => html! {
445 p .block.block--redacted { "reasoning redacted" }
446 },
447 Known::Thinking { thinking } => html! {
448 section .block.block--thinking {
449 (self.markdown(thinking))
450 }
451 },
452 Known::ToolUse { name, input, .. } => self.tool_call(name, input),
453 Known::ToolResult {
454 content,
455 is_error,
456 answers,
457 ..
458 } => html! {
459 details .marginalia.marginalia--result data-error[*is_error] {
460 summary .marginalia__head { @if *is_error { "error" } @else { "result" } }
461 (tools::result(self, answers.as_ref(), content, *is_error))
462 }
463 },
464 Known::Image { source } => image(source),
465 }
466 }
467
468 fn tool_call(&self, name: &str, input: &Value) -> Markup {
473 let setting = tools::call(self, name, input);
474 let head = html! {
475 span .marginalia__tool { (name) }
476 @if let Some(gist) = &setting.gist {
477 @match &setting.href {
478 Some(href) => a .marginalia__gist href=(href) { (gist) },
479 None => span .marginalia__gist { (gist) },
480 }
481 }
482 @for note in &setting.notes {
483 span .marginalia__note { (note) }
484 }
485 };
486 match &setting.body {
487 Some(body) => html! {
488 details .marginalia.marginalia--use {
489 summary .marginalia__head { (head) }
490 (body)
491 }
492 },
493 None => html! {
494 div .marginalia.marginalia--use.marginalia--flat {
495 div .marginalia__head { (head) }
496 }
497 },
498 }
499 }
500
501 pub(crate) fn code_block(&self, lang: &str, code: &str) -> Markup {
505 let code = code.trim_end();
509 let fence = "`".repeat(longest_backtick_run(code).max(2) + 1);
510 self.markdown(&format!("{fence}{lang}\n{code}\n{fence}"))
511 }
512}
513
514const CELL_WIDTH: u32 = 90;
517const CELL_HEIGHT: u32 = 210;
518
519const STRIP_CELLS: usize = 48;
523
524const VINE_CELL: &str = include_str!("drolleries/vine.svg");
526
527const TRAIL: &str = include_str!("drolleries/trail.svg");
532
533const VINE_FADE: &str = "<defs><linearGradient id=\"vinefade\" gradientUnits=\"userSpaceOnUse\" \
538 x1=\"0\" y1=\"0\" x2=\"0\" y2=\"54\">\
539 <stop offset=\"0\" stop-color=\"#c1912f\"/>\
540 <stop offset=\"1\" stop-color=\"#c1912f\" stop-opacity=\"0\"/></linearGradient></defs>";
541
542const DROLLERIES: [(&str, i32, i32); 10] = [
551 (include_str!("drolleries/snail.svg"), 0, -18),
552 (include_str!("drolleries/budgie.svg"), -7, -11),
553 (include_str!("drolleries/cockatiel.svg"), -8, 2),
554 (include_str!("drolleries/cardinal.svg"), -6, -3),
555 (include_str!("drolleries/fish.svg"), -8, 0),
556 (include_str!("drolleries/butterfly.svg"), 0, -23),
557 (include_str!("drolleries/frog.svg"), 0, -32),
558 (include_str!("drolleries/cat.svg"), -5, -23),
559 (include_str!("drolleries/hare.svg"), 0, -14),
560 (include_str!("drolleries/stag.svg"), -2, -21),
561];
562
563struct Rng(u64);
566
567impl Rng {
568 fn next(&mut self) -> u64 {
569 self.0 = self.0.wrapping_add(0x9e37_79b9_7f4a_7c15);
570 let mut z = self.0;
571 z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
572 z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
573 z ^ (z >> 31)
574 }
575}
576
577fn border_seed(session_id: &str, salt: &str) -> u64 {
582 session_id
583 .bytes()
584 .chain(salt.bytes())
585 .fold(0xcbf2_9ce4_8422_2325, |hash, byte| {
586 (hash ^ u64::from(byte)).wrapping_mul(0x0000_0100_0000_01b3)
587 })
588}
589
590#[derive(Clone, Copy)]
593enum BorderCell {
594 Vine,
595 Drollery {
596 svg: &'static str,
597 dx: i32,
598 dy: i32,
599 flip: bool,
600 },
601}
602
603fn shuffled_bag(rng: &mut Rng) -> Vec<usize> {
607 let mut bag: Vec<usize> = (0..DROLLERIES.len()).collect();
608 for i in (1..bag.len()).rev() {
609 bag.swap(i, rng.next() as usize % (i + 1));
610 }
611 bag
612}
613
614fn border_cells(seed: u64) -> Vec<BorderCell> {
618 let mut rng = Rng(seed);
619 let mut bag = shuffled_bag(&mut rng);
620 let mut cells = Vec::with_capacity(STRIP_CELLS);
621 let mut previous_was_drollery = false;
622 for index in 0..STRIP_CELLS {
623 let seam = index == 0 || index == STRIP_CELLS - 1;
624 let drollery = !seam && !previous_was_drollery && rng.next().is_multiple_of(3);
625 if drollery {
626 if bag.is_empty() {
627 bag = shuffled_bag(&mut rng);
628 }
629 let (svg, dx, dy) = DROLLERIES[bag.pop().expect("bag refilled when empty")];
630 let flip = rng.next().is_multiple_of(2);
631 cells.push(BorderCell::Drollery { svg, dx, dy, flip });
632 } else {
633 cells.push(BorderCell::Vine);
634 }
635 previous_was_drollery = drollery;
636 }
637 cells
638}
639
640fn margin_strip(seed: u64) -> String {
644 let height = STRIP_CELLS as u32 * CELL_HEIGHT;
645 let mut inner = String::new();
646 for (index, cell) in border_cells(seed).iter().enumerate() {
647 let y = index as u32 * CELL_HEIGHT;
648 let content = match cell {
652 BorderCell::Vine => VINE_CELL.to_string(),
653 BorderCell::Drollery { svg, dx, dy, flip } => {
654 let place = if *flip {
657 format!("translate(90,0) scale(-1,1) translate({dx},{dy})")
658 } else {
659 format!("translate({dx},{dy})")
660 };
661 format!(
662 "{TRAIL}<g transform=\"{place}\">{svg}</g>\
663 <g transform=\"translate(0,{CELL_HEIGHT}) scale(1,-1)\">{TRAIL}</g>"
664 )
665 }
666 };
667 inner.push_str(&format!("<g transform=\"translate(0,{y})\">{content}</g>"));
668 }
669 let svg = format!(
670 "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{CELL_WIDTH}\" \
671 height=\"{height}\" viewBox=\"0 0 {CELL_WIDTH} {height}\">{VINE_FADE}{inner}</svg>"
672 );
673 format!("data:image/svg+xml;base64,{}", STANDARD.encode(svg))
674}
675
676fn tally(input: u64, output: u64) -> String {
678 format!("↑ {} ↓ {}", compact(input), compact(output))
679}
680
681fn turn_flux(usage: &Usage) -> String {
685 format!(
686 "{} input this turn · {} output this turn",
687 separated(usage.uncached_input()),
688 separated(usage.output_tokens),
689 )
690}
691
692fn folio_flux(largest_input: u64, output: u64) -> String {
695 format!(
696 "{} input at its largest · {} output in all",
697 separated(largest_input),
698 separated(output),
699 )
700}
701
702fn separated(tokens: u64) -> String {
704 let digits = tokens.to_string();
705 let mut grouped = String::new();
706 for (index, digit) in digits.chars().enumerate() {
707 if index > 0 && (digits.len() - index).is_multiple_of(3) {
708 grouped.push(',');
709 }
710 grouped.push(digit);
711 }
712 grouped
713}
714
715fn compact(tokens: u64) -> String {
718 let (scaled, suffix) = match tokens {
722 ..1_000 => return tokens.to_string(),
723 1_000..999_950 => (tokens as f64 / 1_000.0, "k"),
724 _ => (tokens as f64 / 1_000_000.0, "M"),
725 };
726 let rounded = format!("{scaled:.1}");
727 format!("{}{suffix}", rounded.strip_suffix(".0").unwrap_or(&rounded))
728}
729
730fn longest_backtick_run(source: &str) -> usize {
731 let mut longest = 0;
732 let mut run = 0;
733 for byte in source.bytes() {
734 if byte == b'`' {
735 run += 1;
736 longest = longest.max(run);
737 } else {
738 run = 0;
739 }
740 }
741 longest
742}
743
744pub(crate) fn json(value: &Value) -> Markup {
745 let pretty = serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string());
746 html! { pre { code { (pretty) } } }
747}
748
749fn unknown(value: &Value) -> Markup {
750 let label = value
751 .get("type")
752 .and_then(Value::as_str)
753 .unwrap_or("unrecognized");
754 html! {
755 details .block.block--unknown {
756 summary { (label) }
757 (json(value))
758 }
759 }
760}
761
762fn image(source: &ImageSource) -> Markup {
763 let src = format!("data:{};base64,{}", source.media_type, source.data);
764 html! { figure .block.block--image { img src=(src) alt="pasted image"; } }
765}
766
767#[cfg(test)]
768mod tests {
769 use super::*;
770
771 #[test]
772 fn token_counts_shorten_to_one_decimal_place_per_magnitude() {
773 assert_eq!(compact(0), "0");
774 assert_eq!(compact(847), "847");
775 assert_eq!(compact(999), "999");
776 assert_eq!(compact(1_000), "1k");
777 assert_eq!(compact(47_612), "47.6k");
778 assert_eq!(compact(999_400), "999.4k");
779 assert_eq!(compact(7_643_000), "7.6M");
780 }
781
782 #[test]
783 fn a_count_that_rounds_up_a_magnitude_takes_the_next_suffix() {
784 assert_eq!(compact(999_949), "999.9k");
785 assert_eq!(compact(999_950), "1M");
786 assert_eq!(compact(1_000_000), "1M");
787 }
788
789 #[test]
790 fn a_turn_counts_only_the_input_it_added() {
791 let usage = Usage {
794 input_tokens: 3,
795 output_tokens: 214,
796 cache_creation_input_tokens: 32_400,
797 cache_read_input_tokens: 15_200,
798 };
799
800 assert_eq!(
801 tally(usage.uncached_input(), usage.output_tokens),
802 "↑ 32.4k ↓ 214"
803 );
804 assert_eq!(
805 turn_flux(&usage),
806 "32,403 input this turn · 214 output this turn"
807 );
808 }
809
810 #[test]
811 fn a_folio_names_its_input_as_the_largest_rather_than_a_sum() {
812 assert_eq!(
813 folio_flux(60_867, 48_923),
814 "60,867 input at its largest · 48,923 output in all"
815 );
816 }
817
818 #[test]
819 fn exact_counts_group_in_thousands() {
820 assert_eq!(separated(7), "7");
821 assert_eq!(separated(942), "942");
822 assert_eq!(separated(1_206), "1,206");
823 assert_eq!(separated(47_603), "47,603");
824 assert_eq!(separated(7_643_812), "7,643,812");
825 }
826
827 #[test]
828 fn a_render_reads_in_milliseconds_until_it_takes_a_second() {
829 assert_eq!(elapsed(Duration::from_micros(412_400)), "412 ms");
830 assert_eq!(elapsed(Duration::from_millis(999)), "999 ms");
831 assert_eq!(elapsed(Duration::from_millis(1_000)), "1.0 s");
832 assert_eq!(elapsed(Duration::from_millis(3_260)), "3.3 s");
833 }
834
835 #[test]
836 fn a_folio_is_sized_in_the_units_files_are_quoted_in() {
837 assert_eq!(size(742), "742 B");
838 assert_eq!(size(6_140), "6 kB");
839 assert_eq!(size(812_600), "813 kB");
840 assert_eq!(size(2_947_312), "2.9 MB");
841 }
842
843 #[test]
844 fn a_folio_states_the_render_it_came_out_of() {
845 let markup = format!("<p>taking {TOOK_MARK} to set {SIZE_MARK}.</p>");
846 let labour = Labour {
847 took: Duration::from_millis(412),
848 bytes: 2_947_312,
849 };
850
851 assert_eq!(
852 inscribe(markup, &labour),
853 "<p>taking 412 ms to set 2.9 MB.</p>"
854 );
855 }
856
857 #[test]
858 fn border_strip_is_stable_per_seed() {
859 let seed = border_seed("3f9c-a17b-session", "left");
860 assert_eq!(margin_strip(seed), margin_strip(seed));
861 }
862
863 #[test]
864 fn the_two_borders_of_a_folio_differ() {
865 let session = "3f9c-a17b-session";
866 assert_ne!(
867 margin_strip(border_seed(session, "left")),
868 margin_strip(border_seed(session, "right"))
869 );
870 }
871
872 fn is_drollery(cell: &BorderCell) -> bool {
873 matches!(cell, BorderCell::Drollery { .. })
874 }
875
876 #[test]
877 fn borders_are_mostly_vine_with_non_adjacent_drolleries() {
878 let mut total_drolleries = 0;
881 for salt in [
882 "one", "two", "three", "four", "five", "six", "seven", "eight",
883 ] {
884 let cells = border_cells(border_seed("a-session", salt));
885 assert_eq!(cells.len(), STRIP_CELLS);
886 assert!(matches!(cells[0], BorderCell::Vine));
887 assert!(matches!(cells[STRIP_CELLS - 1], BorderCell::Vine));
888 let drolleries = cells.iter().filter(|cell| is_drollery(cell)).count();
889 assert!(
890 drolleries < STRIP_CELLS / 2,
891 "too many drolleries: {drolleries}"
892 );
893 let adjacent = cells
894 .windows(2)
895 .any(|pair| is_drollery(&pair[0]) && is_drollery(&pair[1]));
896 assert!(!adjacent, "two drolleries sat adjacent for salt {salt:?}");
897 total_drolleries += drolleries;
898 }
899 assert!(total_drolleries > 0, "generator produced no drolleries");
902 }
903
904 #[test]
905 fn drolleries_face_both_ways_across_a_border() {
906 let cells = border_cells(border_seed("flip-variety-session", "left"));
909 let flips: Vec<bool> = cells
910 .iter()
911 .filter_map(|cell| match cell {
912 BorderCell::Drollery { flip, .. } => Some(*flip),
913 BorderCell::Vine => None,
914 })
915 .collect();
916 assert!(flips.iter().any(|&flip| flip), "no creature was flipped");
917 assert!(
918 flips.iter().any(|&flip| !flip),
919 "no creature kept its facing"
920 );
921 }
922
923 #[test]
924 fn a_border_cycles_through_the_whole_bestiary() {
925 let cells = border_cells(border_seed("variety-session", "left"));
929 let used: std::collections::HashSet<&str> = cells
930 .iter()
931 .filter_map(|cell| match cell {
932 BorderCell::Drollery { svg, .. } => Some(*svg),
933 BorderCell::Vine => None,
934 })
935 .collect();
936 let count = cells.iter().filter(|cell| is_drollery(cell)).count();
937 let expected = count.min(DROLLERIES.len());
938 assert_eq!(
939 used.len(),
940 expected,
941 "creatures repeated before the bag drained"
942 );
943 }
944}