Skip to content

Commit e8ab978

Browse files
committed
robot-name: Redesign for testability
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 e16a91c commit e8ab978

File tree

8 files changed

+131
-74
lines changed

8 files changed

+131
-74
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: 37 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,52 +1,62 @@
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+
self.name = generate_name(rng, &mut used_names);
5161
}
5262
}

exercises/practice/robot-name/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ 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+
# We use a wild-card specifier to minimize the risk of breaking existing
11+
# solutions in case the version in the test runner is updated.
12+
rand = "*"
1013

1114
[lints.clippy]
1215
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: 71 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
1-
use robot_name as robot;
1+
use std::collections::HashSet;
2+
3+
use rand::SeedableRng as _;
4+
use rand::rngs::SmallRng;
5+
use robot_name::*;
6+
7+
fn deterministic_rng() -> SmallRng {
8+
SmallRng::seed_from_u64(0)
9+
}
210

311
fn assert_name_matches_pattern(n: &str) {
412
assert!(n.len() == 5, "name is exactly 5 characters long");
@@ -14,46 +22,86 @@ fn assert_name_matches_pattern(n: &str) {
1422

1523
#[test]
1624
fn name_should_match_expected_pattern() {
17-
let r = robot::Robot::new();
18-
assert_name_matches_pattern(r.name());
25+
let mut rng = deterministic_rng();
26+
let mut factory = RobotFactory::new();
27+
let robot = factory.new_robot(&mut rng);
28+
assert_name_matches_pattern(robot.name());
1929
}
2030

2131
#[test]
2232
#[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");
33+
fn factory_prevents_name_collisions() {
34+
let mut factory = RobotFactory::new();
35+
let robot_1 = factory.new_robot(&mut deterministic_rng());
36+
let robot_2 = factory.new_robot(&mut deterministic_rng());
37+
assert_ne!(robot_1.name(), robot_2.name());
2738
}
2839

2940
#[test]
3041
#[ignore]
31-
fn many_different_robots_have_different_names() {
32-
use std::collections::HashSet;
42+
fn robot_name_depends_on_rng() {
43+
let mut rng = deterministic_rng();
44+
let robot_1 = RobotFactory::new().new_robot(&mut rng);
45+
let robot_2 = RobotFactory::new().new_robot(&mut rng);
46+
assert_ne!(robot_1.name(), robot_2.name());
47+
}
3348

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();
49+
#[test]
50+
#[ignore]
51+
fn robot_name_only_depends_on_rng() {
52+
let robot_1 = RobotFactory::new().new_robot(&mut deterministic_rng());
53+
let robot_2 = RobotFactory::new().new_robot(&mut deterministic_rng());
54+
assert_eq!(robot_1.name(), robot_2.name());
55+
}
3756

38-
let number_of_collisions = vec.len() - set.len();
39-
assert_eq!(number_of_collisions, 0);
57+
#[test]
58+
#[ignore]
59+
fn many_different_robots_have_different_names() {
60+
// In 3,529 random robot names, there is ~99.99% chance of a name collision
61+
let mut rng = deterministic_rng();
62+
let mut factory = RobotFactory::new();
63+
let robots: Vec<_> = (0..3529).map(|_| factory.new_robot(&mut rng)).collect();
64+
let mut set = HashSet::new();
65+
assert!(robots.iter().all(|robot| set.insert(robot.name())));
4066
}
4167

4268
#[test]
4369
#[ignore]
4470
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());
71+
let mut rng = deterministic_rng();
72+
let mut factory = RobotFactory::new();
73+
let mut robot = factory.new_robot(&mut rng);
74+
assert_name_matches_pattern(robot.name());
75+
robot.reset(&mut rng);
76+
assert_name_matches_pattern(robot.name());
4977
}
5078

5179
#[test]
5280
#[ignore]
5381
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");
82+
let mut rng = deterministic_rng();
83+
let mut factory = RobotFactory::new();
84+
let mut robot = factory.new_robot(&mut rng);
85+
let name_1 = robot.name().to_string();
86+
robot.reset(&mut rng);
87+
let name_2 = robot.name().to_string();
88+
assert_ne!(name_1, name_2, "Robot name should change when reset");
89+
}
90+
91+
#[test]
92+
#[ignore]
93+
fn factory_prevents_name_collision_despite_reset() {
94+
// To keep the same probablity as the first test with many robots, we
95+
// generate 3,529 robots and reset their names, then generate another 3,529
96+
// robots and check if there are collisions across these two groups.
97+
let mut rng = deterministic_rng();
98+
let mut factory = RobotFactory::new();
99+
let mut reset_robots: Vec<_> = (0..3529).map(|_| factory.new_robot(&mut rng)).collect();
100+
for robot in &mut reset_robots {
101+
robot.reset(&mut rng);
102+
}
103+
let mut set = HashSet::new();
104+
assert!(reset_robots.iter().all(|robot| set.insert(robot.name())));
105+
106+
assert!((0..3529).all(|_| !set.contains(factory.new_robot(&mut rng).name())));
59107
}

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)