Skip to main content

sofie_model/
testing.rs

1//! Test helpers panic: a broken fixture is a broken test.
2
3use std::fs;
4use std::path::{Path, PathBuf};
5use std::process;
6use std::sync::atomic::{AtomicU32, Ordering};
7
8use crate::naming::{contract_package, crate_name, stored_name};
9use crate::{
10    Aggregate, App, AreaClient, AreaContribution, AreaDefinition, Crate, Dependency, Effects,
11    EnumDeclaration, EnumVariant, FieldDeclaration, Function, Manifest, Method, Module, Route,
12    Tier, TypeMethod, TypeName,
13};
14
15static NEXT_DIRECTORY: AtomicU32 = AtomicU32::new(0);
16
17pub struct TemporaryDirectory {
18    root: PathBuf,
19}
20
21impl TemporaryDirectory {
22    /// Unique per process and call, so tests running in parallel never share one.
23    #[must_use]
24    pub fn new(prefix: &str) -> Self {
25        let root = std::env::temp_dir().join(format!(
26            "{prefix}-{}-{}",
27            process::id(),
28            NEXT_DIRECTORY.fetch_add(1, Ordering::Relaxed)
29        ));
30        fs::create_dir_all(&root).expect("the temporary directory is creatable");
31        Self { root }
32    }
33
34    #[must_use]
35    pub fn root(&self) -> &Path {
36        &self.root
37    }
38
39    #[must_use]
40    pub fn path(&self, relative: &str) -> PathBuf {
41        self.root.join(relative)
42    }
43
44    #[expect(
45        clippy::must_use_candidate,
46        reason = "most callers only want the file written"
47    )]
48    pub fn write(&self, relative: &str, text: &str) -> PathBuf {
49        let path = self.path(relative);
50        let directory = path.parent().expect("a written file lives in a directory");
51        fs::create_dir_all(directory).expect("the fixture directory is creatable");
52        fs::write(&path, text).expect("the fixture file is writable");
53        path
54    }
55}
56
57impl Drop for TemporaryDirectory {
58    fn drop(&mut self) {
59        if let Err(error) = fs::remove_dir_all(&self.root) {
60            eprintln!("cannot remove {}: {error}", self.root.display());
61        }
62    }
63}
64
65#[must_use]
66pub fn manifest(package: &str, dependencies: &str) -> String {
67    format!(
68        "[package]\nname = \"{package}\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n[dependencies]\n{dependencies}"
69    )
70}
71
72pub fn krate(package: &str, path: impl Into<PathBuf>) -> Crate {
73    Crate {
74        package: package.to_owned(),
75        crate_name: crate_name(package),
76        path: path.into(),
77        dependencies: Vec::new(),
78        sources: Vec::new(),
79        tests_and_examples: Vec::new(),
80    }
81}
82
83#[must_use]
84pub fn dependency(package: &str) -> Dependency {
85    Dependency {
86        package: package.to_owned(),
87        crate_name: crate_name(package),
88    }
89}
90
91pub fn module(package: &str, path: impl Into<PathBuf>) -> Module {
92    let krate = krate(package, path);
93    let manifest = Manifest {
94        name: krate.crate_name.clone(),
95        tier: Tier::Core,
96        context: None,
97        settings: Vec::new(),
98        file: krate.path.join("src/module.rs"),
99        line: 3,
100        declared: true,
101    };
102    Module {
103        krate,
104        manifest: Some(manifest),
105        config: None,
106        contract: None,
107        defined_areas: Vec::new(),
108        contract_declares_gen: false,
109        contract_exports: Vec::new(),
110        contract_traits: Vec::new(),
111        migrations: Vec::new(),
112        sqlite_migrations: Vec::new(),
113        areas: Vec::new(),
114        listeners: Vec::new(),
115        schedules: Vec::new(),
116        events: Vec::new(),
117        module_events: Vec::new(),
118        jobs: Vec::new(),
119        tasks: Vec::new(),
120        implementations: Vec::new(),
121        extensions: Vec::new(),
122        gateways: Vec::new(),
123        islands: Vec::new(),
124        components: Vec::new(),
125        languages: Vec::new(),
126        mails: Vec::new(),
127        plans: None,
128        private_items: Vec::new(),
129        code: crate::Code::default(),
130    }
131}
132
133#[must_use]
134pub fn area(module: &Module, name: &str, routes: Vec<Route>) -> AreaContribution {
135    AreaContribution {
136        area: name.to_owned(),
137        routes_file: module
138            .krate
139            .path
140            .join(format!("src/areas/{name}/routes.rs")),
141        declared: true,
142        routes,
143        screens: Vec::new(),
144        overrides: Vec::new(),
145        menu: None,
146    }
147}
148
149pub fn define_area(module: &mut Module, name: &str) {
150    let path = module.krate.path.join("contract");
151    let contract = krate(&contract_package(&module.krate.package), &path);
152    module.defined_areas.push(AreaDefinition {
153        name: name.to_owned(),
154        type_path: vec![
155            contract.crate_name.clone(),
156            "area".to_owned(),
157            "Member".to_owned(),
158        ],
159        prefix: Some("/account".to_owned()),
160        client: AreaClient::Html,
161        principal: vec!["Account".to_owned()],
162        implements_member_area: true,
163        opens_sign_up: false,
164        file: path.join("src/area.rs"),
165        line: 7,
166    });
167    module.contract = Some(contract);
168}
169
170#[must_use]
171pub fn route(method: Method, path: &str, handler: &str, line: usize) -> Route {
172    let handler = handler.split("::").map(str::to_owned).collect();
173    crate::routes::new_route(method, path.to_owned(), handler, line)
174}
175
176pub fn app(root: impl Into<PathBuf>, modules: Vec<Module>) -> App {
177    let root = root.into();
178    let mut shop = krate("shop", root.join("app"));
179    shop.dependencies = modules
180        .iter()
181        .flat_map(|module| std::iter::once(&module.krate).chain(&module.contract))
182        .map(|krate| dependency(&krate.package))
183        .collect();
184    App {
185        root,
186        app: shop,
187        modules,
188        identity: None,
189        package_events: Vec::new(),
190        packages: Vec::new(),
191        split: crate::Split::default(),
192        structure_ceilings: false,
193    }
194}
195
196#[must_use]
197pub fn type_name(written: &str) -> TypeName {
198    TypeName::of(&syn::parse_str(written).expect("a fixture type parses"))
199}
200
201#[must_use]
202pub fn field(name: &str, ty: &str, line: usize) -> FieldDeclaration {
203    FieldDeclaration {
204        name: name.to_owned(),
205        ty: type_name(ty),
206        constraints: Vec::new(),
207        bounds: Vec::new(),
208        line,
209    }
210}
211
212pub fn function(path: &str, file: impl Into<PathBuf>, line: usize) -> Function {
213    Function {
214        path: path.split("::").map(str::to_owned).collect(),
215        arguments: Vec::new(),
216        output: None,
217        renders: Vec::new(),
218        doc: None,
219        effects: Effects::default(),
220        file: file.into(),
221        line,
222    }
223}
224
225pub fn method(owner: &str, name: &str, file: impl Into<PathBuf>, line: usize) -> TypeMethod {
226    TypeMethod {
227        owner: owner.to_owned(),
228        name: name.to_owned(),
229        implements: None,
230        doc: None,
231        effects: Effects::default(),
232        file: file.into(),
233        line,
234    }
235}
236
237pub fn aggregate(name: &str, fields: Vec<FieldDeclaration>, file: impl Into<PathBuf>) -> Aggregate {
238    Aggregate {
239        name: name.to_owned(),
240        path: vec![name.to_owned()],
241        mirror: None,
242        fields,
243        children: Vec::new(),
244        behaviors: Vec::new(),
245        exposed: Vec::new(),
246        owner: None,
247        owner_on_struct: false,
248        doc: None,
249        file: file.into(),
250        line: 1,
251    }
252}
253
254#[must_use]
255pub fn enumeration(name: &str, variants: &[(&str, &[&str])]) -> EnumDeclaration {
256    EnumDeclaration {
257        name: name.to_owned(),
258        path: vec![name.to_owned()],
259        stored_names: variants
260            .iter()
261            .map(|(variant, _)| stored_name(variant))
262            .collect(),
263        variants: variants
264            .iter()
265            .map(|(variant, to)| EnumVariant {
266                name: (*variant).to_owned(),
267                to: to.iter().map(|target| (*target).to_owned()).collect(),
268                tone: None,
269            })
270            .collect(),
271    }
272}