mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-31 13:56:54 -06:00
**A styles and variables behaviour suite written from the public documentation, and fixes for every divergence it found.** Each documented rule (what a style holds, creating from a selection, applying, editing, detaching, deleting a used style, organising by folders and order, descriptions; variable types, modes and the default mode, aliases, scopes, mode inheritance, bindings by field and type, deleting a bound variable, code syntax, number variables in pixels) is a table-driven case with a one-line derivation; where the documentation is silent the case says so and pins the behaviour.
**Fixes (10 of the suite's 18 tests failed on the old code, counting the missing settings below)**
- **Editing a styled property unlinks that style**: a fill, stroke, effect or layout grid list edited away from its style (through `node.set`, a paint row, an added fill, or a variable bound to it) drops the link, as text chars already did. Before, the layer kept a stale link and the next style edit silently overwrote the user's edit. Moving or renaming a layer keeps its links.
- **Applying a style replaces variables bound to what it sets** (the fill list for a paint style, the typography for a text style); other bindings stay. Before, the variable won again on the next edit.
- **Text styles go on text layers only**: applying one to a mixed selection leaves the other layers alone (they used to get a stray text style link); a selection without text is refused.
- **Deleting a bound variable unbinds the layers using it**: they keep the value they show; variables aliasing it take its values per mode (docs silent on aliases). Before, the binding dangled. `variable.deleteCollection` now does the same for aliases from other collections into it.
- **Bindings are type-checked**: a variable of a type the field doesn't take (a colour on `width`, a number on `visible`) is refused; scopes still only filter pickers. Number variables can fill text content (shown as text).
- **Aliases are checked when a variable is created** (`variable.create {value: {alias}}` took any name, of any type).
- **Style names** are trimmed per `/` folder part; empty names are refused.
**Missing documented settings, added**
- **Organising styles**: `style.move {styles, before?, folder?}` reorders and moves into a folder (renaming the style to match), `style.duplicate` puts "<name> copy" right below the original, `style.renameFolder` renames a folder's styles. The page's **Local styles** list (nothing selected) is now grouped by type and folder with swatches and text sizes: click opens the style editor, right-click offers Edit style / Duplicate / Delete, a row dragged by its grip moves (into the folder it lands in), right-click on a folder renames it.
- **Modes**: Duplicate mode (`variable.addMode {from}`), Set as default and Move column left/right (`variable.moveMode {collection, mode, to}`); layers on Auto follow a new default, layers set to a mode keep theirs.
- **Code syntax** per platform (Web, iOS, Android): `variable.update {codeSyntax}`, three fields in Edit variable, carried by the variables JSON (import and export); generated CSS and the CSS variables export use the Web name (`var(--x)` or `--x`).
- **Number variable scopes**: font weight, paragraph spacing, paragraph indent and text content (and `fontWeight`, `paragraphSpacing`, `paragraphIndent` bind with their scopes).
New strings go through `tr(...)` with `en.toml` / `es.toml` entries, new commands have Spanish labels; the new rows, fields and menus have accessible names. Each edit stays one undo step.
168 lines
7.7 KiB
Rust
168 lines
7.7 KiB
Rust
//! Styles, variables, collections and comments created by several people at once: ids from each
|
|
//! creator's space, checked by the server, so concurrent creations never overwrite each other.
|
|
|
|
use figcraft_doc::ids::{STYLE_ID_PREFIX, id_space};
|
|
use figcraft_doc::{Collection, Comment, Document, Message, Mode, Style, StyleKind, Variable, VariableType};
|
|
|
|
use crate::change::{Change, Entry, Json};
|
|
use crate::message::{ClientMsg, ServerMsg};
|
|
use crate::sim::Sim;
|
|
use crate::{Client, Limits, NoRecompute, Outgoing, Server};
|
|
|
|
fn style(id: String, name: &str) -> Style {
|
|
Style { id, name: name.into(), description: String::new(), kind: StyleKind::Paint { paints: Vec::new() } }
|
|
}
|
|
|
|
fn comment(id: u64, doc: &Document) -> Comment {
|
|
let page = doc.first_page().unwrap();
|
|
Comment { id, page, x: 1.0, y: 2.0, anchor: None, first: Message::new("A", "hi", 0), resolved: false, replies: Vec::new(), read_by: None }
|
|
}
|
|
|
|
fn join(server: &mut Server) -> Client {
|
|
let ClientMsg::Hello(h) = Client::hello("f", "", "Ada", "#FF8800") else { panic!("not a hello") };
|
|
let (_, out) = server.join(h).unwrap();
|
|
let Some(Outgoing::To(_, welcome)) = out.into_iter().next() else { panic!("no welcome") };
|
|
Client::join(welcome).unwrap()
|
|
}
|
|
|
|
/// Send `changes` from client `from` as batch `batch_id`; the server's answer to it.
|
|
fn send(server: &mut Server, from: u32, batch_id: u64, changes: Vec<Change>) -> ServerMsg {
|
|
let out = server.receive(from, ClientMsg::Changes { base_seq: server.seq(), batch_id, changes });
|
|
match out.into_iter().next() {
|
|
Some(Outgoing::All(m) | Outgoing::To(_, m)) => m,
|
|
other => panic!("unexpected {other:?}"),
|
|
}
|
|
}
|
|
|
|
fn put_style(id: String) -> Change {
|
|
let value = Json::of(&style(id.clone(), "Forged")).unwrap();
|
|
Change::Put { entry: Entry::Style(id), value }
|
|
}
|
|
|
|
#[test]
|
|
fn new_entries_must_come_from_the_senders_space() {
|
|
let mut base = Document::new("Shared");
|
|
base.styles.push(style("S:1".into(), "Old"));
|
|
let mut server = Server::new(base, "", Limits::default());
|
|
let mut a = join(&mut server);
|
|
let b = join(&mut server);
|
|
// A style made in a session takes its id from its creator's space.
|
|
let mut doc = a.document().clone();
|
|
let id = doc.new_style_id();
|
|
assert_eq!(id_space(&id, STYLE_ID_PREFIX), a.id(), "{id}");
|
|
doc.styles.push(style(id.clone(), "Brand"));
|
|
let msg = a.edit(&doc).unwrap().unwrap();
|
|
assert!(matches!(server.receive(a.id(), msg).first(), Some(Outgoing::All(ServerMsg::Changes(_)))));
|
|
// Someone else can change it (last writer wins)…
|
|
assert!(matches!(send(&mut server, b.id(), 1, vec![put_style(id.clone())]), ServerMsg::Changes(_)));
|
|
// …but not make a new one in another person's space.
|
|
let theirs = format!("{STYLE_ID_PREFIX}{}", (u64::from(a.id()) << 32) | 999);
|
|
let ServerMsg::Reject { reason, .. } = send(&mut server, b.id(), 2, vec![put_style(theirs)]) else { panic!("accepted") };
|
|
assert!(reason.contains("id space"), "{reason}");
|
|
// Ids from before the session (or of other forms, such as library copies) are anyone's.
|
|
for (k, old) in ["S:77", "library:S:5"].into_iter().enumerate() {
|
|
assert!(matches!(send(&mut server, b.id(), 3 + k as u64, vec![put_style(old.into())]), ServerMsg::Changes(_)), "{old}");
|
|
}
|
|
// A removed entry can be put back by anyone (undo).
|
|
assert!(matches!(send(&mut server, a.id(), 100, vec![Change::Remove { entry: Entry::Style(id.clone()) }]), ServerMsg::Changes(_)));
|
|
assert!(matches!(send(&mut server, b.id(), 10, vec![put_style(id.clone())]), ServerMsg::Changes(_)));
|
|
// The whole-list fallback is checked the same way.
|
|
let mut list = server.document().styles.clone();
|
|
list.push(style(format!("{STYLE_ID_PREFIX}{}", (u64::from(a.id()) << 32) | 1000), "Sneaky"));
|
|
let set = Change::SetDoc { prop: crate::DocProp::Styles, value: Json::of(&list).unwrap() };
|
|
assert!(matches!(send(&mut server, b.id(), 11, vec![set]), ServerMsg::Reject { .. }));
|
|
// Comments too.
|
|
let forged = comment((u64::from(a.id()) << 32) | 5, server.document());
|
|
let put = Change::Put { entry: Entry::Comment(forged.id), value: Json::of(&forged).unwrap() };
|
|
assert!(matches!(send(&mut server, b.id(), 12, vec![put]), ServerMsg::Reject { .. }));
|
|
// Removed entries are part of the saved state.
|
|
let state = server.state().unwrap();
|
|
assert!(state.removed.is_empty(), "put back since");
|
|
let restored = Server::restore(state.clone(), "", Limits::default()).unwrap();
|
|
assert_eq!(restored.state().unwrap(), state);
|
|
}
|
|
|
|
#[test]
|
|
fn client_ids_skip_spaces_used_by_styles_and_comments() {
|
|
let mut d = Document::new("a");
|
|
d.styles.push(style(format!("{STYLE_ID_PREFIX}{}", (4u64 << 32) | 1), "From session 4"));
|
|
let c = comment((9u64 << 32) | 1, &d);
|
|
d.comments.push(c);
|
|
let mut server = Server::new(d, "", Limits::default());
|
|
assert_eq!(join(&mut server).id(), 10);
|
|
}
|
|
|
|
/// Each client makes `rounds` styles, collections (with an extra mode), variables and comments
|
|
/// before seeing anyone else's.
|
|
fn create_everything(doc: &mut Document, who: usize, round: usize) {
|
|
let name = format!("p{who} r{round}");
|
|
let id = doc.new_style_id();
|
|
doc.styles.push(style(id, &name));
|
|
let cid = doc.new_collection_id();
|
|
doc.variables.collections.push(Collection {
|
|
id: cid.clone(),
|
|
name: name.clone(),
|
|
modes: vec![Mode { id: format!("{cid}/mode1"), name: "Light".into() }],
|
|
extends: None,
|
|
});
|
|
if let Some(m) = doc.new_mode_id(&cid)
|
|
&& let Some(c) = doc.variables.collections.iter_mut().find(|c| c.id == cid)
|
|
{
|
|
c.modes.push(Mode { id: m, name: "Dark".into() });
|
|
}
|
|
let vid = doc.new_variable_id();
|
|
doc.variables.variables.push(Variable {
|
|
id: vid,
|
|
name,
|
|
collection: cid,
|
|
kind: VariableType::Number,
|
|
values: Default::default(),
|
|
description: String::new(),
|
|
scopes: None,
|
|
hidden_from_publishing: false,
|
|
extends: None,
|
|
overrides: Default::default(),
|
|
code_syntax: Default::default(),
|
|
});
|
|
let k = doc.new_comment_id();
|
|
let c = comment(k, doc);
|
|
doc.comments.push(c);
|
|
}
|
|
|
|
#[test]
|
|
fn concurrent_creations_all_survive() {
|
|
for seed in 0..20 {
|
|
let n = 2 + (seed as usize % 4);
|
|
let rounds = 3;
|
|
let mut sim = Sim::new(Document::new("Shared"), n, seed, 12).unwrap();
|
|
for round in 0..rounds {
|
|
// Everyone creates before anything is delivered: the worst case for colliding ids.
|
|
for i in 0..n {
|
|
let mut doc = sim.document(i).unwrap().clone();
|
|
create_everything(&mut doc, i, round);
|
|
assert!(sim.edit(i, &doc).unwrap());
|
|
}
|
|
for _ in 0..sim.rng().below(6) {
|
|
sim.step(&NoRecompute).unwrap();
|
|
}
|
|
}
|
|
sim.settle(&NoRecompute, 1_000_000).unwrap();
|
|
sim.converged().unwrap_or_else(|e| panic!("seed {seed}: {e}"));
|
|
assert_eq!(sim.rejected, 0, "seed {seed}: nothing is refused");
|
|
let d = sim.server.document();
|
|
let made = n * rounds;
|
|
assert_eq!(d.styles.len(), made, "seed {seed}: every style survives");
|
|
assert_eq!(d.variables.collections.len(), made, "seed {seed}");
|
|
assert!(d.variables.collections.iter().all(|c| c.modes.len() == 2), "seed {seed}: both modes of each");
|
|
assert_eq!(d.variables.variables.len(), made, "seed {seed}");
|
|
assert_eq!(d.comments.len(), made, "seed {seed}");
|
|
for who in 0..n {
|
|
for round in 0..rounds {
|
|
let name = format!("p{who} r{round}");
|
|
assert!(d.styles.iter().any(|s| s.name == name), "seed {seed}: {name}'s style");
|
|
assert!(d.variables.variables.iter().any(|v| v.name == name), "seed {seed}: {name}'s variable");
|
|
}
|
|
}
|
|
}
|
|
}
|