Skip to content

Commit 1009984

Browse files
committed
fix: make three-hop put test deterministic
1 parent d3ce033 commit 1009984

File tree

4 files changed

+234
-9
lines changed

4 files changed

+234
-9
lines changed

crates/core/tests/operations.rs

Lines changed: 51 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1909,9 +1909,31 @@ async fn test_delegate_request(ctx: &mut TestContext) -> TestResult {
19091909
/// Expected flow:
19101910
/// 1. peer-a sends PUT → routes through gateway → stored on peer-c
19111911
/// 2. peer-c sends PUT response → routes back through gateway → received by peer-a
1912+
const THREE_HOP_TEST_CONTRACT: &str = "test-contract-integration";
1913+
1914+
static THREE_HOP_CONTRACT: LazyLock<(ContractContainer, freenet::dev_tool::Location)> =
1915+
LazyLock::new(|| {
1916+
let contract =
1917+
test_utils::load_contract(THREE_HOP_TEST_CONTRACT, vec![].into()).expect("contract");
1918+
let location = freenet::dev_tool::Location::from(&contract.key());
1919+
(contract, location)
1920+
});
1921+
1922+
fn three_hop_node_locations() -> Vec<f64> {
1923+
let (_, contract_location) = &*THREE_HOP_CONTRACT;
1924+
let gateway = freenet::dev_tool::Location::new_rounded(contract_location.as_f64() + 0.2);
1925+
let peer_a = freenet::dev_tool::Location::new_rounded(contract_location.as_f64() + 0.5);
1926+
vec![
1927+
gateway.as_f64(),
1928+
peer_a.as_f64(),
1929+
contract_location.as_f64(),
1930+
]
1931+
}
1932+
19121933
#[freenet_test(
19131934
nodes = ["gateway", "peer-a", "peer-c"],
19141935
gateways = ["gateway"],
1936+
node_locations_fn = three_hop_node_locations,
19151937
auto_connect_peers = true,
19161938
timeout_secs = 240,
19171939
startup_wait_secs = 15,
@@ -1922,10 +1944,12 @@ async fn test_delegate_request(ctx: &mut TestContext) -> TestResult {
19221944
async fn test_put_contract_three_hop_returns_response(ctx: &mut TestContext) -> TestResult {
19231945
use freenet::dev_tool::Location;
19241946

1925-
const TEST_CONTRACT: &str = "test-contract-integration";
1926-
let contract = test_utils::load_contract(TEST_CONTRACT, vec![].into())?;
1947+
let (contract, contract_location) = {
1948+
let (contract, location) = &*THREE_HOP_CONTRACT;
1949+
(contract.clone(), *location)
1950+
};
19271951
let contract_key = contract.key();
1928-
let contract_location = Location::from(&contract_key);
1952+
let node_locations = three_hop_node_locations();
19291953

19301954
let initial_state = test_utils::create_empty_todo_list();
19311955
let wrapped_state = WrappedState::from(initial_state);
@@ -1935,16 +1959,36 @@ async fn test_put_contract_three_hop_returns_response(ctx: &mut TestContext) ->
19351959
let peer_a = ctx.node("peer-a")?;
19361960
let peer_c = ctx.node("peer-c")?;
19371961

1938-
// Note: We cannot modify node locations after they're created with the macro,
1939-
// so this test will use random locations. The original test had specific location
1940-
// requirements to ensure proper three-hop routing. For now, we'll proceed with
1941-
// the test and it should still validate PUT response routing.
1962+
assert_eq!(gateway.location, node_locations[0]);
1963+
assert_eq!(peer_a.location, node_locations[1]);
1964+
assert_eq!(peer_c.location, node_locations[2]);
19421965

19431966
tracing::info!("Node A data dir: {:?}", peer_a.temp_dir_path);
19441967
tracing::info!("Gateway node data dir: {:?}", gateway.temp_dir_path);
19451968
tracing::info!("Node C data dir: {:?}", peer_c.temp_dir_path);
19461969
tracing::info!("Contract location: {}", contract_location.as_f64());
19471970

1971+
let gateway_distance = Location::new(gateway.location).distance(contract_location);
1972+
let peer_a_distance = Location::new(peer_a.location).distance(contract_location);
1973+
let peer_c_distance = Location::new(peer_c.location).distance(contract_location);
1974+
1975+
// Ensure the contract should naturally route to peer-c to create the 3-hop path:
1976+
// peer-a (client) -> gateway -> peer-c (closest to contract).
1977+
assert!(
1978+
peer_c_distance.as_f64() < gateway_distance.as_f64(),
1979+
"peer-c must be closer to contract than the gateway for three-hop routing"
1980+
);
1981+
assert!(
1982+
peer_c_distance.as_f64() < peer_a_distance.as_f64(),
1983+
"peer-c must be closest node to the contract location"
1984+
);
1985+
tracing::info!(
1986+
"Distances to contract - gateway: {}, peer-a: {}, peer-c: {}",
1987+
gateway_distance.as_f64(),
1988+
peer_a_distance.as_f64(),
1989+
peer_c_distance.as_f64()
1990+
);
1991+
19481992
// Connect to peer A's WebSocket API
19491993
let uri_a = format!(
19501994
"ws://127.0.0.1:{}/v1/contract/command?encodingProtocol=native",

crates/freenet-macros/README.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,45 @@ async fn test_multi_gateway(ctx: &mut TestContext) -> TestResult {
8282
}
8383
```
8484

85+
#### `node_locations`
86+
Set explicit ring locations for nodes (order must match the `nodes` list). This is useful for deterministic routing topologies in multi-hop tests.
87+
88+
```rust
89+
#[freenet_test(
90+
nodes = ["gateway", "peer-a", "peer-b"],
91+
node_locations = [0.2, 0.6, 0.85], // gateway, peer-a, peer-b
92+
auto_connect_peers = true
93+
)]
94+
async fn test_with_locations(ctx: &mut TestContext) -> TestResult {
95+
// Test logic here...
96+
Ok(())
97+
}
98+
```
99+
100+
**Rules:**
101+
- Provide one numeric value per node.
102+
- Values should be in the range `[0.0, 1.0]`.
103+
- Omit `node_locations` to use random locations (default behavior).
104+
105+
#### `node_locations_fn`
106+
Provide a function that returns `Vec<f64>` (one per node) when locations need to be computed dynamically (for example, based on a contract’s ring location).
107+
108+
```rust
109+
fn my_locations() -> Vec<f64> {
110+
// Must return one entry per node in the same order.
111+
vec![0.1, 0.6, 0.9]
112+
}
113+
114+
#[freenet_test(
115+
nodes = ["gateway", "peer-a", "peer-b"],
116+
node_locations_fn = my_locations,
117+
auto_connect_peers = true
118+
)]
119+
```
120+
121+
- The function must return exactly as many values as there are nodes; otherwise the test fails early.
122+
- `node_locations` and `node_locations_fn` are mutually exclusive.
123+
85124
#### `auto_connect_peers`
86125
Automatically configure all peer nodes to connect to all gateway nodes.
87126

crates/freenet-macros/src/codegen.rs

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,69 @@ use proc_macro2::TokenStream;
55
use quote::{format_ident, quote};
66
use syn::{ItemFn, LitInt, Result};
77

8+
/// Get the configured location for a node or fall back to randomness.
9+
fn node_location(idx: usize) -> TokenStream {
10+
let idx_lit = syn::Index::from(idx);
11+
quote! {{
12+
if let Some(ref locs) = __node_locations {
13+
locs[#idx_lit]
14+
} else {
15+
rand::Rng::random(&mut rand::rng())
16+
}
17+
}}
18+
}
19+
20+
/// Generate node location initialization (literal list or function).
21+
fn generate_node_locations_init(args: &FreenetTestArgs) -> TokenStream {
22+
let node_count = args.nodes.len();
23+
24+
if let Some(ref fn_path) = args.node_locations_fn {
25+
quote! {
26+
let __node_locations: Option<Vec<f64>> = {
27+
let locs = #fn_path();
28+
if locs.len() != #node_count {
29+
return Err(anyhow::anyhow!(
30+
"node_locations_fn returned {} locations, expected {}",
31+
locs.len(),
32+
#node_count
33+
));
34+
}
35+
for (idx, loc) in locs.iter().enumerate() {
36+
if !(0.0..=1.0).contains(loc) {
37+
return Err(anyhow::anyhow!(
38+
"node_locations_fn value at index {} is out of range: {} (must be in [0.0, 1.0])",
39+
idx,
40+
loc
41+
));
42+
}
43+
}
44+
Some(locs)
45+
};
46+
}
47+
} else if let Some(ref locations) = args.node_locations {
48+
let values: Vec<_> = locations.iter().map(|loc| quote! { #loc }).collect();
49+
quote! {
50+
let __node_locations: Option<Vec<f64>> = {
51+
let locs = vec![#(#values),*];
52+
for (idx, loc) in locs.iter().enumerate() {
53+
if !(0.0..=1.0).contains(loc) {
54+
return Err(anyhow::anyhow!(
55+
"node_locations value at index {} is out of range: {} (must be in [0.0, 1.0])",
56+
idx,
57+
loc
58+
));
59+
}
60+
}
61+
Some(locs)
62+
};
63+
}
64+
} else {
65+
quote! {
66+
let __node_locations: Option<Vec<f64>> = None;
67+
}
68+
}
69+
}
70+
871
/// Helper to determine if a node is a gateway
972
fn is_gateway(args: &FreenetTestArgs, node_label: &str, node_idx: usize) -> bool {
1073
if let Some(ref gateways) = args.gateways {
@@ -27,6 +90,7 @@ pub fn generate_test_code(args: FreenetTestArgs, input_fn: ItemFn) -> Result<Tok
2790

2891
// Generate node setup code
2992
let node_setup = generate_node_setup(&args);
93+
let node_locations_init = generate_node_locations_init(&args);
3094

3195
// Extract values before configs are moved
3296
let value_extraction = generate_value_extraction(&args);
@@ -71,6 +135,7 @@ pub fn generate_test_code(args: FreenetTestArgs, input_fn: ItemFn) -> Result<Tok
71135
tracing::info!("Starting test: {}", stringify!(#test_fn_name));
72136

73137
// 2. Create node configurations
138+
#node_locations_init
74139
#node_setup
75140

76141
// 3. Extract values before configs are moved
@@ -117,6 +182,7 @@ fn generate_node_setup(args: &FreenetTestArgs) -> TokenStream {
117182

118183
if is_gw {
119184
// Gateway node configuration
185+
let location_expr = node_location(idx);
120186
setup_code.push(quote! {
121187
let (#config_var, #temp_var) = {
122188
let temp_dir = tempfile::tempdir()?;
@@ -128,7 +194,7 @@ fn generate_node_setup(args: &FreenetTestArgs) -> TokenStream {
128194
let network_port = freenet::test_utils::reserve_local_port()?;
129195
let ws_port = freenet::test_utils::reserve_local_port()?;
130196

131-
let location: f64 = rand::Rng::random(&mut rand::rng());
197+
let location: f64 = #location_expr;
132198

133199
let config = freenet::config::ConfigArgs {
134200
ws_api: freenet::config::WebsocketApiArgs {
@@ -193,6 +259,7 @@ fn generate_node_setup(args: &FreenetTestArgs) -> TokenStream {
193259
let is_gw = is_gateway(args, node_label, idx);
194260

195261
if !is_gw {
262+
let location_expr = node_location(idx);
196263
// Collect gateway info variables to serialize
197264
let gateways_config = if args.auto_connect_peers {
198265
// Collect all gateway_info_X variables
@@ -238,7 +305,7 @@ fn generate_node_setup(args: &FreenetTestArgs) -> TokenStream {
238305
let network_port = freenet::test_utils::reserve_local_port()?;
239306
let ws_port = freenet::test_utils::reserve_local_port()?;
240307

241-
let location: f64 = rand::Rng::random(&mut rand::rng());
308+
let location: f64 = #location_expr;
242309

243310
let config = freenet::config::ConfigArgs {
244311
ws_api: freenet::config::WebsocketApiArgs {

crates/freenet-macros/src/parser.rs

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ pub struct FreenetTestArgs {
77
pub nodes: Vec<String>,
88
/// Which nodes are gateways (if not specified, first node is gateway)
99
pub gateways: Option<Vec<String>>,
10+
/// Optional explicit node locations (same order as nodes)
11+
pub node_locations: Option<Vec<f64>>,
12+
/// Optional function path that returns node locations (same order as nodes)
13+
pub node_locations_fn: Option<syn::ExprPath>,
1014
/// Whether peers should auto-connect to gateways
1115
pub auto_connect_peers: bool,
1216
/// Test timeout in seconds
@@ -40,6 +44,8 @@ impl syn::parse::Parse for FreenetTestArgs {
4044
fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
4145
let mut nodes = None;
4246
let mut gateways = None;
47+
let mut node_locations = None;
48+
let mut node_locations_fn = None;
4349
let mut auto_connect_peers = false;
4450
let mut timeout_secs = 180;
4551
let mut startup_wait_secs = 15;
@@ -79,6 +85,47 @@ impl syn::parse::Parse for FreenetTestArgs {
7985

8086
nodes = Some(node_list);
8187
}
88+
"node_locations" => {
89+
// Parse array literal of floats: [0.1, 0.5, 0.9]
90+
let content;
91+
syn::bracketed!(content in input);
92+
93+
let mut locations = Vec::new();
94+
while !content.is_empty() {
95+
let lit: syn::Lit = content.parse()?;
96+
let value = match lit {
97+
syn::Lit::Float(f) => f.base10_parse::<f64>()?,
98+
syn::Lit::Int(i) => i.base10_parse::<f64>()?,
99+
other => {
100+
return Err(syn::Error::new(
101+
other.span(),
102+
"node_locations must be numeric literals",
103+
))
104+
}
105+
};
106+
107+
locations.push(value);
108+
109+
// Handle optional trailing comma
110+
if content.peek(syn::Token![,]) {
111+
content.parse::<syn::Token![,]>()?;
112+
}
113+
}
114+
115+
if locations.is_empty() {
116+
return Err(syn::Error::new(
117+
key.span(),
118+
"node_locations array cannot be empty if specified",
119+
));
120+
}
121+
122+
node_locations = Some(locations);
123+
}
124+
"node_locations_fn" => {
125+
// Parse a path to a function returning Vec<f64>
126+
let path: syn::ExprPath = input.parse()?;
127+
node_locations_fn = Some(path);
128+
}
82129
"gateways" => {
83130
// Parse array literal: ["gateway-1", "gateway-2", ...]
84131
let content;
@@ -191,9 +238,37 @@ impl syn::parse::Parse for FreenetTestArgs {
191238
}
192239
}
193240

241+
// Validate node_locations if provided
242+
if let Some(ref locations) = node_locations {
243+
if locations.len() != nodes.len() {
244+
return Err(input.error(format!(
245+
"node_locations length ({}) must match nodes length ({})",
246+
locations.len(),
247+
nodes.len()
248+
)));
249+
}
250+
for (i, &loc) in locations.iter().enumerate() {
251+
if !(0.0..=1.0).contains(&loc) {
252+
return Err(input.error(format!(
253+
"node_locations[{}] = {} is out of range. Values must be in [0.0, 1.0]",
254+
i, loc
255+
)));
256+
}
257+
}
258+
}
259+
260+
// Only one of node_locations or node_locations_fn may be provided
261+
if node_locations.is_some() && node_locations_fn.is_some() {
262+
return Err(
263+
input.error("Specify only one of node_locations or node_locations_fn (not both)")
264+
);
265+
}
266+
194267
Ok(FreenetTestArgs {
195268
nodes,
196269
gateways,
270+
node_locations,
271+
node_locations_fn,
197272
auto_connect_peers,
198273
timeout_secs,
199274
startup_wait_secs,

0 commit comments

Comments
 (0)