//! 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::(); 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"); }