Skip to main content

logicaffeine_web/
learn_state.rs

1//! Tab & Focus State Management for Learn Page
2//!
3//! Manages the state for the integrated learn page experience:
4//! - Tab modes (Lesson, Examples, Practice, Test)
5//! - Focus state (which era/module is expanded)
6//! - Exercise navigation within modes
7
8/// The four tab modes available for each module
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
10pub enum TabMode {
11    #[default]
12    Lesson,
13    Examples,
14    Practice,
15    Test,
16}
17
18impl TabMode {
19    /// Get display label for the tab
20    pub fn label(&self) -> &'static str {
21        match self {
22            TabMode::Lesson => "LESSON",
23            TabMode::Examples => "EXAMPLES",
24            TabMode::Practice => "PRACTICE",
25            TabMode::Test => "TEST",
26        }
27    }
28
29    /// Get all tab modes in order
30    pub fn all() -> [TabMode; 4] {
31        [TabMode::Lesson, TabMode::Examples, TabMode::Practice, TabMode::Test]
32    }
33}
34
35/// State for a single module's tab interface
36#[derive(Debug, Clone, Default)]
37pub struct ModuleTabState {
38    pub module_id: String,
39    pub current_tab: TabMode,
40    pub exercise_index: usize,
41    pub submitted: bool,
42}
43
44impl ModuleTabState {
45    pub fn new(module_id: &str) -> Self {
46        Self {
47            module_id: module_id.to_string(),
48            current_tab: TabMode::Lesson,
49            exercise_index: 0,
50            submitted: false,
51        }
52    }
53
54    /// Switch to a new tab, resetting exercise state
55    pub fn switch_tab(&mut self, tab: TabMode) {
56        self.current_tab = tab;
57        self.exercise_index = 0;
58        self.submitted = false;
59    }
60
61    /// Reset exercise state without changing tab
62    pub fn reset_exercise(&mut self) {
63        self.exercise_index = 0;
64        self.submitted = false;
65    }
66}
67
68/// Tracks which era is currently focused (expanded)
69#[derive(Debug, Clone, Default)]
70pub struct FocusState {
71    /// The currently focused era (None = no focus, all eras visible)
72    pub focused_era: Option<String>,
73    /// The currently expanded module within the focused era
74    pub expanded_module: Option<(String, String)>, // (era_id, module_id)
75}
76
77impl FocusState {
78    pub fn new() -> Self {
79        Self::default()
80    }
81
82    /// Focus on a specific era
83    pub fn focus_era(&mut self, era_id: &str) {
84        self.focused_era = Some(era_id.to_string());
85    }
86
87    /// Expand a module within an era
88    pub fn expand_module(&mut self, era_id: &str, module_id: &str) {
89        self.focused_era = Some(era_id.to_string());
90        self.expanded_module = Some((era_id.to_string(), module_id.to_string()));
91    }
92
93    /// Collapse the current module (but keep era focused)
94    pub fn collapse_module(&mut self) {
95        self.expanded_module = None;
96    }
97
98    /// Unfocus completely (show all eras)
99    pub fn unfocus(&mut self) {
100        self.focused_era = None;
101        self.expanded_module = None;
102    }
103
104    /// Check if a specific era is visible (either focused or no focus)
105    pub fn is_era_visible(&self, era_id: &str) -> bool {
106        match &self.focused_era {
107            None => true,
108            Some(focused) => focused == era_id,
109        }
110    }
111
112    /// Check if a specific module is expanded
113    pub fn is_module_expanded(&self, era_id: &str, module_id: &str) -> bool {
114        match &self.expanded_module {
115            None => false,
116            Some((e, m)) => e == era_id && m == module_id,
117        }
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124
125    #[test]
126    fn test_tab_modes_all_four_exist() {
127        let tabs = TabMode::all();
128        assert_eq!(tabs.len(), 4);
129        assert_eq!(tabs[0], TabMode::Lesson);
130        assert_eq!(tabs[1], TabMode::Examples);
131        assert_eq!(tabs[2], TabMode::Practice);
132        assert_eq!(tabs[3], TabMode::Test);
133    }
134
135    #[test]
136    fn test_initial_tab_is_lesson() {
137        let state = ModuleTabState::new("test-module");
138        assert_eq!(state.current_tab, TabMode::Lesson);
139    }
140
141    #[test]
142    fn test_tab_switch_resets_exercise_index() {
143        let mut state = ModuleTabState::new("test-module");
144        state.exercise_index = 5;
145        state.submitted = true;
146
147        state.switch_tab(TabMode::Practice);
148
149        assert_eq!(state.current_tab, TabMode::Practice);
150        assert_eq!(state.exercise_index, 0);
151        assert!(!state.submitted);
152    }
153
154    #[test]
155    fn test_focus_state_toggles_era() {
156        let mut focus = FocusState::new();
157        assert!(focus.focused_era.is_none());
158
159        focus.focus_era("first-steps");
160        assert_eq!(focus.focused_era, Some("first-steps".to_string()));
161
162        focus.unfocus();
163        assert!(focus.focused_era.is_none());
164    }
165
166    #[test]
167    fn test_is_era_visible_when_focused() {
168        let mut focus = FocusState::new();
169
170        // No focus = all eras visible
171        assert!(focus.is_era_visible("first-steps"));
172        assert!(focus.is_era_visible("mastery"));
173
174        // Focus on one era = only that era visible
175        focus.focus_era("first-steps");
176        assert!(focus.is_era_visible("first-steps"));
177        assert!(!focus.is_era_visible("mastery"));
178    }
179
180    #[test]
181    fn test_module_expansion() {
182        let mut focus = FocusState::new();
183
184        focus.expand_module("first-steps", "introduction");
185        assert!(focus.is_module_expanded("first-steps", "introduction"));
186        assert!(!focus.is_module_expanded("first-steps", "syllogistic"));
187
188        focus.collapse_module();
189        assert!(!focus.is_module_expanded("first-steps", "introduction"));
190        // Era should still be focused
191        assert!(focus.is_era_visible("first-steps"));
192    }
193}