Skip to content

Commit 6dfcdeb

Browse files
authored
robot-name: Redesign for testability (#2114)
The previous exercise design had two fundamental flaws wrt. testability: - The source of randomness was not injected by the tests. It was expected that users would initialize an RNG on their own. This lead to users simply not using any randomness at all. - The fact that names were required to be _globally_ unique meant that there was no way to test for deterministic behavior, given a PRNG with a stable seed. These two problems are solved by injecting an RNG from the tests and by narrowing the requirement of unique names to a single "robot factory". Motivated by this discussion on the forum: https://forum.exercism.org/t/rust-track-check-predictability-of-names-in-robot-name-exercise/20077 The function `RobotFactory::new_robot` takes a `&mut self`, even though it could take a `&self` as well. `&self` would work, because the robot factory needs shared ownership & interior mutability anyway to track robot names generated with `Robot::reset`. However, that would slightly interfere with the TTD flow of this exercise. If `RobotFactory::new_robot` takes a `&self`, users will be forced to use interior mutability right away, even though it's unclear why that is even necessary until they get around to implementing `Robot::reset`.
1 parent 9f0dc6a commit 6dfcdeb

File tree

8 files changed

+130
-75
lines changed

8 files changed

+130
-75
lines changed

exercises/practice/robot-name/.docs/instructions.append.md

Lines changed: 0 additions & 9 deletions
This file was deleted.

exercises/practice/robot-name/.meta/Cargo-example.toml

Lines changed: 0 additions & 10 deletions
This file was deleted.

exercises/practice/robot-name/.meta/config.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
"pminten",
2323
"razielgn",
2424
"rofrol",
25+
"senekor",
2526
"spazm",
2627
"stringparser",
2728
"workingjubilee",
Lines changed: 39 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,52 +1,64 @@
11
use std::{
22
collections::HashSet,
3-
sync::{LazyLock, Mutex},
3+
sync::{Arc, Mutex},
44
};
55

6-
use rand::{Rng, thread_rng};
6+
use rand::Rng;
77

8-
static NAMES: LazyLock<Mutex<HashSet<String>>> = LazyLock::new(|| Mutex::new(HashSet::new()));
9-
10-
pub struct Robot {
11-
name: String,
12-
}
13-
14-
fn generate_name() -> String {
8+
fn generate_name<R: Rng>(rng: &mut R, used_names: &mut HashSet<String>) -> String {
159
loop {
1610
let mut s = String::with_capacity(5);
17-
static LETTERS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ";
18-
static NUMBERS: &[u8] = b"0123456789";
1911
for _ in 0..2 {
20-
s.push(*thread_rng().choose(LETTERS).unwrap() as char);
12+
s.push(rng.random_range('A'..='Z'));
2113
}
2214
for _ in 0..3 {
23-
s.push(*thread_rng().choose(NUMBERS).unwrap() as char);
15+
s.push(rng.random_range('0'..='9'));
2416
}
25-
26-
if NAMES.lock().unwrap().insert(s.clone()) {
17+
if used_names.insert(s.clone()) {
2718
return s;
2819
}
2920
}
3021
}
3122

32-
impl Robot {
33-
pub fn new() -> Robot {
34-
Robot {
35-
name: generate_name(),
23+
/// A `RobotFactory` is responsible for ensuring that all robots produced by
24+
/// it have a unique name. Robots from different factories can have the same
25+
/// name.
26+
pub struct RobotFactory {
27+
used_names: Arc<Mutex<HashSet<String>>>,
28+
}
29+
30+
pub struct Robot {
31+
used_names: Arc<Mutex<HashSet<String>>>,
32+
name: String,
33+
}
34+
35+
impl RobotFactory {
36+
pub fn new() -> Self {
37+
Self {
38+
used_names: Arc::new(Mutex::new(HashSet::new())),
3639
}
3740
}
3841

39-
pub fn name(&self) -> &str {
40-
&self.name[..]
42+
pub fn new_robot<R: Rng>(&mut self, rng: &mut R) -> Robot {
43+
let mut guard = self.used_names.lock().unwrap();
44+
Robot {
45+
used_names: Arc::clone(&self.used_names),
46+
name: generate_name(rng, &mut guard),
47+
}
4148
}
49+
}
4250

43-
pub fn reset_name(&mut self) {
44-
self.name = generate_name();
51+
impl Robot {
52+
pub fn name(&self) -> &str {
53+
&self.name
4554
}
46-
}
4755

48-
impl Default for Robot {
49-
fn default() -> Self {
50-
Self::new()
56+
// When a robot is reset, its factory must still ensure that there are no
57+
// name conflicts with other robots from the same factory.
58+
pub fn reset<R: Rng>(&mut self, rng: &mut R) {
59+
let mut used_names = self.used_names.lock().unwrap();
60+
let new_name = generate_name(rng, &mut used_names);
61+
used_names.remove(&self.name);
62+
self.name = new_name
5163
}
5264
}

exercises/practice/robot-name/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ edition = "2024"
77
# The full list of available libraries is here:
88
# https://github.com/exercism/rust-test-runner/blob/main/local-registry/Cargo.toml
99
[dependencies]
10+
rand = "0.9"
1011

1112
[lints.clippy]
1213
new_without_default = "allow"
Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,28 @@
1+
use rand::Rng;
2+
3+
/// A `RobotFactory` is responsible for ensuring that all robots produced by
4+
/// it have a unique name. Robots from different factories can have the same
5+
/// name.
6+
pub struct RobotFactory;
7+
18
pub struct Robot;
29

3-
impl Robot {
10+
impl RobotFactory {
411
pub fn new() -> Self {
5-
todo!("Construct a new Robot struct.");
12+
todo!("Create a new robot factory")
613
}
714

15+
pub fn new_robot<R: Rng>(&mut self, _rng: &mut R) -> Robot {
16+
todo!("Create a new robot with a unique name")
17+
}
18+
}
19+
20+
impl Robot {
821
pub fn name(&self) -> &str {
9-
todo!("Return the reference to the robot's name.");
22+
todo!("Return a reference to the robot's name");
1023
}
1124

12-
pub fn reset_name(&mut self) {
13-
todo!("Assign a new unique name to the robot.");
25+
pub fn reset<R: Rng>(&mut self, _rng: &mut R) {
26+
todo!("Assign a new unique name to the robot");
1427
}
1528
}
Lines changed: 70 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
use robot_name as robot;
1+
use rand::SeedableRng as _;
2+
use rand::rngs::SmallRng;
3+
use robot_name::*;
4+
5+
fn deterministic_rng() -> SmallRng {
6+
SmallRng::seed_from_u64(0)
7+
}
28

39
fn assert_name_matches_pattern(n: &str) {
410
assert!(n.len() == 5, "name is exactly 5 characters long");
@@ -14,46 +20,86 @@ fn assert_name_matches_pattern(n: &str) {
1420

1521
#[test]
1622
fn name_should_match_expected_pattern() {
17-
let r = robot::Robot::new();
18-
assert_name_matches_pattern(r.name());
23+
let mut rng = deterministic_rng();
24+
let mut factory = RobotFactory::new();
25+
let robot = factory.new_robot(&mut rng);
26+
assert_name_matches_pattern(robot.name());
1927
}
2028

2129
#[test]
2230
#[ignore]
23-
fn different_robots_have_different_names() {
24-
let r1 = robot::Robot::new();
25-
let r2 = robot::Robot::new();
26-
assert_ne!(r1.name(), r2.name(), "Robot names should be different");
31+
fn robot_name_depends_on_rng() {
32+
let mut rng = deterministic_rng();
33+
let robot_1 = RobotFactory::new().new_robot(&mut rng);
34+
let robot_2 = RobotFactory::new().new_robot(&mut rng);
35+
assert_ne!(robot_1.name(), robot_2.name());
2736
}
2837

2938
#[test]
3039
#[ignore]
31-
fn many_different_robots_have_different_names() {
32-
use std::collections::HashSet;
33-
34-
// In 3,529 random robot names, there is ~99.99% chance of a name collision
35-
let vec: Vec<_> = (0..3529).map(|_| robot::Robot::new()).collect();
36-
let set: HashSet<_> = vec.iter().map(|robot| robot.name()).collect();
40+
fn robot_name_only_depends_on_rng() {
41+
let robot_1 = RobotFactory::new().new_robot(&mut deterministic_rng());
42+
let robot_2 = RobotFactory::new().new_robot(&mut deterministic_rng());
43+
assert_eq!(robot_1.name(), robot_2.name());
44+
}
3745

38-
let number_of_collisions = vec.len() - set.len();
39-
assert_eq!(number_of_collisions, 0);
46+
#[test]
47+
#[ignore]
48+
fn factory_prevents_name_collisions() {
49+
let mut factory = RobotFactory::new();
50+
let robot_1 = factory.new_robot(&mut deterministic_rng());
51+
let robot_2 = factory.new_robot(&mut deterministic_rng());
52+
assert_ne!(robot_1.name(), robot_2.name());
4053
}
4154

4255
#[test]
4356
#[ignore]
4457
fn new_name_should_match_expected_pattern() {
45-
let mut r = robot::Robot::new();
46-
assert_name_matches_pattern(r.name());
47-
r.reset_name();
48-
assert_name_matches_pattern(r.name());
58+
let mut rng = deterministic_rng();
59+
let mut factory = RobotFactory::new();
60+
let mut robot = factory.new_robot(&mut rng);
61+
assert_name_matches_pattern(robot.name());
62+
robot.reset(&mut rng);
63+
assert_name_matches_pattern(robot.name());
4964
}
5065

5166
#[test]
5267
#[ignore]
5368
fn new_name_is_different_from_old_name() {
54-
let mut r = robot::Robot::new();
55-
let n1 = r.name().to_string();
56-
r.reset_name();
57-
let n2 = r.name().to_string();
58-
assert_ne!(n1, n2, "Robot name should change when reset");
69+
let mut rng = deterministic_rng();
70+
let mut factory = RobotFactory::new();
71+
let mut robot = factory.new_robot(&mut rng);
72+
let name_1 = robot.name().to_string();
73+
robot.reset(&mut rng);
74+
let name_2 = robot.name().to_string();
75+
assert_ne!(name_1, name_2, "Robot name should change when reset");
76+
}
77+
78+
#[test]
79+
#[ignore]
80+
fn factory_prevents_name_collision_despite_reset() {
81+
let mut factory = RobotFactory::new();
82+
83+
let mut rng = deterministic_rng();
84+
let mut robot_1 = factory.new_robot(&mut rng);
85+
robot_1.reset(&mut rng);
86+
87+
let mut rng = deterministic_rng();
88+
let mut robot_2 = factory.new_robot(&mut rng);
89+
robot_2.reset(&mut rng);
90+
91+
assert_ne!(robot_1.name(), robot_2.name());
92+
}
93+
94+
#[test]
95+
#[ignore]
96+
fn old_name_becomes_available_after_reset() {
97+
let mut rng = deterministic_rng();
98+
let mut factory = RobotFactory::new();
99+
let mut robot = factory.new_robot(&mut rng);
100+
let first_name = robot.name().to_string();
101+
robot.reset(&mut rng); // cause first name to become available again
102+
let mut rng = deterministic_rng(); // reset rng
103+
let second_robot = factory.new_robot(&mut rng);
104+
assert_eq!(second_robot.name(), first_name);
59105
}

rust-tooling/ci-tests/tests/no_underscore_prefixed_idents.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ static EXCEPTIONS: &[&str] = &[
5656
"roman-numerals", // std::fmt::Formatter is not Debug
5757
"simple-linked-list", // has generics
5858
"sublist", // has generics
59+
"robot-name", // has generics
5960
];
6061

6162
fn line_is_not_a_comment(line: &&str) -> bool {

0 commit comments

Comments
 (0)