87a148226e
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
195 lines
9.9 KiB
Rust
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 = " |