#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Stage {
Paying,
Starting,
Thinking,
Streaming,
Tools,
}
impl Stage {
pub fn word(self) -> &'static str {
match self {
Stage::Paying => "paying",
Stage::Starting => "starting",
Stage::Thinking => "thinking",
Stage::Streaming => "streaming",
Stage::Tools => "tools",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Slot {
Past,
Current,
Idle,
}
#[derive(Debug, Default)]
pub struct StagePipeline {
trail: Vec<Stage>,
current: usize,
}
impl StagePipeline {
pub fn new() -> Self {
Self::default()
}
pub fn enter(&mut self, stage: Stage) -> bool {
if let Some(idx) = self.trail.iter().position(|s| *s == stage) {
if self.current == idx {
return false;
}
self.current = idx;
return true;
}
self.trail.push(stage);
self.current = self.trail.len() - 1;
true
}
pub fn is_empty(&self) -> bool {
self.trail.is_empty()
}
pub fn slots(&self) -> Vec<(Stage, Slot)> {
self.trail
.iter()
.enumerate()
.map(|(i, s)| {
let slot = match i.cmp(&self.current) {
std::cmp::Ordering::Less => Slot::Past,
std::cmp::Ordering::Equal => Slot::Current,
std::cmp::Ordering::Greater => Slot::Idle,
};
(*s, slot)
})
.collect()
}
}
#[cfg(test)]
mod tests {
use super::{Slot, Stage, StagePipeline};
#[test]
fn words_are_lowercase_and_stable() {
assert_eq!(Stage::Paying.word(), "paying");
assert_eq!(Stage::Starting.word(), "starting");
assert_eq!(Stage::Thinking.word(), "thinking");
assert_eq!(Stage::Streaming.word(), "streaming");
assert_eq!(Stage::Tools.word(), "tools");
for s in [
Stage::Paying,
Stage::Starting,
Stage::Thinking,
Stage::Streaming,
Stage::Tools,
] {
assert_eq!(s.word(), s.word().to_lowercase());
assert!(!s.word().contains(' '));
}
}
#[test]
fn fresh_pipeline_renders_nothing() {
let p = StagePipeline::new();
assert!(p.is_empty());
assert!(p.slots().is_empty());
}
#[test]
fn first_enter_appends_and_is_current() {
let mut p = StagePipeline::new();
assert!(p.enter(Stage::Thinking));
assert_eq!(p.slots(), vec![(Stage::Thinking, Slot::Current)]);
}
#[test]
fn reentering_the_current_stage_is_a_no_op() {
let mut p = StagePipeline::new();
assert!(p.enter(Stage::Streaming));
assert!(!p.enter(Stage::Streaming));
assert!(!p.enter(Stage::Streaming));
assert_eq!(p.slots(), vec![(Stage::Streaming, Slot::Current)]);
}
#[test]
fn paid_cold_session_walks_the_full_pipeline() {
let mut p = StagePipeline::new();
for s in [Stage::Paying, Stage::Starting, Stage::Thinking, Stage::Streaming] {
assert!(p.enter(s));
}
assert_eq!(
p.slots(),
vec![
(Stage::Paying, Slot::Past),
(Stage::Starting, Slot::Past),
(Stage::Thinking, Slot::Past),
(Stage::Streaming, Slot::Current),
]
);
}
#[test]
fn free_turn_never_shows_paying_or_starting() {
let mut p = StagePipeline::new();
p.enter(Stage::Thinking);
p.enter(Stage::Streaming);
let words: Vec<&str> = p.slots().iter().map(|(s, _)| s.word()).collect();
assert_eq!(words, vec!["thinking", "streaming"]);
}
#[test]
fn tools_streaming_ping_pong_never_duplicates_words() {
let mut p = StagePipeline::new();
p.enter(Stage::Streaming);
p.enter(Stage::Tools);
assert!(p.enter(Stage::Streaming)); assert_eq!(
p.slots(),
vec![(Stage::Streaming, Slot::Current), (Stage::Tools, Slot::Idle)]
);
assert!(p.enter(Stage::Tools));
assert_eq!(
p.slots(),
vec![(Stage::Streaming, Slot::Past), (Stage::Tools, Slot::Current)]
);
for _ in 0..5 {
p.enter(Stage::Streaming);
p.enter(Stage::Tools);
}
assert_eq!(p.slots().len(), 2);
}
#[test]
fn exactly_one_current_at_all_times() {
let mut p = StagePipeline::new();
for s in [
Stage::Paying,
Stage::Starting,
Stage::Thinking,
Stage::Streaming,
Stage::Tools,
Stage::Streaming, ] {
p.enter(s);
let currents = p
.slots()
.iter()
.filter(|(_, slot)| *slot == Slot::Current)
.count();
assert_eq!(currents, 1);
}
}
}