1use crate::highscore::podium::{Medal, Record};
2use crate::highscore::Highscores;
3use crate::HumanReadable;
4
5const STROKE: &str = "─";
6const STAR: &str = "★";
7const MIDDLE_DOT: &str = "·"; const EM_DASH: &str = "—"; const TROPHY: &str = "🏆";
10const MEDAL_GOLD: &str = "🥇";
11const MEDAL_SILVER: &str = "🥈";
12const MEDAL_BRONZE: &str = "🥉";
13
14const BANNER_INDENT: &str = " ";
15const HEADER_SIDE_STROKES: usize = 4;
16const RULE_STROKES: usize = 28;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum TrackName {
21 SingleCleanup,
22 TotalRun,
23}
24
25impl TrackName {
26 pub fn sort_key(self) -> u8 {
27 match self {
28 TrackName::SingleCleanup => 0,
29 TrackName::TotalRun => 1,
30 }
31 }
32}
33
34impl std::fmt::Display for TrackName {
35 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36 match self {
37 TrackName::SingleCleanup => write!(f, "Single cleanup"),
38 TrackName::TotalRun => write!(f, "Total run"),
39 }
40 }
41}
42
43pub struct EarnedMedal {
45 pub medal: Medal,
46 pub track: TrackName,
47 pub size: u64,
48}
49
50impl Medal {
51 pub fn emoji(&self) -> &'static str {
52 match self {
53 Medal::Gold => MEDAL_GOLD,
54 Medal::Silver => MEDAL_SILVER,
55 Medal::Bronze => MEDAL_BRONZE,
56 }
57 }
58
59 pub fn label(&self) -> &'static str {
60 match self {
61 Medal::Gold => "Gold",
62 Medal::Silver => "Silver",
63 Medal::Bronze => "Bronze",
64 }
65 }
66}
67
68fn banner_header(title: &str) -> String {
70 let side = STROKE.repeat(HEADER_SIDE_STROKES);
71 format!(
72 "{}{} {} {} {} {}",
73 BANNER_INDENT, side, STAR, title, STAR, side
74 )
75}
76
77fn banner_rule() -> String {
79 format!("{}{}", BANNER_INDENT, STROKE.repeat(RULE_STROKES))
80}
81
82fn render_medal(earned: &EarnedMedal) -> String {
84 let size = (earned.size as usize).as_human_readable();
85 format!(
86 "\n{}\n {} {} {} {}\n {}\n{}",
87 banner_header("NEW HIGHSCORE"),
88 earned.medal.emoji(),
89 earned.medal.label(),
90 MIDDLE_DOT,
91 earned.track,
92 size,
93 banner_rule(),
94 )
95}
96
97pub fn render_medals(medals: &[EarnedMedal]) -> Option<String> {
101 if medals.is_empty() {
102 return None;
103 }
104 let mut sorted: Vec<&EarnedMedal> = medals.iter().collect();
105 sorted.sort_by_key(|m| (m.track.sort_key(), m.medal.sort_key()));
106 let output: String = sorted.iter().map(|m| render_medal(m)).collect();
107 Some(output)
108}
109
110pub fn inline_hint() -> String {
112 format!("{} new highscore!", TROPHY)
113}
114
115fn center_pad(s: &str, width: usize) -> String {
117 let len = s.chars().count();
118 if len >= width {
119 return s.to_string();
120 }
121 let diff = width - len;
122 let left = (diff + 1).div_ceil(2);
123 let right = diff / 2;
124 format!("{}{}{}", " ".repeat(left), s, " ".repeat(right))
125}
126
127fn render_board_slot(medal: Medal, record: Option<&Record>) -> String {
131 let detail = match record {
132 Some(r) => format!(
133 "{} {} {}",
134 (r.size as usize).as_human_readable(),
135 MIDDLE_DOT,
136 r.date
137 ),
138 None => format!("(open {} be the first!)", EM_DASH),
139 };
140 format!(" {} {}\n{:>29}", medal.emoji(), medal.label(), detail,)
143}
144
145pub fn render_board(highscores: &Highscores) -> String {
149 let tracks = [
150 ("SINGLE CLEANUP", &highscores.single_cleanup),
151 ("TOTAL RUN", &highscores.total_run),
152 ];
153 let title_width = tracks.iter().map(|(t, _)| t.chars().count()).max().unwrap();
154
155 let mut out = String::new();
156 for (title, podium) in tracks {
157 let padded = center_pad(title, title_width);
158 out.push('\n');
159 out.push_str(&banner_header(&padded));
160 out.push('\n');
161 out.push_str(&render_board_slot(Medal::Gold, podium.gold.as_ref()));
162 out.push('\n');
163 out.push_str(&banner_rule());
164 out.push('\n');
165 out.push_str(&render_board_slot(Medal::Silver, podium.silver.as_ref()));
166 out.push('\n');
167 out.push_str(&banner_rule());
168 out.push('\n');
169 out.push_str(&render_board_slot(Medal::Bronze, podium.bronze.as_ref()));
170 out.push('\n');
171 out.push_str(&banner_rule());
172 out.push('\n');
173 }
174 out
175}
176
177#[cfg(test)]
178mod tests {
179 use super::*;
180
181 #[test]
182 fn render_single_gold_medal() {
183 let medals = vec![EarnedMedal {
184 medal: Medal::Gold,
185 track: TrackName::SingleCleanup,
186 size: 2_684_354_560, }];
188 let output = render_medals(&medals).unwrap();
189 assert!(output.contains("NEW HIGHSCORE"));
190 assert!(output.contains("Gold"));
191 assert!(output.contains("Single cleanup"));
192 assert!(output.contains("2.5GiB"));
193 }
194
195 #[test]
196 fn render_multiple_medals() {
197 let medals = vec![
198 EarnedMedal {
199 medal: Medal::Gold,
200 track: TrackName::SingleCleanup,
201 size: 2_684_354_560,
202 },
203 EarnedMedal {
204 medal: Medal::Silver,
205 track: TrackName::TotalRun,
206 size: 1_073_741_824,
207 },
208 ];
209 let output = render_medals(&medals).unwrap();
210 assert!(output.contains("Gold"));
212 assert!(output.contains("Silver"));
213 }
214
215 #[test]
216 fn render_medals_sorted_by_track_then_rank() {
217 let medals = vec![
218 EarnedMedal {
219 medal: Medal::Gold,
220 track: TrackName::SingleCleanup,
221 size: 3_000_000_000,
222 },
223 EarnedMedal {
224 medal: Medal::Bronze,
225 track: TrackName::SingleCleanup,
226 size: 500_000_000,
227 },
228 EarnedMedal {
229 medal: Medal::Silver,
230 track: TrackName::SingleCleanup,
231 size: 2_000_000_000,
232 },
233 EarnedMedal {
234 medal: Medal::Gold,
235 track: TrackName::TotalRun,
236 size: 5_500_000_000,
237 },
238 ];
239 let output = render_medals(&medals).unwrap();
240 let gold_pos = output.find("Gold \u{00B7} Single").unwrap();
241 let silver_pos = output.find("Silver \u{00B7} Single").unwrap();
242 let bronze_pos = output.find("Bronze \u{00B7} Single").unwrap();
243 let total_pos = output.find("Gold \u{00B7} Total").unwrap();
244 assert!(gold_pos < silver_pos);
245 assert!(silver_pos < bronze_pos);
246 assert!(bronze_pos < total_pos);
247 }
248
249 #[test]
250 fn render_empty_returns_none() {
251 assert!(render_medals(&[]).is_none());
252 }
253
254 #[test]
255 fn inline_hint_contains_trophy() {
256 let hint = inline_hint();
257 assert!(hint.contains("new highscore!"));
258 }
259
260 #[test]
261 fn render_board_slot_populated_contains_size_and_date() {
262 let record = Record {
263 size: 1_073_741_824, date: "2026-03-15".to_string(),
265 };
266 let out = render_board_slot(Medal::Gold, Some(&record));
267 assert!(out.contains("Gold"));
268 assert!(out.contains("1.0GiB"));
269 assert!(out.contains("2026-03-15"));
270 assert!(!out.contains("open"));
271 }
272
273 #[test]
274 fn render_board_slot_open_contains_marker() {
275 let out = render_board_slot(Medal::Silver, None);
276 assert!(out.contains("Silver"));
277 assert!(out.contains("open"));
278 assert!(out.contains("be the first"));
279 }
280
281 #[test]
282 fn render_board_slot_right_aligns_detail_to_fixed_width() {
283 let short = Record {
284 size: 1_073_741_824,
285 date: "2026-03-15".to_string(),
286 };
287 let long = Record {
288 size: 42_949_672_960,
289 date: "2026-03-15".to_string(),
290 };
291 let out_short = render_board_slot(Medal::Gold, Some(&short));
292 let out_long = render_board_slot(Medal::Gold, Some(&long));
293 let detail_short = out_short.lines().nth(1).unwrap();
294 let detail_long = out_long.lines().nth(1).unwrap();
295 assert_eq!(
296 detail_short.chars().count(),
297 detail_long.chars().count(),
298 "detail lines must have equal char counts for right-alignment"
299 );
300 }
301
302 use crate::highscore::podium::Podium;
303
304 fn populated_record(size: u64, date: &str) -> Record {
305 Record {
306 size,
307 date: date.to_string(),
308 }
309 }
310
311 #[test]
312 fn render_board_empty_highscores_shows_all_open() {
313 let highscores = Highscores::default();
314 let out = render_board(&highscores);
315 assert!(out.contains("SINGLE CLEANUP"));
316 assert!(out.contains("TOTAL RUN"));
317 assert_eq!(out.matches("(open").count(), 6);
319 assert!(!out.contains("GiB"));
321 assert!(!out.contains("MiB"));
322 assert!(!out.contains("KiB"));
323 }
324
325 #[test]
326 fn render_board_fully_populated_shows_all_records() {
327 let highscores = Highscores {
328 single_cleanup: Podium {
329 gold: Some(populated_record(3_000_000_000, "2026-03-15")),
330 silver: Some(populated_record(2_000_000_000, "2026-02-01")),
331 bronze: Some(populated_record(1_000_000_000, "2026-01-20")),
332 },
333 total_run: Podium {
334 gold: Some(populated_record(5_500_000_000, "2026-03-15")),
335 silver: Some(populated_record(3_300_000_000, "2026-02-14")),
336 bronze: Some(populated_record(1_100_000_000, "2026-01-10")),
337 },
338 };
339 let out = render_board(&highscores);
340 assert!(out.contains("SINGLE CLEANUP"));
341 assert!(out.contains("TOTAL RUN"));
342 assert_eq!(out.matches("Gold").count(), 2);
343 assert_eq!(out.matches("Silver").count(), 2);
344 assert_eq!(out.matches("Bronze").count(), 2);
345 assert!(out.contains("2026-03-15"));
347 assert!(out.contains("2026-01-10"));
348 assert_eq!(out.matches("(open").count(), 0);
350 }
351
352 #[test]
353 fn render_board_banner_headers_have_equal_width() {
354 let highscores = Highscores::default();
355 let out = render_board(&highscores);
356 let header_lines: Vec<&str> = out.lines().filter(|l| l.contains(STAR)).collect();
357 assert_eq!(header_lines.len(), 2);
358 assert_eq!(
359 header_lines[0].chars().count(),
360 header_lines[1].chars().count(),
361 "banner headers for SINGLE CLEANUP and TOTAL RUN must match widths"
362 );
363 }
364
365 #[test]
366 fn render_board_slot_detail_mirrors_left_margin() {
367 let record = Record {
370 size: 1_073_741_824,
371 date: "2026-03-15".to_string(),
372 };
373 let out = render_board_slot(Medal::Gold, Some(&record));
374 let detail_line = out.lines().nth(1).unwrap();
375 assert_eq!(detail_line.chars().count(), 29);
376 }
377
378 #[test]
379 fn render_board_partial_track_mixes_populated_and_open() {
380 let highscores = Highscores {
381 single_cleanup: Podium {
382 gold: Some(populated_record(1_073_741_824, "2026-03-15")),
383 silver: None,
384 bronze: None,
385 },
386 ..Default::default()
387 };
388 let out = render_board(&highscores);
389 assert!(out.contains("1.0GiB"));
391 assert!(out.contains("2026-03-15"));
392 assert_eq!(out.matches("(open").count(), 5);
394 }
395}