use std::fs;
use sofie_model::testing::{TemporaryDirectory, manifest};
use super::*;
const PRODUCT: &str = "use sofie::prelude::*;
#[derive(Aggregate)]
pub struct Product {
meta: Meta,
#[max(40)]
name: String,
price: Money,
status: ProductStatus,
}
#[derive(Clone, Copy, Enum)]
pub enum ProductStatus {
#[to(Listed)]
Draft,
Listed,
}
";
fn app() -> TemporaryDirectory {
let directory = TemporaryDirectory::new("sofie-scaffold-tests");
directory.write(
"Cargo.toml",
"[workspace]\nresolver = \"3\"\nmembers = [\"app\", \"modules/*\"]\n",
);
directory.write(
"app/Cargo.toml",
&manifest("shop", "catalog = { path = \"../modules/catalog\" }\n"),
);
directory.write("app/src/main.rs", "fn main() {}\n");
directory.write("modules/catalog/Cargo.toml", &manifest("catalog", ""));
directory.write(
"modules/catalog/src/lib.rs",
"mod product;\n\npub use product::{Product, ProductStatus};\n",
);
directory.write("modules/catalog/src/product.rs", PRODUCT);
directory
}
fn at(second: i64) -> Timestamp {
Timestamp::from_second(second).unwrap()
}
fn written_text(outcome: &ScaffoldedTests, index: usize) -> String {
fs::read_to_string(&outcome.created[index]).unwrap()
}
fn file_name(file: &Path) -> &str {
file.file_name().unwrap().to_str().unwrap()
}
#[test]
fn writes_a_test_for_each_fact_and_lists_the_facts() {
let app = app();
let outcome = scaffold_tests_at(app.root(), at(1_791_720_000)).unwrap();
let [file, root] = &outcome.created[..] else {
panic!("{outcome:?}");
};
let module_file = file_name(file).strip_suffix(".rs").unwrap();
assert_eq!(root, &app.path("app/tests/scaffolded/main.rs"));
let text = written_text(&outcome, 0);
let facts: Vec<(String, String)> = [
("value Money", "money_parses_its_samples_and_round_trips"),
(
"transitions ProductStatus DRAFT>LISTED",
"product_status_moves_only_along_its_transitions",
),
("schema Product", "product_table_matches_its_schema"),
(
"factory Product makes a valid record",
"product_is_made_by_its_factory",
),
(
"constraint Product.name max 40",
"product_name_keeps_max_40",
),
(
"store Product",
"product_loads_as_saved_and_a_stale_save_conflicts",
),
]
.map(|(fact, test)| (fact.to_owned(), test.to_owned()))
.into();
let written = constant_facts(&text);
for fact in &facts {
assert!(written.contains(fact), "{fact:?}\n{text}");
}
let comments = text
.lines()
.filter(|line| line.trim_start().starts_with("//"))
.count();
assert_eq!(
comments, 1,
"a scaffold of a small app must stay within the comment ratio sofie check holds app crates to: {text}"
);
assert!(
text.contains(
"sofie_testing::assert_boundary(\"name\", catalog::Product::__NAME, \"max 40\");"
),
"{text}"
);
assert!(
text.contains(
"sofie_testing::assert_moves::<catalog::ProductStatus>(&[(\"DRAFT\", &[\"LISTED\"])]);"
),
"{text}"
);
assert_eq!(
fs::read_to_string(root).unwrap(),
format!(
"//! @generated by `sofie scaffold tests`, which declares each file beside it; the files are yours.\n\n\
mod {module_file};\n"
)
);
}
#[test]
fn a_later_run_writes_only_what_changed_and_names_the_superseded_tests() {
let app = app();
let first = scaffold_tests_at(app.root(), at(1_791_720_000)).unwrap();
let unchanged = scaffold_tests_at(app.root(), at(1_791_720_060)).unwrap();
app.write(
"modules/catalog/src/product.rs",
&PRODUCT
.replace("#[to(Listed)]", "#[to(Listed, Retired)]")
.replace("Listed,\n}", "Listed,\n Retired,\n}"),
);
let changed = scaffold_tests_at(app.root(), at(1_791_720_120)).unwrap();
assert_eq!(unchanged, ScaffoldedTests::default());
assert_eq!(changed.created.len(), 1);
assert_eq!(changed.changed, [app.path("app/tests/scaffolded/main.rs")]);
let text = written_text(&changed, 0);
assert_eq!(
constant_facts(&text),
[(
"transitions ProductStatus DRAFT>LISTED,RETIRED".to_owned(),
"product_status_moves_only_along_its_transitions".to_owned()
)]
);
assert_eq!(
changed.superseded,
[format!(
"{}: product_status_moves_only_along_its_transitions (transitions ProductStatus DRAFT>LISTED)",
first.created[0].display()
)]
);
assert!(first.created[0].exists(), "a file once written stays");
}
#[test]
fn a_later_run_appends_to_the_root_file_and_keeps_its_owners_edits() {
let app = app();
scaffold_tests_at(app.root(), at(1_791_720_000)).unwrap();
let root = app.path("app/tests/scaffolded/main.rs");
let edited = format!("{}\nfn shared() {{}}\n", fs::read_to_string(&root).unwrap());
fs::write(&root, &edited).unwrap();
app.write(
"modules/catalog/src/product.rs",
&PRODUCT.replace("#[max(40)]", "#[max(50)]"),
);
let changed = scaffold_tests_at(app.root(), at(1_791_720_060)).unwrap();
let module = file_name(&changed.created[0]).strip_suffix(".rs").unwrap();
assert_eq!(
fs::read_to_string(&root).unwrap(),
format!("{edited}mod {module};\n")
);
}
#[test]
fn a_run_failing_part_way_undoes_what_it_wrote() {
let app = app();
fs::create_dir_all(app.path("app/tests/scaffolded/main.rs")).unwrap();
assert!(scaffold_tests_at(app.root(), at(1_791_720_000)).is_err());
let left: Vec<PathBuf> = files_with_extension(&app.path("app/tests/scaffolded"), "rs")
.unwrap()
.into_iter()
.filter(|file| !file.ends_with("main.rs"))
.collect();
assert_eq!(left, Vec::<PathBuf>::new());
}
#[test]
fn a_file_written_with_fact_lines_still_counts_its_facts_as_covered() {
let app = app();
let first = scaffold_tests_at(app.root(), at(1_791_720_000)).unwrap();
let text = written_text(&first, 0);
let mut lines = String::new();
for (fact, test) in constant_facts(&text) {
let _ = writeln!(lines, "{FACT_LINE}{fact}`: `{test}`");
}
fs::write(&first.created[0], format!("{lines}\nfn other() {{}}\n")).unwrap();
let again = scaffold_tests_at(app.root(), at(1_791_720_060)).unwrap();
assert_eq!(again, ScaffoldedTests::default());
}
#[test]
fn names_a_file_and_its_header_by_the_moment_like_the_files_before_it() {
let app = app();
let outcome = scaffold_tests_at(app.root(), at(951_782_400)).unwrap();
assert_eq!(file_name(&outcome.created[0]), "catalog_20000229000000.rs");
assert!(
written_text(&outcome, 0)
.starts_with("//! Scaffolded by `sofie scaffold tests` at 2000-02-29 00:00:00 UTC"),
"{}",
written_text(&outcome, 0)
);
}
fn app_with_a_product_page() -> TemporaryDirectory {
let directory = app();
directory.write(
"Cargo.toml",
"[workspace]\nresolver = \"3\"\nmembers = [\"app\", \"modules/*\", \"modules/*/contract\"]\n",
);
directory.write(
"modules/catalog/contract/Cargo.toml",
&manifest("catalog-contract", ""),
);
directory.write(
"modules/catalog/contract/src/lib.rs",
"mod r#gen;\n\npub use r#gen::routes;\n",
);
directory.write(
"modules/catalog/src/lib.rs",
"mod product;\nmod queries;\n\npub use product::{Product, ProductStatus};\n\n\
pub mod areas {\n pub mod web {\n pub mod routes;\n }\n}\n",
);
directory.write(
"modules/catalog/src/queries.rs",
"use sofie::prelude::*;\n\nuse crate::Product;\n\n\
#[derive(ReadModel)]\n#[read(from = Product)]\npub struct ProductDetail {\n id: Id<Product>,\n \
name: String,\n price: Money,\n}\n\n\
pub async fn show_product(product: ProductDetail) -> Result<ProductDetail> {\n Ok(product)\n}\n",
);
directory.write(
"modules/catalog/src/areas/web/routes.rs",
"use sofie::prelude::*;\n\nuse crate::queries;\n\npub fn routes(routes: &mut Routes<Web>) {\n \
routes.screen(\"/products/{product}\", \"product\", queries::show_product);\n}\n",
);
directory
}
#[test]
fn a_page_test_checks_the_read_model_it_answers_and_shows_the_record_its_path_loads() {
let app = app_with_a_product_page();
let outcome = scaffold_tests_at(app.root(), at(1_791_720_000)).unwrap();
let text = written_text(&outcome, 0);
let succeeds = text
.split("async fn show_product_succeeds")
.nth(1)
.and_then(|rest| rest.split("\n}\n").next())
.unwrap_or_else(|| panic!("{text}"));
assert!(
succeeds.contains("shown.assert_ok();\n let shown = shown.json();\n assert_eq!(shown[\"id\"], record_0.to_string());"),
"{succeeds}"
);
assert!(
succeeds.contains("for field in [\"name\", \"price\"] {"),
"{succeeds}"
);
assert!(
text.contains("async fn product_screen_renders_with_fixtures"),
"{text}"
);
assert!(!text.contains("_shown_"), "{text}");
assert!(
text.contains("use sofie::db::Aggregate as _;\n"),
"a fixture's `.id()` is the `Aggregate` trait's: {text}"
);
}
#[test]
fn a_page_test_finds_the_text_fields_its_screen_writes_bare_on_the_page() {
let app = app_with_a_product_page();
app.write(
"modules/catalog/src/areas/web/screens/product.html",
"<Section title=\"Product\">\n <a href=\"/products/{data.id}\" title={data.name}>Back</a>\n \
<dd>{data.name}</dd>\n <dd><Money value={data.price} /></dd>\n</Section>\n",
);
let outcome = scaffold_tests_at(app.root(), at(1_791_720_000)).unwrap();
let text = written_text(&outcome, 0);
let renders = text
.split("async fn product_screen_renders_with_fixtures")
.nth(1)
.and_then(|rest| rest.split("\n}\n").next())
.unwrap_or_else(|| panic!("{text}"));
assert!(renders.contains("let shown = app"), "{renders}");
assert!(
renders.contains("for field in [\"id\", \"name\"] {"),
"a bare `{{data.id}}` in an attribute's text counts, money does not: {renders}"
);
assert!(
renders.contains("sofie::engine::push_escaped(&mut written, value);"),
"{renders}"
);
}
#[test]
fn a_run_reports_the_gaps_of_its_own_facts_and_counts_the_older_ones() {
let app = app();
app.write(
"modules/catalog/src/product/factory.rs",
"use sofie::prelude::*;\n",
);
let gap = "catalog: Product: no fixture, as its factory is written by hand and no route of \
the module creates it";
let first = scaffold_tests_at(app.root(), at(1_791_720_000)).unwrap();
app.write(
"modules/catalog/src/lib.rs",
"mod category;\nmod product;\n\npub use category::Category;\npub use product::{Product, ProductStatus};\n",
);
app.write(
"modules/catalog/src/category.rs",
"use sofie::prelude::*;\n\n#[derive(Aggregate)]\npub struct Category {\n meta: Meta,\n name: String,\n}\n",
);
let second = scaffold_tests_at(app.root(), at(1_791_720_060)).unwrap();
assert_eq!(first.uncovered, [gap], "{first:?}");
assert!(first.older_uncovered.is_empty(), "{first:?}");
assert!(second.uncovered.is_empty(), "{second:?}");
assert_eq!(second.older_uncovered, [gap], "{second:?}");
let summary = second.summary();
assert!(!summary.contains(gap), "{summary}");
assert!(
summary.contains(
"not covered: 1 older gap this run leaves as they were; `scaffold_tests` with \
`all: true` lists them\n"
),
"{summary}"
);
assert!(
second
.to_string()
.contains(&format!("not covered: {gap}\n")),
"{second}"
);
}
#[test]
fn a_list_linking_each_row_to_a_page_is_tested_with_one_record() {
let app = app_with_a_product_page();
app.write(
"modules/catalog/src/areas/web/routes.rs",
"use sofie::prelude::*;\n\nuse crate::queries;\n\npub fn routes(routes: &mut Routes<Web>) {\n \
routes.screen(\"/products\", \"products\", queries::list_products);\n \
routes.screen(\"/products/{product}\", \"product\", queries::show_product);\n}\n",
);
app.write(
"modules/catalog/src/areas/web/screens/products.html",
"<Table id=\"products\" of={data} columns=\"name\" as=\"product\" \
link=\"/products/{product.id}\" />\n",
);
let outcome = scaffold_tests_at(app.root(), at(1_791_720_000)).unwrap();
let text = written_text(&outcome, 0);
let links = text
.split("async fn products_screen_links_a_row_to_show_product")
.nth(1)
.and_then(|rest| rest.split("\n}\n").next())
.unwrap_or_else(|| panic!("{text}"));
assert!(
links.contains("let record = app.fixture::<catalog::Product>().await.id().uuid();"),
"{links}"
);
assert!(
links.contains("let href = format!(\"href=\\\"/products/{record}\\\"\");\n")
&& links.contains("page.body.contains(&href),"),
"{links}"
);
assert!(
text.contains(
"\"screen catalog/web/products links a row to route catalog.web.show_product\""
),
"{text}"
);
}
#[test]
fn the_app_gains_the_dev_dependencies_its_scaffolded_tests_name() {
let app = app_with_a_product_page();
app.write(
"Cargo.toml",
"[workspace]\nresolver = \"3\"\nmembers = [\"app\", \"modules/*\", \"modules/*/contract\"]\n\n\
[workspace.dependencies]\nsofie = { path = \"sofie\" }\n",
);
let outcome = scaffold_tests_at(app.root(), at(1_791_720_000)).unwrap();
let again = scaffold_tests_at(app.root(), at(1_791_720_060)).unwrap();
let manifest = fs::read_to_string(app.path("app/Cargo.toml")).unwrap();
assert!(
manifest.contains(
"[dev-dependencies]\nsofie-testing.workspace = true\n\
catalog-contract = { path = \"../modules/catalog/contract\" }\n"
),
"{manifest}"
);
let workspace = fs::read_to_string(app.path("Cargo.toml")).unwrap();
assert!(
workspace.contains("sofie = { path = \"sofie\" }\nsofie-testing = { path = \""),
"{workspace}"
);
assert!(
outcome.changed.contains(&app.path("app/Cargo.toml")),
"{outcome:?}"
);
assert!(again.changed.is_empty(), "{again:?}");
}
#[test]
fn a_resource_s_routes_are_tested_and_its_create_route_makes_records() {
let app = app_with_a_product_page();
app.write(
"modules/catalog/src/areas/web/routes.rs",
"use sofie::prelude::*;\n\nuse crate::Product;\n\npub fn routes(routes: &mut Routes<Web>) {\n \
routes.resource::<Product>().only([Resource::Index, Create, Edit]);\n}\n",
);
app.write(
"modules/catalog/src/product/factory.rs",
"use sofie::prelude::*;\n",
);
let outcome = scaffold_tests_at(app.root(), at(1_791_720_000)).unwrap();
let text = written_text(&outcome, 0);
assert!(
!outcome
.uncovered
.iter()
.any(|reason| reason.contains("no route of the module creates it")),
"{outcome:?}"
);
assert!(
text.contains("async fn product_loads_as_saved_and_a_stale_save_conflicts"),
"{text}"
);
for test in [
"list_products_succeeds",
"create_product_succeeds",
"update_product_succeeds",
"product_index_screen_renders_with_fixtures",
"product_edit_screen_renders_with_fixtures",
] {
assert!(
text.contains(&format!("async fn {test}(")),
"{test}: {text}"
);
}
assert!(
text.contains("<catalog::Product as sofie::db::Create>::Input"),
"{text}"
);
assert!(
text.contains("<catalog::Product as sofie::db::Update>::Input"),
"{text}"
);
}
fn app_creating_behind_a_permission(signs_members_in: bool) -> TemporaryDirectory {
let app = app_with_a_product_page();
let identity = if signs_members_in {
app.write("sofie-identity/Cargo.toml", &manifest("sofie-identity", ""));
app.write("sofie-identity/src/lib.rs", "");
app.write(
"Cargo.toml",
"[workspace]\nresolver = \"3\"\nmembers = [\"app\", \"modules/*\", \"modules/*/contract\", \"sofie-identity\"]\n",
);
"sofie-identity = { path = \"../sofie-identity\" }\n"
} else {
""
};
app.write(
"app/Cargo.toml",
&manifest(
"shop",
&format!(
"catalog = {{ path = \"../modules/catalog\" }}\n\
catalog-contract = {{ path = \"../modules/catalog/contract\" }}\n{identity}"
),
),
);
app.write(
"modules/catalog/contract/src/lib.rs",
"pub mod area;\nmod r#gen;\n\npub use r#gen::routes;\n",
);
app.write(
"modules/catalog/contract/src/area.rs",
"pub struct Staff;\n\nimpl Area for Staff {\n const NAME: &'static str = \"staff\";\n \
const PREFIX: &'static str = \"/staff\";\n const SIGN_IN: &'static str = \"/staff/sign-in\";\n \
type Principal = sofie_identity::Permitted;\n}\n",
);
app.write(
"modules/catalog/src/lib.rs",
"mod actions;\nmod product;\n\npub use product::{Product, ProductStatus};\n\n\
pub mod areas {\n pub mod staff {\n pub mod routes;\n }\n}\n",
);
app.write(
"modules/catalog/src/actions.rs",
"use sofie::prelude::*;\n\nuse crate::Product;\n\n\
pub async fn add_product() -> Result<Product> {\n todo!()\n}\n",
);
app.write(
"modules/catalog/src/areas/staff/routes.rs",
"use sofie::prelude::*;\n\nuse crate::actions;\n\npub fn routes(routes: &mut Routes<catalog_contract::area::Staff>) {\n \
routes.post(\"/products\", actions::add_product);\n}\n",
);
app.write(
"modules/catalog/src/product/factory.rs",
"use sofie::prelude::*;\n",
);
app
}
#[test]
fn a_record_created_only_behind_a_permission_is_left_uncovered_rather_than_refused() {
let app = app_creating_behind_a_permission(false);
let outcome = scaffold_tests_at(app.root(), at(1_791_720_000)).unwrap();
let text = written_text(&outcome, 0);
assert!(
!text.contains("product_loads_as_saved_and_a_stale_save_conflicts"),
"{text}"
);
assert!(
outcome
.uncovered
.iter()
.any(|reason| reason.contains("needs") && reason.contains("a grant made by hand")),
"{outcome:?}"
);
}
#[test]
fn a_record_created_behind_a_permission_is_made_by_a_principal_granted_it() {
let app = app_creating_behind_a_permission(true);
let outcome = scaffold_tests_at(app.root(), at(1_791_720_000)).unwrap();
let text = written_text(&outcome, 0);
for test in [
"product_loads_as_saved_and_a_stale_save_conflicts",
"add_product_succeeds",
"add_product_refuses_a_principal_without_its_permission",
] {
assert!(
text.contains(&format!("async fn {test}(")),
"{test}: {text}"
);
}
assert!(
text.contains(
"sofie_identity::testing::granted::<catalog_contract::area::Staff>(\n &app,\n &[\"catalog.product.*.add\"],"
),
"{text}"
);
let manifest = fs::read_to_string(app.path("app/Cargo.toml")).unwrap();
assert!(
manifest.contains("features = [\"testing\", \"permissions\"]"),
"{manifest}"
);
assert!(
!outcome
.uncovered
.iter()
.any(|reason| reason.contains("grant made by hand")),
"{outcome:?}"
);
}
#[test]
fn a_dry_run_names_the_file_by_the_moment_s_form_and_the_real_run_by_its_moment() {
let app = app();
let report = scaffold_tests_at(app.root(), at(951_782_400))
.unwrap()
.to_string();
let dry = without_moment(&report, at(951_782_400));
assert!(report.contains("catalog_20000229000000.rs\n"), "{report}");
assert!(report.contains(test_places!()), "{report}");
assert!(dry.contains("catalog_<YYYYMMDDHHMMSS>.rs\n"), "{dry}");
assert!(!dry.contains("20000229000000"), "{dry}");
assert!(
dry.ends_with(
"<YYYYMMDDHHMMSS> is the moment the real run writes the file; it prints the name.\n"
),
"{dry}"
);
assert_eq!(
without_moment("nothing new\n", at(951_782_400)),
"nothing new\n"
);
}
#[test]
fn a_value_without_a_fixture_is_named_instead_of_scaffolded_into_tests_that_do_not_compile() {
let app = app();
app.write(
"modules/catalog/src/lib.rs",
"mod habit;\nmod product;\n\npub use habit::Habit;\npub use product::{Product, ProductStatus};\n",
);
let habit = "use sofie::prelude::*;\n\n#[derive(Value)]\npub struct HabitName(String);\n\n\
#[derive(Aggregate)]\npub struct Habit {\n meta: Meta,\n #[length(max = 40)]\n \
name: HabitName,\n}\n";
app.write("modules/catalog/src/habit.rs", habit);
app.write(
"sofie/Cargo.toml",
"[package]\nname = \"sofie\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n\
[features]\nfixtures = []\n",
);
app.write("sofie/src/lib.rs", "");
app.write(
"modules/catalog/Cargo.toml",
&manifest("catalog", "sofie = { path = \"../../sofie\" }\n"),
);
let missing = scaffold_tests_at(app.root(), at(1_791_720_000)).unwrap();
app.write(
"modules/catalog/src/habit.rs",
&format!(
"{habit}\n#[cfg(feature = \"fixtures\")]\nimpl sofie::values::Fixture for HabitName {{\n fn fixture(_seed: &mut \
sofie::values::Seed) -> Self {{\n Self(\"Read\".to_owned())\n }}\n}}\n"
),
);
let written = scaffold_tests_at(app.root(), at(1_791_720_060)).unwrap();
assert!(
missing.uncovered.iter().any(|gap| gap.starts_with(
"catalog: Habit: no fixture, as `HabitName` has no `impl sofie::values::Fixture`"
)),
"{missing:?}"
);
let text = written_text(&missing, 0);
assert!(!text.contains("habit_is_made_by_its_factory"), "{text}");
let module = fs::read_to_string(app.path("modules/catalog/Cargo.toml")).unwrap();
assert!(
module.contains("[features]\nfixtures = [\"sofie/fixtures\"]\n"),
"{module}"
);
let manifest = fs::read_to_string(app.path("app/Cargo.toml")).unwrap();
let (_, dev) = manifest.split_once("[dev-dependencies]").unwrap();
assert!(
dev.contains("catalog = { path = \"../modules/catalog\", features = [\"fixtures\"] }"),
"{manifest}"
);
assert!(!text.contains("habit_name_keeps"), "{text}");
assert!(
written
.uncovered
.iter()
.all(|gap| !gap.contains("HabitName")),
"{written:?}"
);
}
#[test]
fn an_exported_value_with_a_fixture_is_tested_to_round_trip_and_a_private_one_is_not() {
let app = app();
app.write(
"modules/catalog/src/lib.rs",
"mod habit;\nmod product;\n\npub use habit::{Habit, HabitName};\npub use product::{Product, ProductStatus};\n",
);
app.write(
"modules/catalog/src/habit.rs",
"use sofie::prelude::*;\n\n#[derive(Value)]\npub struct HabitName(String);\n\n\
#[derive(Value)]\npub struct Hidden(String);\n\n\
#[derive(Aggregate)]\npub struct Habit {\n meta: Meta,\n name: HabitName,\n}\n\n\
#[cfg(feature = \"fixtures\")]\nimpl sofie::values::Fixture for HabitName {\n fn fixture(_seed: &mut \
sofie::values::Seed) -> Self {\n Self(\"Read\".to_owned())\n }\n}\n\n\
#[cfg(feature = \"fixtures\")]\nimpl sofie::values::Fixture for Hidden {\n fn fixture(_seed: &mut \
sofie::values::Seed) -> Self {\n Self(\"x\".to_owned())\n }\n}\n",
);
let written = scaffold_tests_at(app.root(), at(1_791_720_000)).unwrap();
let text = written_text(&written, 0);
assert!(
text.contains("sofie_testing::assert_round_trips::<catalog::HabitName>(&app).await;"),
"{text}"
);
assert!(!text.contains("Hidden"), "{text}");
}