feat(manifest): app manifest + decision crate (strict TOML subset, checked newtypes, capability catalog); docs/apps.md spec
This commit is contained in:
241
crates/manifest/src/tests.rs
Normal file
241
crates/manifest/src/tests.rs
Normal file
@@ -0,0 +1,241 @@
|
||||
use crate::*;
|
||||
|
||||
const SNAKE: &str = r##"
|
||||
# the game
|
||||
[app]
|
||||
id = "kos.snake"
|
||||
name = "Snake"
|
||||
version = "1.0.0"
|
||||
binary = "snake"
|
||||
author = "K-OS"
|
||||
|
||||
[capabilities]
|
||||
required = ["display"]
|
||||
optional = ["storage",] # high scores
|
||||
|
||||
[appearance]
|
||||
accent = "#e91e63"
|
||||
"##;
|
||||
|
||||
fn with(line: &str, replacing: &str) -> std::string::String {
|
||||
SNAKE.replace(replacing, line)
|
||||
}
|
||||
|
||||
extern crate std;
|
||||
|
||||
#[test]
|
||||
fn a_full_manifest_reads_into_checked_values() {
|
||||
let m = Manifest::parse(SNAKE).unwrap();
|
||||
assert_eq!(m.id.as_str(), "kos.snake");
|
||||
assert_eq!(m.name.as_str(), "Snake");
|
||||
assert_eq!(
|
||||
m.version,
|
||||
Version {
|
||||
major: 1,
|
||||
minor: 0,
|
||||
patch: 0
|
||||
}
|
||||
);
|
||||
assert_eq!(m.binary.as_str(), "snake");
|
||||
assert_eq!(m.command.as_str(), "snake", "the binary's name by default");
|
||||
assert_eq!(m.author.unwrap().as_str(), "K-OS");
|
||||
assert_eq!(m.required, Capabilities::NONE.with(Capability::Display));
|
||||
assert_eq!(m.optional, Capabilities::NONE.with(Capability::Storage));
|
||||
assert_eq!(m.accent, Some(Accent(0xe91e63)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_app_identity_is_required() {
|
||||
let m = Manifest::parse(
|
||||
"[app]\nid = \"kos.cat\"\nname = \"cat\"\nversion = \"0.1.0\"\nbinary = \"cat\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(m.requested().is_empty());
|
||||
for key in ["id", "name", "version", "binary"] {
|
||||
let text = std::format!(
|
||||
"[app]\n{}",
|
||||
[
|
||||
"id = \"a.b\"",
|
||||
"name = \"n\"",
|
||||
"version = \"1.0.0\"",
|
||||
"binary = \"b\""
|
||||
]
|
||||
.iter()
|
||||
.filter(|l| !l.starts_with(key))
|
||||
.copied()
|
||||
.collect::<std::vec::Vec<_>>()
|
||||
.join("\n")
|
||||
);
|
||||
assert_eq!(Manifest::parse(&text), Err(Error::Missing(key)), "{key}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anything_unknown_is_refused_not_ignored() {
|
||||
assert_eq!(
|
||||
Manifest::parse(&with(
|
||||
"homepage = \"x\"\nauthor = \"K-OS\"",
|
||||
"author = \"K-OS\""
|
||||
)),
|
||||
Err(Error::Unknown { line: 8 })
|
||||
);
|
||||
assert_eq!(
|
||||
Manifest::parse(&with(
|
||||
"required = [\"teleport\"]",
|
||||
"required = [\"display\"]"
|
||||
)),
|
||||
Err(Error::Unknown { line: 11 })
|
||||
);
|
||||
assert!(matches!(
|
||||
Manifest::parse(&with("[extras]\nx = \"y\"\n[appearance]", "[appearance]")),
|
||||
Err(Error::Unknown { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicates_are_refused() {
|
||||
assert!(matches!(
|
||||
Manifest::parse(&with(
|
||||
"name = \"Snake\"\nname = \"Other\"",
|
||||
"name = \"Snake\""
|
||||
)),
|
||||
Err(Error::Duplicate { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
Manifest::parse(&with(
|
||||
"required = [\"display\", \"display\"]",
|
||||
"required = [\"display\"]"
|
||||
)),
|
||||
Err(Error::Duplicate { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
Manifest::parse(&std::format!("{SNAKE}\n[app]\n")),
|
||||
Err(Error::Duplicate { .. })
|
||||
));
|
||||
assert_eq!(
|
||||
Manifest::parse(&with(
|
||||
"required = [\"storage\"]",
|
||||
"required = [\"display\"]"
|
||||
)),
|
||||
Err(Error::Conflict)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn values_are_checked() {
|
||||
for (bad, good) in [
|
||||
("id = \"Snake\"", "id = \"kos.snake\""),
|
||||
("id = \"snake\"", "id = \"kos.snake\""),
|
||||
("id = \"kos..snake\"", "id = \"kos.snake\""),
|
||||
("version = \"1.0\"", "version = \"1.0.0\""),
|
||||
("version = \"01.0.0\"", "version = \"1.0.0\""),
|
||||
("version = \"1.0.0.0\"", "version = \"1.0.0\""),
|
||||
("binary = \"../shell\"", "binary = \"snake\""),
|
||||
("binary = \"bin/snake\"", "binary = \"snake\""),
|
||||
("binary = \"..\"", "binary = \"snake\""),
|
||||
("icon = \".\"", "author = \"K-OS\""),
|
||||
("accent = \"e91e63\"", "accent = \"#e91e63\""),
|
||||
("name = [\"Snake\"]", "name = \"Snake\""),
|
||||
] {
|
||||
assert!(
|
||||
matches!(
|
||||
Manifest::parse(&with(bad, good)),
|
||||
Err(Error::Invalid { .. })
|
||||
),
|
||||
"{bad}"
|
||||
);
|
||||
}
|
||||
// A binary that is no valid command needs one spelled out.
|
||||
let odd = with("binary = \"Snake.bin\"", "binary = \"snake\"");
|
||||
assert_eq!(Manifest::parse(&odd), Err(Error::Missing("command")));
|
||||
let named = with(
|
||||
"binary = \"Snake.bin\"\ncommand = \"snake\"",
|
||||
"binary = \"snake\"",
|
||||
);
|
||||
assert_eq!(
|
||||
Manifest::parse(&named).unwrap().binary.as_str(),
|
||||
"Snake.bin"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_subset_parses() {
|
||||
for bad in [
|
||||
"id = 'kos.snake'", // single quotes
|
||||
"id = \"kos.\\snake\"", // escapes
|
||||
"id = \"kos.snake", // unclosed
|
||||
"id = kos.snake", // bare value
|
||||
"id \"kos.snake\"", // no =
|
||||
"[app", // unclosed table
|
||||
] {
|
||||
assert!(
|
||||
matches!(
|
||||
Manifest::parse(&with(bad, "id = \"kos.snake\"")),
|
||||
Err(Error::Syntax { .. })
|
||||
),
|
||||
"{bad}"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
matches!(
|
||||
Manifest::parse("id = \"a.b\"\n"),
|
||||
Err(Error::Syntax { line: 1 })
|
||||
),
|
||||
"a key outside any table"
|
||||
);
|
||||
// A comma inside a list item cannot be split wrongly: refused.
|
||||
assert!(matches!(
|
||||
Manifest::parse(&with(
|
||||
"optional = [\"a, b\"]",
|
||||
"optional = [\"storage\",] # high scores"
|
||||
)),
|
||||
Err(Error::Syntax { .. })
|
||||
));
|
||||
// `#` inside a string is not a comment.
|
||||
let hash = with("name = \"Snake #1\"", "name = \"Snake\"");
|
||||
assert_eq!(Manifest::parse(&hash).unwrap().name.as_str(), "Snake #1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decisions_round_trip() {
|
||||
let d = Decision {
|
||||
granted: Capabilities::NONE
|
||||
.with(Capability::Display)
|
||||
.with(Capability::Storage),
|
||||
};
|
||||
let mut buf = [0; MAX_DECISION_TEXT];
|
||||
let n = d.write(&mut buf);
|
||||
let text = core::str::from_utf8(&buf[..n]).unwrap();
|
||||
assert_eq!(text, "[decision]\ngranted = [\"display\", \"storage\"]\n");
|
||||
assert_eq!(Decision::parse(text), Ok(d));
|
||||
assert_eq!(
|
||||
Decision::parse("[decision]\ngranted = []\n"),
|
||||
Ok(Decision::default())
|
||||
);
|
||||
assert_eq!(
|
||||
Decision::parse("[decision]\n"),
|
||||
Err(Error::Missing("granted"))
|
||||
);
|
||||
assert!(matches!(
|
||||
Decision::parse("[decision]\ngranted = [\"x\"]\n"),
|
||||
Err(Error::Unknown { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_capability_name_round_trips_and_fits_the_set() {
|
||||
assert!(Capability::ALL.len() <= 32);
|
||||
for c in Capability::ALL {
|
||||
assert_eq!(Capability::from_name(c.name()), Some(*c));
|
||||
}
|
||||
let all: Capabilities = Capability::ALL.iter().copied().collect();
|
||||
let mut buf = [0; MAX_DECISION_TEXT];
|
||||
let n = Decision { granted: all }.write(&mut buf);
|
||||
assert!(n < MAX_DECISION_TEXT, "every name fits a decision file");
|
||||
assert_eq!(
|
||||
Decision::parse(core::str::from_utf8(&buf[..n]).unwrap())
|
||||
.unwrap()
|
||||
.granted,
|
||||
all
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user