use sofie_check::Screens;
use sofie_model::naming::{snake_case, stored_name};
use sofie_model::{Aggregate, App, Module};
use super::requests::{Making, Requests, writes_factory};
use super::route_facts;
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct Fact {
pub(crate) subject: String,
pub(crate) text: String,
pub(crate) test: String,
pub(crate) body: String,
pub(crate) on_app: bool,
}
#[derive(Debug)]
pub(crate) struct Uncovered {
pub(crate) subject: String,
pub(crate) reason: String,
}
#[derive(Debug, Default)]
pub(crate) struct ModuleFacts {
pub(crate) facts: Vec<Fact>,
pub(crate) uncovered: Vec<Uncovered>,
}
impl ModuleFacts {
pub(crate) fn uncover(&mut self, subject: &str, reason: String) {
self.uncovered.push(Uncovered {
subject: subject.to_owned(),
reason,
});
}
}
const VALUES: [(&str, &str); 19] = [
("Coordinate", "sofie::values::Coordinate"),
("Currency", "sofie::values::Currency"),
("Date", "sofie::values::Date"),
("Email", "sofie::values::Email"),
("Energy", "sofie::values::Energy"),
("Instant", "sofie::core::Instant"),
("Length", "sofie::values::Length"),
("LocalDateTime", "sofie::values::LocalDateTime"),
("Locale", "sofie::values::Locale"),
("Money", "sofie::values::Money"),
("Percentage", "sofie::values::Percentage"),
("PersonName", "sofie::values::PersonName"),
("PhoneNumber", "sofie::values::PhoneNumber"),
("Slug", "sofie::values::Slug"),
("TimeOfDay", "sofie::values::TimeOfDay"),
("TimeZone", "sofie::values::TimeZone"),
("Url", "sofie::values::Url"),
("Weekday", "sofie::values::Weekday"),
("Weight", "sofie::values::Weight"),
];
pub(super) fn needs_value_fixtures(module: &Module) -> bool {
let code = &module.code;
code.aggregates.iter().any(|aggregate| {
!writes_factory(&aggregate.file) && !code.values_in(&aggregate.fields).is_empty()
})
}
pub(crate) fn module_facts(app: &App, module: &Module, screens: &Screens) -> ModuleFacts {
let requests = Requests {
app,
module,
screens,
};
let mut facts = ModuleFacts::default();
values(&requests, &mut facts);
own_values(&requests, &mut facts);
status_enums(&requests, &mut facts);
for aggregate in &module.code.aggregates {
aggregate_facts(&requests, aggregate, &mut facts);
}
for site in app.route_sites() {
if std::ptr::eq(site.module, module) {
route_facts::route(&requests, &site, &mut facts);
}
}
facts
}
fn values(requests: &Requests, facts: &mut ModuleFacts) {
let code = &requests.module.code;
let mut seen: Vec<&str> = Vec::new();
let types = code
.aggregates
.iter()
.flat_map(|aggregate| &aggregate.fields)
.chain(code.inputs.iter().flat_map(|input| &input.fields))
.map(
|field| match (field.ty.name.as_str(), field.ty.arguments.as_slice()) {
("Option", [inner]) => inner.name.as_str(),
(name, _) => name,
},
);
for name in types {
let Some((_, path)) = VALUES.iter().find(|(value, _)| *value == name) else {
continue;
};
if seen.contains(&name) {
continue;
}
seen.push(name);
facts.facts.push(Fact {
subject: name.to_owned(),
text: format!("value {name}"),
test: format!("{}_parses_its_samples_and_round_trips", snake_case(name)),
body: format!("sofie_testing::assert_value::<{path}>(&app).await;"),
on_app: true,
});
}
}
fn own_values(requests: &Requests, facts: &mut ModuleFacts) {
let code = &requests.module.code;
for path in &code.value_paths {
let (Some(name), [_]) = (path.last(), path.as_slice()) else {
continue;
};
let fixtured = code
.methods
.iter()
.any(|method| method.owner == *name && method.implements.as_deref() == Some("Fixture"));
if !fixtured {
continue;
}
facts.facts.push(Fact {
subject: name.clone(),
text: format!("value {name}"),
test: format!("{}_round_trips", snake_case(name)),
body: format!(
"sofie_testing::assert_round_trips::<{}::{name}>(&app).await;",
requests.crate_name()
),
on_app: true,
});
}
}
fn status_enums(requests: &Requests, facts: &mut ModuleFacts) {
let statuses = requests
.module
.code
.enums
.iter()
.filter(|declared| declared.has_transitions());
for status in statuses {
let name = status.path.last().map_or("", String::as_str);
let transitions: Vec<(String, Vec<String>)> = status
.variants
.iter()
.filter(|variant| !variant.to.is_empty())
.map(|variant| {
let to = variant
.to
.iter()
.map(|target| stored_name(target))
.collect();
(stored_name(&variant.name), to)
})
.collect();
let moves: Vec<String> = transitions
.iter()
.map(|(from, to)| format!("{from}>{}", to.join(",")))
.collect();
let listed: Vec<String> = transitions
.iter()
.map(|(from, to)| {
let to: Vec<String> = to.iter().map(|to| format!("{to:?}")).collect();
format!("({from:?}, &[{}])", to.join(", "))
})
.collect();
facts.facts.push(Fact {
subject: name.to_owned(),
text: format!("transitions {name} {}", moves.join(" ")),
test: format!("{}_moves_only_along_its_transitions", snake_case(name)),
body: format!(
"sofie_testing::assert_moves::<{}::{}>(&[{}]);",
requests.crate_name(),
status.path.join("::"),
listed.join(", ")
),
on_app: false,
});
}
}
fn aggregate_facts(requests: &Requests, aggregate: &Aggregate, facts: &mut ModuleFacts) {
let name = &aggregate.name;
let snake = snake_case(name);
let path = requests.path(aggregate);
let derived = !writes_factory(&aggregate.file);
facts.facts.push(Fact {
subject: name.clone(),
text: format!("schema {name}"),
test: format!("{snake}_table_matches_its_schema"),
body: format!("sofie_testing::assert_schema::<{path}>(&app).await;"),
on_app: true,
});
let unmade = requests
.module
.code
.values_without_fixture(&aggregate.fields);
if derived && !unmade.is_empty() {
let types: Vec<String> = unmade.iter().map(|ty| format!("`{ty}`")).collect();
facts.uncover(
name,
format!(
"{name}: no fixture, as {} has no `impl sofie::values::Fixture` (`fn \
fixture(seed: &mut sofie::values::Seed) -> Self`, drawing a value its `try_new` \
takes); write one under `#[cfg(feature = \"fixtures\")]`, the module feature \
the app's tests turn on, then scaffold the tests again",
types.join(", ")
),
);
constraints(requests, aggregate, false, &unmade, facts);
return;
}
if derived {
facts.facts.push(Fact {
subject: name.clone(),
text: format!("factory {name} makes a valid record"),
test: format!("{snake}_is_made_by_its_factory"),
body: format!("let _: {path} = app.fixture().await;"),
on_app: true,
});
}
constraints(requests, aggregate, derived, &unmade, facts);
let made = requests.two_records(aggregate, derived);
let Some(made) = made else {
let reason = match requests.creation_permission(name) {
Some((route, permission)) => format!(
"{name}: no fixture, as its factory is written by hand and {route}, which creates \
it, needs {permission}, a grant made by hand"
),
None => format!(
"{name}: no fixture, as its factory is written by hand and no route of the module creates it"
),
};
facts.uncover(name, reason);
return;
};
facts.facts.push(Fact {
subject: name.clone(),
text: format!("store {name}"),
test: format!("{snake}_loads_as_saved_and_a_stale_save_conflicts"),
body: format!(
"{}sofie_testing::assert_store::<{path}>(&app, sofie::db::Id::from_uuid(first), sofie::db::Id::from_uuid(second)).await;",
made.statements
),
on_app: true,
});
if !aggregate.exposed.is_empty() {
lookup(requests, aggregate, &made, facts);
}
}
fn constraints(
requests: &Requests,
aggregate: &Aggregate,
fixture: bool,
unmade: &[&str],
facts: &mut ModuleFacts,
) {
let name = &aggregate.name;
let snake = snake_case(name);
let path = requests.path(aggregate);
for declared in &aggregate.fields {
let field = &declared.name;
let types = std::iter::once(&declared.ty).chain(&declared.ty.arguments);
if types
.into_iter()
.any(|ty| unmade.contains(&ty.name.as_str()))
{
continue;
}
for constraint in &declared.bounds {
let text = format!("constraint {name}.{field} {constraint}");
if constraint == "unique" && !fixture {
facts.uncover(
name,
format!(
"{name}.{field} unique: no fixture to make a second record with, as its \
factory is written by hand or a field's type has no `Fixture`"
),
);
continue;
}
let fact = if constraint == "unique" {
Fact {
subject: name.clone(),
text,
test: format!("{snake}_{field}_is_unique"),
body: format!("sofie_testing::assert_unique::<{path}>(&app, {field:?}).await;"),
on_app: true,
}
} else {
Fact {
subject: name.clone(),
text,
test: format!(
"{snake}_{field}_keeps_{}",
constraint.replace([' ', '-'], "_")
),
body: format!(
"sofie_testing::assert_boundary({field:?}, {path}::__{}, {constraint:?});",
field.to_uppercase()
),
on_app: false,
}
};
facts.facts.push(fact);
}
}
}
fn lookup(requests: &Requests, aggregate: &Aggregate, made: &Making, facts: &mut ModuleFacts) {
let Some(contract) = &requests.module.contract else {
return;
};
let name = &aggregate.name;
let mut exposed = vec!["id".to_owned()];
exposed.extend(aggregate.exposed.iter().cloned());
exposed.sort();
let listed: Vec<String> = exposed.iter().map(|field| format!("{field:?}")).collect();
facts.facts.push(Fact {
subject: name.clone(),
text: format!("lookup {name} {}", exposed.join(",")),
test: format!("{}_lookup_answers_its_exposed_fields", snake_case(name)),
body: format!(
"{}sofie_testing::assert_lookup::<{}, {}::{name}>(&app, &[sofie::db::Id::from_uuid(first), sofie::db::Id::from_uuid(second)], &[{}]).await;",
made.statements,
requests.path(aggregate),
contract.crate_name,
listed.join(", ")
),
on_app: true,
});
}