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        unrouted_areas: Vec::new(),
130        code: crate::Code::default(),
131    }
132}
133
134#[must_use]
135pub fn area(module: &Module, name: &str, routes: Vec<Route>) -> AreaContribution {
136    AreaContribution {
137        area: name.to_owned(),
138        routes_file: module
139            .krate
140            .path
141            .join(format!("src/areas/{name}/routes.rs")),
142        declared: true,
143        routes,
144        screens: Vec::new(),
145        overrides: Vec::new(),
146        menu: None,
147    }
148}
149
150pub fn define_area(module: &mut Module, name: &str) {
151    let path = module.krate.path.join("contract");
152    let contract = krate(&contract_package(&module.krate.package), &path);
153    module.defined_areas.push(AreaDefinition {
154        name: name.to_owned(),
155        type_path: vec![
156            contract.crate_name.clone(),
157            "area".to_owned(),
158            "Member".to_owned(),
159        ],
160        prefix: Some("/account".to_owned()),
161        client: AreaClient::Html,
162        principal: vec!["Account".to_owned()],
163        implements_member_area: true,
164        opens_sign_up: false,
165        file: path.join("src/area.rs"),
166        line: 7,
167    });
168    module.contract = Some(contract);
169}
170
171#[must_use]
172pub fn route(method: Method, path: &str, handler: &str, line: usize) -> Route {
173    let handler = handler.split("::").map(str::to_owned).collect();
174    crate::routes::new_route(method, path.to_owned(), handler, line)
175}
176
177pub fn app(root: impl Into<PathBuf>, modules: Vec<Module>) -> App {
178    let root = root.into();
179    let mut shop = krate("shop", root.join("app"));
180    shop.dependencies = modules
181        .iter()
182        .flat_map(|module| std::iter::once(&module.krate).chain(&module.contract))
183        .map(|krate| dependency(&krate.package))
184        .collect();
185    App {
186        root,
187        app: shop,
188        modules,
189        identity: None,
190        package_events: Vec::new(),
191        packages: Vec::new(),
192        split: crate::Split::default(),
193        structure_ceilings: false,
194    }
195}
196
197#[must_use]
198pub fn type_name(written: &str) -> TypeName {
199    TypeName::of(&syn::parse_str(written).expect("a fixture type parses"))
200}
201
202#[must_use]
203pub fn field(name: &str, ty: &str, line: usize) -> FieldDeclaration {
204    FieldDeclaration {
205        name: name.to_owned(),
206        ty: type_name(ty),
207        constraints: Vec::new(),
208        bounds: Vec::new(),
209        line,
210    }
211}
212
213pub fn function(path: &str, file: impl Into<PathBuf>, line: usize) -> Function {
214    Function {
215        path: path.split("::").map(str::to_owned).collect(),
216        arguments: Vec::new(),
217        output: None,
218        renders: Vec::new(),
219        doc: None,
220        effects: Effects::default(),
221        file: file.into(),
222        line,
223    }
224}
225
226pub fn method(owner: &str, name: &str, file: impl Into<PathBuf>, line: usize) -> TypeMethod {
227    TypeMethod {
228        owner: owner.to_owned(),
229        name: name.to_owned(),
230        implements: None,
231        doc: None,
232        effects: Effects::default(),
233        file: file.into(),
234        line,
235    }
236}
237
238pub fn aggregate(name: &str, fields: Vec<FieldDeclaration>, file: impl Into<PathBuf>) -> Aggregate {
239    Aggregate {
240        name: name.to_owned(),
241        path: vec![name.to_owned()],
242        mirror: None,
243        fields,
244        children: Vec::new(),
245        behaviors: Vec::new(),
246        exposed: Vec::new(),
247        owner: None,
248        owner_on_struct: false,
249        doc: None,
250        file: file.into(),
251        line: 1,
252    }
253}
254
255#[must_use]
256pub fn enumeration(name: &str, variants: &[(&str, &[&str])]) -> EnumDeclaration {
257    EnumDeclaration {
258        name: name.to_owned(),
259        path: vec![name.to_owned()],
260        stored_names: variants
261            .iter()
262            .map(|(variant, _)| stored_name(variant))
263            .collect(),
264        variants: variants
265            .iter()
266            .map(|(variant, to)| EnumVariant {
267                name: (*variant).to_owned(),
268                to: to.iter().map(|target| (*target).to_owned()).collect(),
269                tone: None,
270            })
271            .collect(),
272    }
273}