@@ -74,17 +74,22 @@ pub struct Test {
7474 client_project : String ,
7575
7676 /// A language suitable for the `spacetime generate` CLI command.
77+ ///
78+ /// The string `"unrealcpp"` is recognized and treated differently here
79+ /// because code-generation takes different arguments for Unreal client projects.
80+ /// Tests written for the Unreal client SDK must specify exactly `"unrealcpp"`,
81+ /// not any of the aliases the SpacetimeDB CLI's `generate` command would accept.
7782 generate_language : String ,
7883
7984 /// A relative path within the `client_project` to place the module bindings.
8085 ///
81- /// Usually `src/module_bindings`
86+ /// Usually `src/module_bindings`.
87+ ///
88+ /// For Unreal tests (i.e. when `generate_language == "unrealcpp"`),
89+ /// this is instead the Unreal module name, and so should be a non-path string.
90+ /// In this case, it will usually be `"TestClient"`.
8291 generate_subdir : String ,
8392
84- /// Unreal-specific: the target Unreal module name for codegen (e.g., "TestClient").
85- /// Required when `generate_language == "unrealcpp"`. Ignored otherwise.
86- unreal_module_name : Option < String > ,
87-
8893 /// A shell command to compile the client project.
8994 ///
9095 /// Will run with access to the env var `SPACETIME_SDK_TEST_CLIENT_PROJECT`
@@ -103,6 +108,10 @@ pub const TEST_MODULE_PROJECT_ENV_VAR: &str = "SPACETIME_SDK_TEST_MODULE_PROJECT
103108pub const TEST_DB_NAME_ENV_VAR : & str = "SPACETIME_SDK_TEST_DB_NAME" ;
104109pub const TEST_CLIENT_PROJECT_ENV_VAR : & str = "SPACETIME_SDK_TEST_CLIENT_PROJECT" ;
105110
111+ fn language_is_unreal ( language : & str ) -> bool {
112+ language. eq_ignore_ascii_case ( "unrealcpp" )
113+ }
114+
106115impl Test {
107116 pub fn builder ( ) -> TestBuilder {
108117 TestBuilder :: default ( )
@@ -112,27 +121,12 @@ impl Test {
112121
113122 let wasm_file = compile_module ( & self . module_name ) ;
114123
115- // Determine if this is the Unreal SDK
116- let is_unreal = self . generate_language . eq_ignore_ascii_case ( "unrealcpp" ) ;
117-
118- // For Unreal: require unreal_module_name and treat client_project as --uproject-dir
119- let unreal_module_name_ref = if is_unreal {
120- Some (
121- self . unreal_module_name
122- . as_deref ( )
123- . expect ( "unrealcpp requires `unreal_module_name` to be set on Test" ) ,
124- )
125- } else {
126- None
127- } ;
128-
129124 generate_bindings (
130125 & paths,
131126 & self . generate_language ,
132127 & wasm_file,
133128 & self . client_project ,
134129 & self . generate_subdir ,
135- unreal_module_name_ref,
136130 ) ;
137131
138132 compile_client ( & self . compile_command , & self . client_project ) ;
@@ -162,33 +156,49 @@ fn random_module_name() -> String {
162156 . collect ( )
163157}
164158
159+ /// Memoize computing `body` based on `key` by storing the result in a [`HashMap`].
160+ ///
161+ /// The hash map is protected by a [`Mutex`].
162+ /// Only a single operator may be computing a value at a time.
163+ /// Computing the values must not be re-entrant / recursive.
164+ ///
165+ /// The key(s) of the hash map must already be in scope as variables.
166+ ///
167+ /// The keys may be either a single variable or a tuple of variables.
168+ ///
169+ /// E.g.:
170+ ///
171+ /// ```rust
172+ /// fn add_one(n: i32) -> i32 {
173+ /// memoized!(|n: i32| -> i32 { n + 1 })
174+ /// }
175+ ///
176+ /// fn add_together(n: i32, m: i32) -> i32 {
177+ /// memoized!(|(n, m): (i32, i32)| -> i32 { n + m })
178+ /// }
179+ /// ```
180+ ///
181+ /// The key types must be `'static`, `Clone`, `Eq` and `Hash`, as they'll be stored in a [`HashMap`].
182+ ///
183+ /// Used in this file primarily for running expensive and side-effecting subprocesses
184+ /// like compilation or code generation.
165185macro_rules! memoized {
166- // Unit arm: no clone, silence unused key.
167- ( |$key: ident: $key_ty: ty| -> ( ) $body: block) => { {
168- static MEMOIZED : Mutex <Option <HashMap <$key_ty, ( ) >>> = Mutex :: new( None ) ;
169- {
170- let mut map = MEMOIZED . lock( ) . unwrap( ) ; // guard lives for the whole block
171- map. get_or_insert_default( ) . entry( $key) . or_insert_with_key( |__k| {
172- let $key = __k;
173- let _ = & $key;
174- $body
175- } ) ;
176- }
186+ // Recursive case: rewrite a single `key` to be a 1-tuple `(key,)`.
187+ ( |$key: ident: $key_ty: ty| -> $value_ty: ty $body: block) => { {
188+ memoized!( |( $key, ) : ( $key_ty, ) | -> $value_ty $body)
177189 } } ;
178190
179- // Value arm: clone while guard is still alive .
180- ( |$key : ident: $key_ty: ty| -> $value_ty: ty $body: block) => { {
191+ // Base case: keys are a tuple .
192+ ( |( $ ( $key_tuple : ident) , * $ ( , ) ? ) : $key_ty: ty| -> $value_ty: ty $body: block) => { {
181193 static MEMOIZED : Mutex <Option <HashMap <$key_ty, $value_ty>>> = Mutex :: new( None ) ;
182- let cloned = {
183- let mut map = MEMOIZED . lock( ) . unwrap( ) ; // guard lives for the whole block
184- let v = map. get_or_insert_default( ) . entry( $key) . or_insert_with_key( |__k| {
185- let $key = __k;
186- let _ = & $key;
187- $body
188- } ) ;
189- v. clone( )
190- } ;
191- cloned
194+
195+ MEMOIZED
196+ . lock( )
197+ . unwrap( )
198+ . get_or_insert_default( )
199+ . entry( ( $( $key_tuple, ) * ) )
200+ . or_insert_with_key( |( $( $key_tuple, ) * ) | -> $value_ty { $body } )
201+ . clone( )
192202 } } ;
193203}
194204
@@ -226,6 +236,24 @@ fn publish_module(paths: &SpacetimePaths, wasm_file: &str) -> String {
226236 name
227237}
228238
239+ /// Run `spacetime generate` to generate client bindings into the `client_project`.
240+ ///
241+ /// `language` should be a string suitable for the `--lang` argument to `spacetime generate`.
242+ /// `"unrealcpp"` is special-cased to account for the CLI taking different arguments.
243+ /// Tests of the Unreal client SDK must use exactly that string, not any alias accepted by the CLI.
244+ ///
245+ /// `wasm_file` is a path to a compiled WASM blob, as returned by [`compile_module`].
246+ ///
247+ /// `client_project` and `generate_subdir` will be the values set in the [`Test`].
248+ /// These have different semantics depending on whether `language` is `"unrealcpp"`.
249+ ///
250+ /// For Unreal SDK tests, the `client_project` should be the directory which contains the `.uproject` file,
251+ /// and `generate_subdir` should be the Unreal module name.
252+ ///
253+ /// For non-unreal SDK tests, the `client_project` may be an arbitrary path,
254+ /// and the `generate_subdir` an arbitrary relative path within it.
255+ /// These will be combined as `"{client_project}/{generate_subdir}"` to produce the `--out-dir`.
256+ ///
229257/// Note: this function is memoized to ensure we only run `spacetime generate` once for each target directory.
230258///
231259/// Without this lock, if multiple `Test`s ran concurrently in the same process
@@ -259,41 +287,36 @@ fn generate_bindings(
259287 paths : & SpacetimePaths ,
260288 language : & str ,
261289 wasm_file : & str ,
262- client_project : & str , // For non-Unreal: base out dir. For Unreal: .uproject root dir instead.
263- generate_subdir : & str , // Ignored for Unreal.
264- module_name : Option < & str > , // Required for Unreal: the Unreal module to generate into.
290+ client_project : & str ,
291+ generate_subdir : & str ,
265292) {
266- let is_unreal = language. eq_ignore_ascii_case ( "unrealcpp" ) ;
267- let generate_dir = format ! ( "{client_project}/{generate_subdir}" ) ;
268-
269- // Memoize on the *actual* output target to avoid redundant runs.
270- let memo_key = if is_unreal {
271- format ! ( "unreal::{client_project}::{:?}" , module_name)
272- } else {
273- format ! ( "generic::{generate_dir}" )
274- } ;
275-
276- memoized ! ( |memo_key: String | -> ( ) {
277- if !is_unreal {
278- create_dir_all( & generate_dir) . unwrap( ) ;
279- }
293+ // We need these to be owned `String`s so we can memoize on them.
294+ let client_project = client_project. to_owned ( ) ;
295+ let generate_subdir = generate_subdir. to_owned ( ) ;
280296
281- let mut args: Vec <& str > = vec![ "generate" , "--lang" , language] ;
297+ // Codegen is side-effecting and doesn't meaningfully return a Rust value,
298+ // so our memoization has unit as the value.
299+ // This makes it run at most once for each key.
300+ memoized ! ( |( client_project, generate_subdir) : ( String , String ) | -> ( ) {
301+ let mut args: Vec <& str > = vec![ "generate" , "--lang" , language, "--bin-path" , wasm_file] ;
282302
283- // Prefer --project-path/--bin-path behavior you already have; here we show --bin-path.
284- // If you dynamically choose between them elsewhere, keep that logic and just insert the Unreal flags.
285- args. extend_from_slice( & [ "--bin-path" , wasm_file] ) ;
303+ let generate_dir: String ;
286304
287- if is_unreal {
288- let module = module_name. expect( "unrealcpp requires --module-name" ) ;
289- args. extend_from_slice( & [ "--module-name" , module] ) ;
305+ // `generate --lang unrealcpp` takes different arguments from non-Unreal languages
306+ // to account for some quirks of Unreal project structure.
307+ if language_is_unreal( language) {
308+ // For unreal, we use `client_project` as the uproject directory,
309+ // and `generate_subdir` as the module name.
290310 args. extend_from_slice( & [ "--uproject-dir" , client_project] ) ;
311+ args. extend_from_slice( & [ "--module-name" , generate_subdir] ) ;
291312 } else {
313+ generate_dir = format!( "{client_project}/{generate_subdir}" ) ;
314+ create_dir_all( & generate_dir) . unwrap( ) ;
292315 args. extend_from_slice( & [ "--out-dir" , & generate_dir] ) ;
293316 }
294317
295318 invoke_cli( paths, & args) ;
296- } ) ;
319+ } )
297320}
298321
299322fn split_command_string ( command : & str ) -> ( String , Vec < String > ) {
@@ -374,8 +397,7 @@ pub struct TestBuilder {
374397 module_name : Option < String > ,
375398 client_project : Option < String > ,
376399 generate_language : Option < String > ,
377- generate_subdir : Option < String > , // Ignored for unrealcpp
378- unreal_module_name : Option < String > , // Required for unrealcpp
400+ generate_subdir : Option < String > ,
379401 compile_command : Option < String > ,
380402 run_command : Option < String > ,
381403}
@@ -419,7 +441,7 @@ impl TestBuilder {
419441 // Unreal-only: names the Unreal module into which bindings are generated.
420442 pub fn with_unreal_module ( self , unreal_module_name : impl Into < String > ) -> Self {
421443 TestBuilder {
422- unreal_module_name : Some ( unreal_module_name. into ( ) ) ,
444+ generate_subdir : Some ( unreal_module_name. into ( ) ) ,
423445 ..self
424446 }
425447 }
@@ -442,26 +464,14 @@ impl TestBuilder {
442464 let generate_language = self
443465 . generate_language
444466 . expect ( "Supply a client language using TestBuilder::with_language" ) ;
445- let is_unreal = generate_language. eq_ignore_ascii_case ( "unrealcpp" ) ;
446467
447468 // For non-Unreal: require generate_subdir as before.
448469 // For Unreal: ignore generate_subdir entirely, but still populate with a harmless placeholder.
449- let generate_subdir = if is_unreal {
450- String :: from ( "_unreal_ignored_" )
451- } else {
452- self . generate_subdir
453- . expect ( "Supply a module_bindings subdirectory using TestBuilder::with_bindings_dir" )
454- } ;
455-
456- // For Unreal: require unreal_module_name.
457- let unreal_module_name = if is_unreal {
458- Some (
459- self . unreal_module_name
460- . expect ( "Supply Unreal module using TestBuilder::with_unreal_module for unrealcpp" ) ,
461- )
470+ let generate_subdir = self . generate_subdir . expect ( if language_is_unreal ( & generate_language) {
471+ "Supply an Unreal module name using TestBuilder::with_unreal_module"
462472 } else {
463- None
464- } ;
473+ "Supply a module_bindings subdirectory using TestBuilder::with_bindings_dir"
474+ } ) ;
465475
466476 Test {
467477 name : self . name . expect ( "Supply a test name using TestBuilder::with_name" ) ,
@@ -473,7 +483,6 @@ impl TestBuilder {
473483 . expect ( "Supply a client project directory using TestBuilder::with_client" ) ,
474484 generate_language,
475485 generate_subdir,
476- unreal_module_name,
477486 compile_command : self
478487 . compile_command
479488 . expect ( "Supply a compile command using TestBuilder::with_compile_command" ) ,
0 commit comments