Files
figcraft/crates/engine/src/proto_present_tests.rs
T
Filip StrajnarandClaude Opus 5.5 7f62fc1416 M5: proto.settings refuses an unknown device and answers the settings (#2027)
`proto.settings` took any string as the device ("Pixel Nonexistent", "iphone 16") and saved it in
the file: the presenter then drew no device frame while its picker showed the name as if it were
one. An unknown colour was already refused with the device's colours, but a typo in the device
went unnoticed, and an agent couldn't read the settings back (`proto.settings {}` answered
`{ok: true}`, and neither `inspect_document` nor `get_node` shows a page's prototype settings).

An unknown name, or a value that isn't a string, is now refused with every name the picker offers
("None", "Custom size" and the devices), and nothing changes. A known name is taken in any case and
with spaces around it, and stored as the picker spells it (`known_device`). Every call answers the
page's settings as they are after it (`settings_json`): device, background, rotation, the colour
shown, the screen as presented (null without a device), the custom size and responsive, beside the
`ok: true` it answered before; `proto.settings {}` reads them.

Test: `proto_present_tests::unknown_devices_are_refused_and_settings_read_back`. Docs: mcp.md,
prototyping-parity.md, ROADMAP.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LvG6E2LxvJ6MuFmiLA6Zqm
2026-11-06 12:44:01 +01:00

128 lines
7.6 KiB
Rust

//! Prototype presentation settings through the session: the device's rotation (saved in the
//! file), the presenter's scale options and explicit variable modes set back to Auto.
use figcraft_doc::NodeKind;
use figcraft_doc::proto::{DeviceRotation, PrototypeSettings};
use serde_json::json;
use crate::Session;
use crate::params::id_of;
fn settings(s: &Session) -> PrototypeSettings {
let doc = s.doc().unwrap();
match &doc.node(s.page().unwrap()).unwrap().kind {
NodeKind::Page { prototype, .. } => prototype.clone(),
_ => panic!("not a page"),
}
}
#[test]
fn device_rotation_turns_phones_and_tablets_and_is_saved() {
let mut s = Session::new();
s.execute("proto.settings", &json!({"device": "iPhone 16"})).unwrap();
assert_eq!(settings(&s).rotation, DeviceRotation::Portrait);
assert_eq!(settings(&s).device().map(|d| (d.width, d.height)), Some((393.0, 852.0)));
// Landscape swaps the screen's sides.
s.execute("proto.settings", &json!({"rotation": "landscape"})).unwrap();
assert!(settings(&s).landscape());
assert_eq!(settings(&s).device().map(|d| (d.width, d.height)), Some((852.0, 393.0)));
// Undoable, and degrees work too.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(settings(&s).rotation, DeviceRotation::Portrait);
s.execute("proto.settings", &json!({"rotation": 90})).unwrap();
assert!(settings(&s).landscape());
// Laptops and desktops don't turn: the rotation is kept but doesn't apply.
s.execute("proto.settings", &json!({"device": "MacBook Air"})).unwrap();
assert_eq!(settings(&s).rotation, DeviceRotation::Landscape);
assert!(!settings(&s).landscape());
assert_eq!(settings(&s).device().map(|d| (d.width, d.height)), Some((1280.0, 832.0)));
// Junk is refused and changes nothing.
for bad in [json!("sideways"), json!(45), json!(true), json!({}), json!(f64::MAX)] {
assert!(s.execute("proto.settings", &json!({"rotation": bad})).is_err(), "{bad}");
}
assert_eq!(settings(&s).rotation, DeviceRotation::Landscape);
// Saved in the file and read back; files without it open in portrait.
s.execute("proto.settings", &json!({"device": "iPad mini"})).unwrap();
let bytes = figcraft_format::save_bytes(s.doc().unwrap(), None).unwrap();
let (doc, _) = figcraft_format::load_bytes(&bytes).unwrap();
let NodeKind::Page { prototype, .. } = &doc.node(doc.pages[0]).unwrap().kind else { panic!("not a page") };
assert_eq!(prototype.device().map(|d| (d.width, d.height)), Some((1133.0, 744.0)));
let old: PrototypeSettings = serde_json::from_value(json!({"device": "iPhone 16"})).unwrap();
assert_eq!(old.rotation, DeviceRotation::Portrait);
}
#[test]
fn present_options_ask_the_ui_and_check_their_values() {
let mut s = Session::new();
s.ui_requests.clear();
s.execute("proto.presentOptions", &json!({"scale": "fitWidth", "deviceFrame": false})).unwrap();
let r = s.ui_requests.last().unwrap();
assert_eq!((r.kind.as_str(), &r.data), ("present.options", &json!({"scale": "fitWidth", "deviceFrame": false})));
for scale in crate::cmd::proto::PRESENT_SCALES {
s.execute("proto.presentOptions", &json!({"scale": scale})).unwrap();
}
assert!(s.execute("proto.presentOptions", &json!({"scale": "huge"})).is_err());
assert!(s.execute("proto.presentOptions", &json!({"deviceFrame": "yes"})).is_err());
assert!(s.execute("proto.presentOptions", &json!({})).is_err(), "nothing to set");
// It's a viewing option: the document doesn't change.
assert!(!s.ds().unwrap().is_dirty());
}
#[test]
fn explicit_variable_modes_go_back_to_auto() {
let mut s = Session::new();
let id = |v: serde_json::Value| id_of(&v["id"]).unwrap();
let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap());
let r = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10, "parent": f})).unwrap());
s.execute("variable.createCollection", &json!({"name": "Tokens", "modes": ["Light", "Dark"]})).unwrap();
s.execute("variable.create", &json!({"collection": "Tokens", "name": "size", "type": "number", "value": 20})).unwrap();
s.execute("variable.set", &json!({"variable": "size", "mode": "Dark", "value": 40})).unwrap();
s.execute("variable.bind", &json!({"ids": [r], "field": "width", "variable": "size"})).unwrap();
let width = |s: &Session| s.doc().unwrap().node(r).unwrap().width;
s.execute("variable.setMode", &json!({"ids": [f], "collection": "Tokens", "mode": "Dark"})).unwrap();
assert_eq!(width(&s), 40.0);
// Auto: the frame inherits the page's mode again, and the layer follows.
s.execute("variable.setMode", &json!({"ids": [f], "collection": "Tokens", "mode": null})).unwrap();
assert!(s.doc().unwrap().node(f).unwrap().var_modes.is_empty());
assert_eq!(width(&s), 20.0);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(width(&s), 40.0);
// The page set to Dark: Auto inherits it.
let page = s.page().unwrap();
s.execute("variable.setMode", &json!({"ids": [page], "collection": "Tokens", "mode": "Dark"})).unwrap();
s.execute("variable.setMode", &json!({"ids": [f], "collection": "Tokens", "mode": "Light"})).unwrap();
assert_eq!(width(&s), 20.0);
s.execute("variable.setMode", &json!({"ids": [f], "collection": "Tokens", "mode": null})).unwrap();
assert_eq!(width(&s), 40.0);
assert!(s.execute("variable.setMode", &json!({"ids": [f], "collection": "Tokens", "mode": "Dusk"})).is_err());
assert!(s.execute("variable.setMode", &json!({"ids": [f], "collection": "Tokens"})).is_err(), "a mode, or null for Auto");
}
/// #2027: an unknown device is refused with the devices to pick from, as an unknown colour is, and
/// nothing changes; a known one is taken whatever its case and stored as the picker names it; the
/// settings read back from `proto.settings` without parameters.
#[test]
fn unknown_devices_are_refused_and_settings_read_back() {
let mut s = Session::new();
let before = settings(&s);
for junk in [json!("Pixel Nonexistent"), json!(""), json!(42), json!(["iPhone 16"])] {
let err = s.execute("proto.settings", &json!({"device": junk, "responsive": true})).unwrap_err().to_string();
assert!(err.contains("unknown device") && err.contains("iPhone 17|") && err.contains("Custom size"), "{junk}: {err}");
assert_eq!(settings(&s), before, "{junk}: nothing changes");
}
let set = s.execute("proto.settings", &json!({"device": " iphone 16 ", "color": "black"})).unwrap();
assert_eq!(settings(&s).device, "iPhone 16", "stored as the picker names it");
assert_eq!((&set["device"], &set["color"]), (&json!("iPhone 16"), &json!("Black")));
let read = s.execute("proto.settings", &json!({})).unwrap();
assert_eq!(read, set, "a call without parameters reads the settings");
assert_eq!((&read["ok"], &read["rotation"], &read["responsive"]), (&json!(true), &json!("portrait"), &json!(false)));
assert_eq!(read["screen"], json!({"width": 393.0, "height": 852.0}));
assert!(read["background"].as_str().is_some_and(|c| c.starts_with('#')), "{read}");
// "None" and "Custom size" in any case; no device has no screen or colour.
s.execute("proto.settings", &json!({"device": "custom SIZE", "width": 800, "height": 600})).unwrap();
assert_eq!(settings(&s).device, PrototypeSettings::CUSTOM_DEVICE);
let none = s.execute("proto.settings", &json!({"device": "none"})).unwrap();
assert_eq!((&none["device"], &none["screen"], &none["color"]), (&json!("None"), &json!(null), &json!(null)));
assert_eq!(none["customSize"], json!({"width": 800.0, "height": 600.0}));
}