Skip to main content

xkcd_1975/
lib.rs

1use std::collections::HashMap;
2
3use serde_derive::{Deserialize, Serialize};
4
5/// An identifier for menus in the graph.
6#[derive(Debug, PartialEq, Eq, Hash, Clone, Deserialize, Serialize)]
7#[serde(transparent)]
8pub struct MenuId(String);
9
10pub type Graph = HashMap<MenuId, Menu>;
11
12#[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
13pub struct Data {
14    pub root: Root,
15    pub graph: Graph,
16}
17
18impl Data {
19    /// The source data, extracted from <https://xkcd.com/s/f9dfe4.js>.
20    const JSON: &'static str = include_str!("data.json");
21
22    /// Load the data from source.
23    pub fn load() -> Self {
24        serde_json::from_str(Self::JSON).unwrap()
25    }
26}
27
28#[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
29pub struct Root {
30    #[serde(rename = "State")]
31    pub state: State,
32    #[serde(rename = "Menu")]
33    pub menu: Menu,
34}
35
36#[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
37pub struct State {
38    #[serde(rename = "Tags")]
39    tags: HashMap<MenuId, String>,
40}
41
42impl State {
43    pub fn update(&mut self, action: &Action) {
44        // Explicitly set tags before unsetting; this is the same order as in the original source
45        self.tags.extend(action.set_tags.clone());
46        self.tags.retain(|k, _| !action.unset_tags.contains(k));
47        if !action.set_tags.is_empty() || !action.unset_tags.is_empty() {
48            eprintln!(
49                "Updated tags: {:#?}",
50                self.tags.keys().map(|tag| &tag.0).collect::<Vec<_>>()
51            );
52        }
53    }
54}
55
56#[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize, Default)]
57pub struct Action {
58    #[serde(rename = "setTags")]
59    set_tags: HashMap<MenuId, String>,
60    #[serde(rename = "unsetTags")]
61    unset_tags: Vec<MenuId>,
62}
63
64#[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
65pub struct MenuItem {
66    /// Unused.
67    icon: Option<String>,
68    /// The string shown on the menu item.
69    pub label: String,
70    /// Whether the menu item should be shown.
71    pub display: Conditional,
72    /// Whether the menu item should be clickable / not disabled.
73    pub active: Conditional,
74    /// What should be done when the user hovers and/or clicks on the menu item.
75    pub reaction: Reaction,
76}
77
78#[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
79#[serde(tag = "tag")]
80pub enum Conditional {
81    Always,
82    TagSet { contents: MenuId },
83    TagUnset { contents: MenuId },
84    TLNot { contents: Box<Conditional> },
85    TLAnd { contents: Vec<Conditional> },
86    TLOr { contents: Vec<Conditional> },
87}
88
89impl Conditional {
90    pub fn evaluate(&self, state: &State) -> bool {
91        match self {
92            Self::Always => true,
93            Self::TagSet { contents } => state.tags.contains_key(contents),
94            Self::TagUnset { contents } => !state.tags.contains_key(contents),
95            Self::TLNot { contents } => !contents.evaluate(state),
96            Self::TLAnd { contents } => contents.iter().all(|item| item.evaluate(state)),
97            Self::TLOr { contents } => contents.iter().any(|item| item.evaluate(state)),
98        }
99    }
100}
101
102#[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
103#[serde(tag = "tag")]
104pub enum Reaction {
105    SubMenu {
106        #[serde(rename = "onAction")]
107        on_hover: Action,
108        #[serde(flatten)]
109        submenu: SubMenu,
110    },
111    #[serde(rename = "Action")]
112    ClickAction {
113        #[serde(rename = "onAction")]
114        on_action: Action,
115        act: Option<ClickAction>,
116    },
117}
118
119#[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
120pub struct SubMenu {
121    #[serde(rename = "subMenu")]
122    sub_menu: MenuId,
123    #[serde(rename = "subIdPostfix")]
124    sub_id_postfix: Option<MenuId>,
125}
126
127impl SubMenu {
128    pub fn id(&self, state: &State) -> MenuId {
129        if let Some(postfix_id) = &self.sub_id_postfix {
130            if let Some(postfix) = state.tags.get(postfix_id) {
131                MenuId(format!("{}{}", self.sub_menu.0, postfix))
132            } else {
133                // Fall back to no postfix if no tag with that ID was set.
134                self.sub_menu.clone()
135            }
136        } else {
137            self.sub_menu.clone()
138        }
139    }
140}
141
142#[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
143#[serde(tag = "tag")]
144pub enum ClickAction {
145    ColapseMenu,
146    Nav {
147        url: String,
148    },
149    Download {
150        url: String,
151        filename: String,
152    },
153    JSCall {
154        #[serde(rename = "jsCall")]
155        js_call: String,
156    },
157}
158
159#[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
160pub struct Menu {
161    pub id: MenuId,
162    #[serde(rename = "onLeave")]
163    pub on_leave: Action,
164    pub entries: Vec<MenuItem>,
165}
166
167/// If any of these tests fail, then we may have to rewrite things.
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    #[test]
173    fn everything_is_parsed() {
174        let expected: serde_json::Value = serde_json::from_str(Data::JSON).unwrap();
175
176        let parsed = Data::load();
177        let after_roundtrip = serde_json::to_value(parsed).unwrap();
178
179        assert_eq!(expected, after_roundtrip);
180    }
181
182    #[test]
183    fn no_icons() {
184        let data = Data::load();
185
186        assert!(data
187            .graph
188            .values()
189            .flat_map(|menu| &menu.entries)
190            .all(|entry| entry.icon.is_none()));
191    }
192
193    #[test]
194    fn root_menu_is_same_as_in_graph() {
195        let data = Data::load();
196
197        assert_eq!(data.root.menu, data.graph[&data.root.menu.id]);
198    }
199
200    #[test]
201    fn root_menu_has_no_on_leave() {
202        let data = Data::load();
203
204        assert_eq!(data.root.menu.on_leave, Action::default());
205    }
206
207    #[test]
208    fn root_menu_entries_has_no_active() {
209        let data = Data::load();
210
211        assert!(data
212            .root
213            .menu
214            .entries
215            .iter()
216            .all(|entry| entry.active == Conditional::Always));
217    }
218
219    #[test]
220    fn root_menu_entries_is_submenu_and_has_no_hover() {
221        let data = Data::load();
222
223        assert!(data.root.menu.entries.iter().all(|entry| matches!(
224            &entry.reaction,
225            Reaction::SubMenu {
226                on_hover,
227                ..
228            } if on_hover == &Action::default()
229        )));
230    }
231}