Files
Marcos Paulo 87a148226e feat(themes): wave 1 implementation
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:59:07 -03:00

195 lines
9.9 KiB
Rust

//! Loading: fallbacks, per-key rejection, round trips and hostile input.
use notescraft_themes::{CalloutKind, Diagnostic, DiagnosticKind, Error, MAX_THEME_BYTES, Rgba, Theme, Variant, builtin, builtin_source};
/// A minimal valid document with the given body appended.
fn theme_doc(body: &str) -> String {
format!("id = \"mine\"\nname = \"Mine\"\nvariant = \"dark\"\n{body}")
}
/// Key paths reported as invalid, in order.
fn invalid_keys(diagnostics: &[Diagnostic]) -> Vec<&str> {
diagnostics.iter().filter(|d| d.kind == DiagnosticKind::InvalidValue).map(|d| d.key.as_str()).collect()
}
#[test]
fn minimal_document_falls_back_to_the_builtin_of_its_variant() {
let (theme, diagnostics) = Theme::from_toml(&theme_doc("")).expect("valid");
assert!(diagnostics.is_empty(), "{diagnostics:?}");
let base = builtin("dark").expect("dark built-in");
assert_eq!(theme.colors, base.colors);
assert_eq!(theme.graph, base.graph);
assert_eq!(theme.radii, base.radii);
assert_eq!(theme.fonts, base.fonts);
// The document has no `[callouts]`, so all 13 derive from the built-in accent.
assert_eq!(theme.callouts.len(), CalloutKind::ALL.len());
for kind in CalloutKind::ALL {
assert_eq!(theme.callout(kind), base.colors.accent, "{kind:?}");
}
assert_eq!(theme.variant, Variant::Dark);
assert_eq!(theme.id, "mine");
assert_eq!(theme.name, "Mine");
let (light, diagnostics) = Theme::from_toml("id = \"l\"\nname = \"L\"\nvariant = \"light\"\n").expect("valid");
assert!(diagnostics.is_empty(), "{diagnostics:?}");
assert_eq!(light.colors, builtin("light").expect("light built-in").colors);
assert_eq!(light.variant, Variant::Light);
}
#[test]
fn overrides_apply_where_given() {
let (theme, diagnostics) = Theme::from_toml(&theme_doc(
"[colors]\nbackground = \"#010203\"\ntext = \"#ffffff80\"\n[radii]\nsmall = 2\nmedium = 12.5\n[fonts]\nmono = \"Fira Code\"\n",
))
.expect("valid");
assert!(diagnostics.is_empty(), "{diagnostics:?}");
let base = builtin("dark").expect("dark built-in");
assert_eq!(theme.colors.background.to_hex(), "#010203");
assert_eq!(theme.colors.text.to_hex(), "#ffffff80");
assert_eq!(theme.colors.surface, base.colors.surface);
assert_eq!(theme.radii.small, 2.0);
assert_eq!(theme.radii.medium, 12.5);
assert_eq!(theme.fonts.mono, "Fira Code");
assert_eq!(theme.fonts.ui, base.fonts.ui);
}
#[test]
fn a_bad_hex_rejects_only_its_own_key() {
let (theme, diagnostics) = Theme::from_toml(&theme_doc("[colors]\nbackground = \"not-a-colour\"\ntext = \"#ffffff\"\n")).expect("valid");
assert_eq!(invalid_keys(&diagnostics), ["colors.background"]);
let base = builtin("dark").expect("dark built-in");
assert_eq!(theme.colors.background, base.colors.background);
assert_eq!(theme.colors.text.to_hex(), "#ffffff");
}
#[test]
fn a_wrong_type_rejects_only_its_own_key() {
let (theme, diagnostics) = Theme::from_toml(&theme_doc(
"[colors]\ntext = 42\naccent = [1, 2]\nsurface = { nested = true }\n[graph]\nnode = 7\n[radii]\nsmall = \"4\"\n[fonts]\nui = 12\n",
))
.expect("valid");
assert_eq!(invalid_keys(&diagnostics), ["colors.surface", "colors.text", "colors.accent", "graph.node", "radii.small", "fonts.ui"]);
let base = builtin("dark").expect("dark built-in");
assert_eq!(theme.colors.text, base.colors.text);
assert_eq!(theme.colors.accent, base.colors.accent);
assert_eq!(theme.colors.surface, base.colors.surface);
assert_eq!(theme.graph.node, base.graph.node);
assert_eq!(theme.radii.small, base.radii.small);
assert_eq!(theme.fonts.ui, base.fonts.ui);
// Untouched keys keep their built-in value.
assert_eq!(theme.colors.background, base.colors.background);
}
#[test]
fn negative_and_huge_radii_are_rejected() {
let (theme, diagnostics) = Theme::from_toml(&theme_doc("[radii]\nsmall = -1.0\nmedium = 1e40\n")).expect("valid");
assert_eq!(invalid_keys(&diagnostics), ["radii.small", "radii.medium"]);
let base = builtin("dark").expect("dark built-in");
assert_eq!(theme.radii, base.radii);
}
#[test]
fn a_non_table_section_is_reported_once() {
let (theme, diagnostics) = Theme::from_toml(&theme_doc("colors = \"#ffffff\"\n")).expect("valid");
assert_eq!(invalid_keys(&diagnostics), ["colors"]);
assert_eq!(theme.colors, builtin("dark").expect("dark built-in").colors);
}
#[test]
fn unknown_keys_are_ignored_with_a_diagnostic() {
let (theme, diagnostics) = Theme::from_toml(&theme_doc(
"author = \"me\"\n[colors]\nforeground = \"#ffffff\"\nbg = \"#000000\"\n[graph]\nedge_color = \"#111111\"\n[radii]\nlarge = 16.0\n[fonts]\nhandwriting = \"Comic\"\n[callouts]\nsummary = \"#123456\"\n",
))
.expect("valid");
let unknown: Vec<&str> = diagnostics.iter().filter(|d| d.kind == DiagnosticKind::UnknownKey).map(|d| d.key.as_str()).collect();
assert_eq!(unknown, ["callouts.summary", "author", "colors.bg", "colors.foreground", "graph.edge_color", "radii.large", "fonts.handwriting"]);
assert_eq!(invalid_keys(&diagnostics), Vec::<&str>::new());
// `summary` is an alias of `abstract`, not one of the 13 kinds: unknown here.
assert_eq!(theme.callouts.len(), CalloutKind::ALL.len());
}
#[test]
fn callouts_left_out_derive_from_the_accent() {
let (theme, diagnostics) = Theme::from_toml(&theme_doc("[colors]\naccent = \"#ff0000\"\n[callouts]\nnote = \"#00ff00\"\n")).expect("valid");
assert!(diagnostics.is_empty(), "{diagnostics:?}");
assert_eq!(theme.callout(CalloutKind::Note).to_hex(), "#00ff00");
for kind in CalloutKind::ALL.into_iter().filter(|kind| *kind != CalloutKind::Note) {
assert_eq!(theme.callout(kind), Rgba::parse("#ff0000").expect("hex"), "{kind:?} should derive from accent");
}
// Even a built-in callout does not survive a document that overrides the accent:
// SPEC §8 derives every callout the document leaves out from `accent`.
assert_ne!(builtin("dark").expect("dark built-in").callout(CalloutKind::Abstract), Rgba::parse("#ff0000").expect("hex"));
}
#[test]
fn required_keys_are_enforced() {
assert_eq!(Theme::from_toml("name = \"n\"\nvariant = \"dark\"\n"), Err(Error::MissingKey { key: "id" }));
assert_eq!(Theme::from_toml("id = \"i\"\nvariant = \"dark\"\n"), Err(Error::MissingKey { key: "name" }));
assert_eq!(Theme::from_toml("id = \"i\"\nname = \"n\"\n"), Err(Error::MissingKey { key: "variant" }));
assert_eq!(Theme::from_toml(""), Err(Error::MissingKey { key: "variant" }));
assert_eq!(Theme::from_toml("id = 1\nname = \"n\"\nvariant = \"dark\"\n"), Err(Error::WrongType { key: "id", expected: "a string" }));
assert_eq!(Theme::from_toml("id = \"i\"\nname = \"n\"\nvariant = \"Dark\"\n"), Err(Error::InvalidVariant { value: "Dark".to_string() }));
assert_eq!(Theme::from_toml("id = \"i\"\nname = \"n\"\nvariant = \"solarized\"\n"), Err(Error::InvalidVariant { value: "solarized".to_string() }));
}
#[test]
fn round_trip_preserves_every_token() {
let source = builtin_source("catppuccin-mocha").expect("source");
let (theme, _) = Theme::from_toml(source).expect("valid");
let exported = theme.to_toml();
assert!(exported.starts_with("id = \"catppuccin-mocha\""), "scalars must come first: {exported}");
let (again, diagnostics) = Theme::from_toml(&exported).expect("round trip");
assert!(diagnostics.is_empty(), "{diagnostics:?}");
assert_eq!(again, theme);
}
#[test]
fn documents_over_the_size_cap_are_rejected() {
let filler = "x".repeat(MAX_THEME_BYTES);
let doc = format!("{}\n# {filler}\n", theme_doc(""));
assert!(doc.len() > MAX_THEME_BYTES);
assert_eq!(Theme::from_toml(&doc), Err(Error::TooLarge { bytes: doc.len(), limit: MAX_THEME_BYTES }));
// One byte under the cap still parses.
let head = "id = \"mine\"\nname = \"Mine\"\nvariant = \"dark\"\n";
let padding = MAX_THEME_BYTES - head.len() - 2;
let under = format!("{head}# {}", "x".repeat(padding));
assert_eq!(under.len(), MAX_THEME_BYTES);
assert!(Theme::from_toml(&under).is_ok());
}
#[test]
fn hostile_documents_never_panic() {
let head = "id = \"mine\"\nname = \"Mine\"\nvariant = \"dark\"\n";
// 400 nested tables, past the TOML parser's own recursion limit.
let deep = format!("{head}[a{}\nvalue = 1\n", ".b".repeat(400));
// Nesting the parser accepts, reached through unknown keys.
let nested = format!("{head}[a{}\nvalue = 1\n", ".b".repeat(20));
let garbage = " not toml at all {{{".to_string();
let dupes = "id = \"a\"\nid = \"b\"\nname = \"n\"\nvariant = \"dark\"\n".to_string();
let arrays = format!("{head}[[callouts]]\nnote = \"#ffffff\"\n[[callouts]]\nnote = \"#000000\"\n");
let binary = [0x7fu8, 0x00, 0xff, 0xfe].iter().map(|b| char::from(*b)).collect::<String>();
for doc in [deep.clone(), nested, garbage, dupes, arrays.clone(), binary, format!("{head}name = \"a\"")] {
// Either an error or a theme with diagnostics: never a panic.
match Theme::from_toml(&doc) {
Err(_) => {}
Ok((theme, _)) => assert_eq!(theme.callouts.len(), CalloutKind::ALL.len(), "callouts stay complete"),
}
}
// Deeper than the parser allows: an error, never a stack overflow.
assert!(matches!(Theme::from_toml(&deep), Err(Error::InvalidToml { .. })), "deep tables must be rejected");
// The array-of-tables form is a diagnostic, not a panic.
let (theme, diagnostics) = Theme::from_toml(&arrays).expect("arrays are a value, not a table");
assert_eq!(invalid_keys(&diagnostics), ["callouts"]);
assert_eq!(theme.callout(CalloutKind::Note), builtin("dark").expect("dark built-in").colors.accent);
}
#[test]
fn diagnostics_read_well() {
let diagnostic = Diagnostic::invalid_value("colors.text", "not a #rrggbb colour: `zz`");
assert_eq!(diagnostic.key, "colors.text");
assert_eq!(diagnostic.kind, DiagnosticKind::InvalidValue);
assert_eq!(diagnostic.to_string(), "colors.text: invalid value (not a #rrggbb colour: `zz`)");
assert_eq!(Diagnostic::unknown_key("colours").to_string(), "colours: unknown key");
}