mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-30 13:26:54 -06:00
* M12: board timer and voting A countdown everyone on a board sees (board.timer, board.timerState), synced as its end time or the time left while paused, and voting sessions (board.vote, board.voteResults): votes per person, on everything, stickies or the selection; clicks vote, your badge takes a vote back; your own votes show while it runs, everyone's with a ranked list once it ends. Timer and vote sync as document properties and ballots as one entry per voter; neither is an undo step. A pill at the top right of the board, new Timer, Pause and Vote icons, labels and strings in every catalog, docs. * M12: board timer and voting on egui 0.37 Id::new is deprecated in egui 0.37: the timer and voting area and its memory ids use Id::unique. * M12: board.vote's start and ballot changes in functions of their own (Sonar)
325 lines
16 KiB
Rust
325 lines
16 KiB
Rust
//! A board's timer and votes: the commands, what undo leaves alone, saving, live sessions, clicks
|
|
//! that vote, and junk parameters.
|
|
|
|
use figcraft_doc::{BoardTimer, NodeId, VoteScope};
|
|
use serde_json::{Value, json};
|
|
|
|
use crate::board_tests::{board, id};
|
|
use crate::cmd::facilitate::{clock, timer_after, timer_state};
|
|
use crate::collab_live_tests::Room;
|
|
use crate::tools::{PointerEvent, PointerKind};
|
|
use crate::{EngineError, Session};
|
|
|
|
const MIN: u64 = 60_000;
|
|
|
|
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
|
|
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"))
|
|
}
|
|
|
|
fn sticky(s: &mut Session, x: f64, text: &str) -> NodeId {
|
|
id(&ex(s, "board.sticky", json!({"x": x, "y": 0, "text": text})), "id")
|
|
}
|
|
|
|
#[test]
|
|
fn the_timer_counts_from_its_end_time() {
|
|
let now = 1_000_000;
|
|
let none = json!({});
|
|
let t = timer_after("t", None, "start", &json!({"minutes": 2}), now).unwrap();
|
|
assert_eq!(t, Some(BoardTimer::Running { ends: now + 2 * MIN }));
|
|
assert_eq!(timer_state(t, now + MIN), "running");
|
|
assert_eq!(timer_state(t, now + 3 * MIN), "done", "run out: done until stopped");
|
|
// Paused half way: the time left stays put however long the pause.
|
|
let p = timer_after("t", t, "pause", &none, now + MIN).unwrap();
|
|
assert_eq!(p, Some(BoardTimer::Paused { left: MIN }));
|
|
assert_eq!(p.unwrap().left(now + 100 * MIN), MIN);
|
|
// A minute more while paused, then on again.
|
|
let p = timer_after("t", p, "add", &none, now + 5 * MIN).unwrap();
|
|
assert_eq!(p, Some(BoardTimer::Paused { left: 2 * MIN }));
|
|
let r = timer_after("t", p, "resume", &none, now + 10 * MIN).unwrap();
|
|
assert_eq!(r, Some(BoardTimer::Running { ends: now + 12 * MIN }));
|
|
// Adding to a timer that ran out starts from now.
|
|
let r = timer_after("t", r, "add", &json!({"minutes": 0.5}), now + 20 * MIN).unwrap();
|
|
assert_eq!(r, Some(BoardTimer::Running { ends: now + 20 * MIN + MIN / 2 }));
|
|
assert_eq!(timer_after("t", r, "stop", &none, now).unwrap(), None);
|
|
// What can't be done says so.
|
|
assert!(timer_after("t", None, "pause", &none, now).is_err());
|
|
assert!(timer_after("t", None, "add", &none, now).is_err());
|
|
assert!(timer_after("t", r, "resume", &none, now).is_err());
|
|
assert!(timer_after("t", Some(BoardTimer::Running { ends: now }), "pause", &none, now).is_err(), "nothing left to pause");
|
|
for m in [json!(0), json!(-1), json!(1441), json!("NaN"), json!(1e308), json!("x"), json!([1])] {
|
|
assert!(timer_after("t", None, "start", &json!({"minutes": m}), now).is_err(), "{m}");
|
|
}
|
|
// At most a day, also by adding.
|
|
let long = timer_after("t", None, "start", &json!({"minutes": 1440}), now).unwrap();
|
|
assert_eq!(timer_after("t", long, "add", &none, now).unwrap().unwrap().left(now), 1440 * MIN);
|
|
assert!(timer_after("t", None, "start", &json!({"minutes": 1}), u64::MAX).is_ok(), "no overflow");
|
|
}
|
|
|
|
#[test]
|
|
fn clocks_read_minutes_and_seconds() {
|
|
assert_eq!(clock(0), "0:00");
|
|
assert_eq!(clock(1), "0:01", "a part of a second left still shows");
|
|
assert_eq!(clock(5 * MIN), "5:00");
|
|
assert_eq!(clock(65_000), "1:05");
|
|
assert_eq!(clock(3_723_000), "1:02:03");
|
|
}
|
|
|
|
#[test]
|
|
fn timer_commands_drive_the_boards_timer() {
|
|
let mut s = board();
|
|
assert_eq!(ex(&mut s, "board.timerState", json!({}))["state"], "off");
|
|
let r = ex(&mut s, "board.timer", json!({"action": "start", "minutes": 3}));
|
|
assert_eq!(r["state"], "running");
|
|
let left = r["left"].as_u64().unwrap();
|
|
assert!(left > 3 * MIN - 5000 && left <= 3 * MIN, "{r}");
|
|
assert!(r["ends"].as_u64().is_some());
|
|
assert_eq!(ex(&mut s, "board.timer", json!({"action": "pause"}))["state"], "paused");
|
|
let r = ex(&mut s, "board.timer", json!({"action": "add"}));
|
|
assert!(r["left"].as_u64().unwrap() > 4 * MIN - 5000, "{r}");
|
|
assert_eq!(ex(&mut s, "board.timer", json!({"action": "resume"}))["state"], "running");
|
|
assert_eq!(ex(&mut s, "board.timer", json!({"action": "stop"}))["state"], "off");
|
|
assert!(s.doc().unwrap().timer.is_none());
|
|
// Not an undo step.
|
|
assert_eq!(s.undo_depth().0, 0);
|
|
assert!(matches!(s.execute("board.timer", &json!({"action": "pause"})), Err(EngineError::Disabled(..))));
|
|
assert!(s.execute("board.timer", &json!({"action": "jump"})).is_err());
|
|
// Boards only.
|
|
let mut d = Session::new();
|
|
d.execute("file.new", &json!({})).unwrap();
|
|
for (cmd, p) in [
|
|
("board.timer", json!({"action": "start"})),
|
|
("board.timerState", json!({})),
|
|
("board.vote", json!({"action": "start"})),
|
|
("board.voteResults", json!({})),
|
|
] {
|
|
assert!(matches!(d.execute(cmd, &p), Err(EngineError::Disabled(..))), "{cmd}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn votes_are_capped_hidden_until_the_end_and_ranked() {
|
|
let mut s = board();
|
|
let a = sticky(&mut s, 0.0, "Ship it");
|
|
let b = sticky(&mut s, 300.0, "Wait");
|
|
let c = sticky(&mut s, 600.0, "Rethink");
|
|
let shape = id(&ex(&mut s, "board.shape", json!({"x": 0, "y": 400})), "id");
|
|
let r = ex(&mut s, "board.vote", json!({"action": "start", "votes": 2, "on": "stickies"}));
|
|
assert_eq!((r["state"].as_str(), r["left"].as_u64(), r["on"].as_str()), (Some("running"), Some(2), Some("stickies")));
|
|
assert!(matches!(s.execute("board.vote", &json!({"action": "start"})), Err(EngineError::Disabled(..))), "one vote at a time");
|
|
let cast = |s: &mut Session, who: &str, on: NodeId| s.execute("board.vote", &json!({"action": "cast", "id": on, "voter": who}));
|
|
// Ada puts both her votes on one sticky; a third is refused, and so is a shape.
|
|
cast(&mut s, "Ada", a).unwrap();
|
|
let r = cast(&mut s, "Ada", a).unwrap();
|
|
assert_eq!((r["left"].as_u64(), r["mine"].clone()), (Some(0), json!([{"id": a.0, "votes": 2}])));
|
|
assert!(matches!(cast(&mut s, "Ada", b), Err(EngineError::Disabled(..))), "no votes left");
|
|
assert!(matches!(cast(&mut s, "Bo", shape), Err(EngineError::Disabled(..))), "stickies only");
|
|
assert!(matches!(cast(&mut s, "Bo", NodeId(99_999)), Err(EngineError::NoNode(_))));
|
|
cast(&mut s, "Bo", b).unwrap();
|
|
cast(&mut s, "Bo", c).unwrap();
|
|
cast(&mut s, "Cy", b).unwrap();
|
|
cast(&mut s, "Cy", c).unwrap();
|
|
// Cy takes one back and puts it elsewhere.
|
|
s.execute("board.vote", &json!({"action": "retract", "id": c, "voter": "Cy"})).unwrap();
|
|
assert!(s.execute("board.vote", &json!({"action": "retract", "id": c, "voter": "Cy"})).is_err(), "no vote there any more");
|
|
cast(&mut s, "Cy", a).unwrap();
|
|
// While it runs, you see your own votes only.
|
|
let r = ex(&mut s, "board.voteResults", json!({"voter": "Bo"}));
|
|
assert!(r.get("results").is_none(), "{r}");
|
|
assert_eq!(r["mine"], json!([{"id": b.0, "votes": 1}, {"id": c.0, "votes": 1}]));
|
|
assert_eq!(r["voters"], 3);
|
|
assert_eq!(s.undo_depth().0, 4, "votes are no undo steps (only the four objects)");
|
|
ex(&mut s, "board.vote", json!({"action": "end"}));
|
|
assert!(s.execute("board.vote", &json!({"action": "cast", "id": b, "voter": "Dee"})).is_err(), "ended");
|
|
let r = ex(&mut s, "board.voteResults", json!({}));
|
|
assert_eq!(r["state"], "ended");
|
|
assert_eq!(
|
|
r["results"],
|
|
json!([
|
|
{"id": a.0, "name": "Ship it", "votes": 3, "rank": 1},
|
|
{"id": b.0, "name": "Wait", "votes": 2, "rank": 2},
|
|
{"id": c.0, "name": "Rethink", "votes": 1, "rank": 3},
|
|
])
|
|
);
|
|
// A deleted object's votes no longer count.
|
|
ex(&mut s, "edit.delete", json!({"ids": [a]}));
|
|
assert_eq!(ex(&mut s, "board.voteResults", json!({}))["results"][0]["id"], b.0);
|
|
ex(&mut s, "board.vote", json!({"action": "clear"}));
|
|
assert_eq!(ex(&mut s, "board.voteResults", json!({}))["state"], "off");
|
|
assert!(s.doc().unwrap().votes.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn a_vote_on_the_selection_takes_only_those() {
|
|
let mut s = board();
|
|
let a = sticky(&mut s, 0.0, "A");
|
|
let b = sticky(&mut s, 300.0, "B");
|
|
ex(&mut s, "select.set", json!({"ids": [a]}));
|
|
let r = ex(&mut s, "board.vote", json!({"action": "start", "on": "selection"}));
|
|
assert_eq!(r["on"], json!({"selection": [a.0]}));
|
|
assert_eq!(r["votesPerPerson"], 3);
|
|
assert!(s.execute("board.vote", &json!({"action": "cast", "id": b})).is_err());
|
|
ex(&mut s, "board.vote", json!({"action": "cast", "id": a}));
|
|
// A finished vote may be replaced by a new one, which starts with no votes.
|
|
ex(&mut s, "board.vote", json!({"action": "end"}));
|
|
ex(&mut s, "board.vote", json!({"action": "start", "on": "all", "votes": 1}));
|
|
assert!(s.doc().unwrap().votes.is_empty());
|
|
assert_eq!(s.doc().unwrap().vote.as_ref().unwrap().scope, VoteScope::All);
|
|
for bad in [json!({"votes": 0}), json!({"votes": 100}), json!({"votes": 1.5}), json!({"on": "pages"}), json!({"on": "selection", "ids": []})] {
|
|
let mut p = bad.clone();
|
|
p["action"] = json!("start");
|
|
ex(&mut s, "board.vote", json!({"action": "clear"}));
|
|
assert!(s.execute("board.vote", &p).is_err(), "{p}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn undo_leaves_the_timer_and_the_vote_alone() {
|
|
let mut s = board();
|
|
let a = sticky(&mut s, 0.0, "A");
|
|
ex(&mut s, "board.timer", json!({"action": "start"}));
|
|
ex(&mut s, "board.vote", json!({"action": "start"}));
|
|
ex(&mut s, "board.vote", json!({"action": "cast", "id": a}));
|
|
let b = sticky(&mut s, 300.0, "B");
|
|
s.undo().unwrap();
|
|
s.undo().unwrap();
|
|
let doc = s.doc().unwrap();
|
|
assert!(doc.node(a).is_none() && doc.node(b).is_none(), "the stickies are undone");
|
|
assert!(doc.timer.is_some() && doc.vote.is_some() && doc.votes.len() == 1, "the timer and the vote stay");
|
|
ex(&mut s, "board.timer", json!({"action": "stop"}));
|
|
s.redo().unwrap();
|
|
assert!(s.doc().unwrap().timer.is_none(), "redo doesn't bring a stopped timer back");
|
|
assert!(s.doc().unwrap().node(a).is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn the_timer_and_votes_save_and_old_files_open() {
|
|
let mut s = board();
|
|
let a = sticky(&mut s, 0.0, "A");
|
|
ex(&mut s, "board.timer", json!({"action": "start"}));
|
|
ex(&mut s, "board.timer", json!({"action": "pause"}));
|
|
ex(&mut s, "board.vote", json!({"action": "start", "on": "stickies"}));
|
|
ex(&mut s, "board.vote", json!({"action": "cast", "id": a}));
|
|
let doc = s.doc().unwrap().clone();
|
|
let bytes = figcraft_format::save_bytes(&doc, None).unwrap();
|
|
let (back, _) = figcraft_format::load_bytes(&bytes).unwrap();
|
|
assert_eq!((back.timer, &back.vote, &back.votes), (doc.timer, &doc.vote, &doc.votes));
|
|
// A file from before: none of the fields.
|
|
let mut v = figcraft_format::to_json(&doc).unwrap();
|
|
for k in ["timer", "vote", "votes"] {
|
|
assert!(v.as_object_mut().unwrap().remove(k).is_some(), "{k} is saved");
|
|
}
|
|
let (old, _) = figcraft_format::from_json(&v.to_string()).unwrap();
|
|
assert!(old.timer.is_none() && old.vote.is_none() && old.votes.is_empty());
|
|
// Design files don't write them.
|
|
let plain = figcraft_format::to_json(&figcraft_doc::Document::new("D")).unwrap();
|
|
assert!(plain.get("timer").is_none() && plain.get("vote").is_none() && plain.get("votes").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn everyone_in_a_live_session_shares_the_timer_and_the_vote() {
|
|
let mut s = board();
|
|
let a = sticky(&mut s, 0.0, "A");
|
|
let b = sticky(&mut s, 300.0, "B");
|
|
ex(&mut s, "board.timer", json!({"action": "start", "minutes": 10}));
|
|
let mut room = Room::new(s.doc().unwrap().clone(), 2, 11);
|
|
assert!(room.s(1).doc().unwrap().timer.is_some(), "joiners get the running timer");
|
|
let t0 = room.s(0).doc().unwrap().timer;
|
|
assert_eq!(room.s(1).doc().unwrap().timer, t0);
|
|
ex(room.s(1), "board.timer", json!({"action": "pause"}));
|
|
ex(room.s(0), "board.vote", json!({"action": "start", "votes": 2}));
|
|
room.settle();
|
|
room.check();
|
|
assert!(matches!(room.s(0).doc().unwrap().timer, Some(BoardTimer::Paused { .. })), "a pause reaches everyone");
|
|
// Both vote at once: each ballot is its own entry, so neither is lost.
|
|
ex(room.s(0), "board.vote", json!({"action": "cast", "id": a}));
|
|
ex(room.s(1), "board.vote", json!({"action": "cast", "id": a}));
|
|
ex(room.s(1), "board.vote", json!({"action": "cast", "id": b}));
|
|
room.settle();
|
|
room.check();
|
|
assert_eq!(room.server.document().votes.len(), 2, "two voters, apart though both are called the same");
|
|
assert_eq!(ex(room.s(0), "board.voteResults", json!({}))["mine"], json!([{"id": a.0, "votes": 1}]));
|
|
ex(room.s(1), "board.vote", json!({"action": "end"}));
|
|
room.settle();
|
|
let r = ex(room.s(0), "board.voteResults", json!({}));
|
|
assert_eq!(r["results"], json!([{"id": a.0, "name": "A", "votes": 2, "rank": 1}, {"id": b.0, "name": "B", "votes": 1, "rank": 2}]));
|
|
// Undo in a session takes back your own edits only, not the vote.
|
|
room.s(0).undo().unwrap();
|
|
room.settle();
|
|
room.check();
|
|
assert!(room.server.document().vote.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn clicks_vote_while_a_vote_runs() {
|
|
let mut s = board();
|
|
let a = sticky(&mut s, 0.0, "A");
|
|
ex(&mut s, "select.none", json!({}));
|
|
let click = |s: &mut Session, x: f64, y: f64, alt: bool| {
|
|
let mut down = PointerEvent::at(PointerKind::Down, x, y, 1.0);
|
|
down.alt = alt;
|
|
s.pointer(&down).unwrap();
|
|
s.pointer(&PointerEvent::at(PointerKind::Up, x, y, 1.0)).unwrap();
|
|
};
|
|
// No vote: a click selects.
|
|
click(&mut s, 100.0, 100.0, false);
|
|
assert_eq!(s.selection(), vec![a]);
|
|
ex(&mut s, "select.none", json!({}));
|
|
ex(&mut s, "board.vote", json!({"action": "start"}));
|
|
click(&mut s, 100.0, 100.0, false);
|
|
click(&mut s, 50.0, 150.0, false);
|
|
assert!(s.selection().is_empty(), "votes, not selection");
|
|
assert_eq!(crate::cmd::facilitate::mine(s.doc().unwrap(), "You"), vec![(a, 2)]);
|
|
// Alt takes one back; so does a click on your badge (the sticky's top-right corner).
|
|
click(&mut s, 100.0, 100.0, true);
|
|
click(&mut s, 199.0, 1.0, false);
|
|
assert!(s.doc().unwrap().votes.is_empty());
|
|
// Empty canvas is the tool's as ever.
|
|
click(&mut s, 900.0, 900.0, false);
|
|
assert!(s.doc().unwrap().votes.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn facilitation_commands_survive_junk() {
|
|
let junk = [
|
|
json!({}),
|
|
json!(null),
|
|
json!([1]),
|
|
json!({"action": 5, "minutes": "NaN", "votes": -1, "on": [1], "id": "x", "voter": {"a": 1}}),
|
|
json!({"action": "start", "minutes": 1e308, "votes": 1e308, "on": "selection", "ids": ["x", -1]}),
|
|
json!({"action": "add", "minutes": -1e308}),
|
|
json!({"action": "cast", "id": 99_999, "voter": "\u{0}\u{7}"}),
|
|
json!({"action": "retract", "id": -1, "voter": "x".repeat(10_000)}),
|
|
json!({"action": "end"}),
|
|
json!({"action": "clear", "ids": 7}),
|
|
json!({"action": "resume", "minutes": [1]}),
|
|
];
|
|
for cmd in ["board.timer", "board.timerState", "board.vote", "board.voteResults"] {
|
|
for j in &junk {
|
|
let mut s = board();
|
|
let a = sticky(&mut s, 0.0, "A");
|
|
ex(&mut s, "board.timer", json!({"action": "start"}));
|
|
ex(&mut s, "board.vote", json!({"action": "start"}));
|
|
ex(&mut s, "board.vote", json!({"action": "cast", "id": a}));
|
|
if let Err(EngineError::Internal { cmd, msg }) = s.execute(cmd, j) {
|
|
panic!("{cmd} panicked on {j}: {msg}");
|
|
}
|
|
// A voter name is kept short.
|
|
assert!(s.doc().unwrap().votes.keys().all(|k| k.chars().count() <= 64));
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn ties_share_a_rank() {
|
|
let mut s = board();
|
|
let ids: Vec<NodeId> = (0..4).map(|i| sticky(&mut s, f64::from(i) * 300.0, "x")).collect();
|
|
ex(&mut s, "board.vote", json!({"action": "start", "votes": 2}));
|
|
for (who, on) in [("a", 0), ("a", 1), ("b", 1), ("b", 0), ("c", 2), ("c", 3), ("d", 2)] {
|
|
ex(&mut s, "board.vote", json!({"action": "cast", "id": ids[on], "voter": who}));
|
|
}
|
|
ex(&mut s, "board.vote", json!({"action": "end"}));
|
|
let r = ex(&mut s, "board.voteResults", json!({}));
|
|
let ranks: Vec<u64> = r["results"].as_array().unwrap().iter().map(|x| x["rank"].as_u64().unwrap()).collect();
|
|
assert_eq!(ranks, vec![1, 1, 1, 4], "{r}");
|
|
}
|