Merge pull request #1801 from mortezanaeemi-ops/fix-library-variables

.fig import: resolve library variables and collections by asset key (newest copy)
This commit is contained in:
BFlat
2026-11-07 12:36:34 +08:00
committed by GitHub
4 changed files with 430 additions and 21 deletions
+144
View File
@@ -48,6 +48,7 @@ const MAX_ASSET_PART: usize = 256;
/// vector networks its layers name by `vectorNetworkRef` (for [`crate::rest::import_with_networks`]).
pub(super) fn to_rest(msg: &Kv, name: &str, max_nodes: usize) -> (Value, Vec<String>, Vec<Option<VectorNetwork>>) {
let mut m = Mapper::new(msg, max_nodes);
let _canon = VarCanon::install(&m);
let doc = m.document();
// Large values are moved in, never passed through `json!`, which serialises its arguments:
// a deep copy of the whole tree, briefly holding it twice.
@@ -365,6 +366,31 @@ impl<'a> Mapper<'a> {
.cloned()
}
/// The collection a `VariableSetID` names, as one collection per library asset: a file that
/// took several updates of a library holds a copy of the collection for each version it met,
/// all with the same asset `key`. Variables and explicit modes may name any of those copies,
/// while the mode ids stay the same across them, so every copy maps to the newest one (the
/// one [`Self::variables`] keeps, with the modes of the others added). Without this, a page set
/// to a mode an older copy didn't have yet lost its mode, and the layers fell back to the
/// default mode of the newest copy. A reference by asset that names no copy by itself (no
/// version, or a version the file doesn't hold, while it holds several) still names the asset:
/// its newest live copy.
pub(super) fn collection_id(&self, v: &Kv) -> Option<String> {
let by_asset = || v.get("assetRef")?.get("key").and_then(Kv::str).and_then(VarCanon::newest_set);
match self.resource_id(v) {
Some(id) if self.nodes.contains_key(&id) => Some(self.canonical_collection(&id).unwrap_or(id)),
// The asset index can name a node a later change removed: the asset's live copy instead.
Some(id) => by_asset().or(Some(id)),
None => by_asset(),
}
}
/// The newest live copy of collection node `id`'s library asset, when it has a key.
pub(super) fn canonical_collection(&self, id: &str) -> Option<String> {
let key = self.nodes.get(id)?.get("key").and_then(Kv::str).filter(|k| !k.is_empty())?;
VarCanon::newest_set(key)
}
/// A note about what this file's mapping left out or changed. Counted, as the REST importer
/// counts its own: these notes are met once per layer, so the kinds were named once each with
/// no sign of how much of the file they covered -- a file whose every text layer lost a
@@ -384,3 +410,121 @@ impl<'a> Mapper<'a> {
self.max_nodes.saturating_sub(self.reserved)
}
}
/// A library version (`"40727:1183"`) as numbers to order by; unparsable parts rank lowest.
fn version_rank(v: &str) -> (u64, u64) {
let mut parts = v.split(':').map(|p| p.trim().parse::<u64>().unwrap_or(0));
(parts.next().unwrap_or(0), parts.next().unwrap_or(0))
}
/// Library variables, one per asset. A file that took several updates of a library keeps a copy
/// of a variable for each version it met (same `key`, another `version`), and layers and other
/// variables name them by that asset (`assetRef {key, version}`) rather than by guid. Each
/// reference goes to the newest copy: it has the values of every mode the library has now, where
/// an older one lacks the modes added since (a layer under a page set to such a mode fell back to
/// the default one). Kept per thread while a file is mapped: the paint and variable readers that
/// meet references are free functions.
#[derive(Default)]
pub(super) struct VarCanon {
by_key: HashMap<String, String>,
by_id: HashMap<String, String>,
/// Every live variable node of the file, with a key or not.
local: BTreeSet<String>,
/// The newest live copy of each library collection, by asset key. Read from the file's live
/// nodes (the last change of each guid, REMOVED ones gone), not from the asset index, which
/// keeps the first node it met for a key and version even when a later change removed it.
sets_by_key: HashMap<String, String>,
}
thread_local! {
static VAR_CANON: std::cell::RefCell<Option<VarCanon>> = const { std::cell::RefCell::new(None) };
}
/// Removes the thread's table when the mapping is done.
pub(super) struct VarCanonGuard;
impl Drop for VarCanonGuard {
/// Clears the thread's table, so a later file on this thread starts without it.
fn drop(&mut self) {
VAR_CANON.with(|c| c.borrow_mut().take());
}
}
impl VarCanon {
/// Builds the table from mapper `m`'s live nodes (newest copy per asset key, for variables and
/// for collections) and installs it for this thread until the returned guard is dropped.
fn install(m: &Mapper) -> VarCanonGuard {
let mut newest: HashMap<String, ((u64, u64), String)> = HashMap::new();
let mut copies: Vec<(String, String)> = Vec::new();
let mut local = BTreeSet::new();
let mut sets: HashMap<String, ((u64, u64), String)> = HashMap::new();
for (id, n) in &m.nodes {
let ty = n.get("type").and_then(Kv::str);
if !matches!(ty, Some("VARIABLE" | "VARIABLE_SET")) || n.get("isSoftDeleted").and_then(Kv::bool) == Some(true) {
continue;
}
if ty == Some("VARIABLE_SET") {
if let Some(key) = n.get("key").and_then(Kv::str).filter(|k| !k.is_empty()) {
let rank = version_rank(n.get("version").and_then(Kv::str).unwrap_or(""));
let e = sets.entry(key.to_string()).or_insert_with(|| (rank, id.clone()));
if rank > e.0 || (rank == e.0 && *id > e.1) {
*e = (rank, id.clone());
}
}
continue;
}
local.insert(id.clone());
let Some(key) = n.get("key").and_then(Kv::str).filter(|k| !k.is_empty()) else { continue };
let rank = version_rank(n.get("version").and_then(Kv::str).unwrap_or(""));
copies.push((id.clone(), key.to_string()));
let e = newest.entry(key.to_string()).or_insert_with(|| (rank, id.clone()));
if rank > e.0 || (rank == e.0 && *id > e.1) {
*e = (rank, id.clone());
}
}
let mut t = VarCanon::default();
for (id, key) in copies {
if let Some((_, canon)) = newest.get(&key) {
t.by_id.insert(id, canon.clone());
}
}
t.by_key = newest.into_iter().map(|(k, (_, id))| (k, id)).collect();
t.local = local;
t.sets_by_key = sets.into_iter().map(|(k, (_, id))| (k, id)).collect();
VAR_CANON.with(|c| *c.borrow_mut() = Some(t));
VarCanonGuard
}
/// The variable a reference (`{guid}` or `{assetRef}`) names, as its newest copy. A guid
/// counts when this file holds a variable by it; one from another file (a paste carries the
/// library's own guids) names nothing here, so the asset it also names is tried before it is
/// kept as it is.
pub(super) fn resolve(r: &Kv) -> Option<String> {
let by_guid = r.get("guid").and_then(guid).filter(|g| g != NO_STYLE);
VAR_CANON.with(|c| {
let c = c.borrow();
let Some(t) = c.as_ref() else { return by_guid.clone() };
if let Some(g) = by_guid.as_ref().filter(|g| t.local.contains(*g)) {
return Some(t.by_id.get(g).cloned().unwrap_or_else(|| g.clone()));
}
let by_asset = r.get("assetRef").and_then(|a| a.get("key")).and_then(Kv::str).and_then(|k| t.by_key.get(k)).cloned();
by_asset.or(by_guid)
})
}
/// The newest live copy of the library collection with asset key `key`.
pub(super) fn newest_set(key: &str) -> Option<String> {
VAR_CANON.with(|c| c.borrow().as_ref().and_then(|t| t.sets_by_key.get(key).cloned()))
}
/// Whether a table is installed on this thread (for tests: none outlives the mapping).
#[cfg(test)]
pub(crate) fn installed() -> bool {
VAR_CANON.with(|c| c.borrow().is_some())
}
/// Whether variable node `id` is an older copy another one replaces.
pub(super) fn superseded(id: &str) -> bool {
VAR_CANON.with(|c| c.borrow().as_ref().and_then(|t| t.by_id.get(id)).is_some_and(|canon| canon != id))
}
}
+42 -17
View File
@@ -259,28 +259,37 @@ impl<'a> Mapper<'a> {
};
let mut order = Vec::new();
let mut collections = Obj::new();
// Copies of one library collection (one per version the file met) become one: the newest
// keeps its modes in its order, the others add the modes it no longer has.
let mut extra: BTreeMap<String, Vec<(String, Value)>> = BTreeMap::new();
for (id, n) in of_type("VARIABLE_SET", "parentIndex") {
order.push(json!(id));
let mut modes: Vec<(&str, Value)> = n
.get("variableSetModes")
.map(Kv::list)
.unwrap_or_default()
.iter()
.filter_map(|m| {
let mode = json!({"modeId": m.get("id").and_then(guid)?, "name": m.get("name").and_then(Kv::str).unwrap_or("Mode")});
Some((m.get("sortPosition").and_then(Kv::str).unwrap_or(""), mode))
})
.collect();
modes.sort_by(|a, b| a.0.cmp(b.0));
let default = modes.first().map_or(Value::Null, |m| m.1["modeId"].clone());
let name = n.get("name").and_then(Kv::str).unwrap_or("Collection");
let modes: Vec<Value> = modes.into_iter().map(|m| m.1).collect();
collections.insert(id.clone(), json!({"id": id, "name": name, "modes": modes, "defaultModeId": default}));
let modes = sorted_modes(n);
match self.canonical_collection(id).filter(|c| c != id) {
Some(canonical) => extra.entry(canonical).or_default().extend(modes),
None => {
order.push(json!(id));
let default = modes.first().map_or(Value::Null, |m| m.1["modeId"].clone());
let name = n.get("name").and_then(Kv::str).unwrap_or("Collection");
let modes: Vec<Value> = modes.into_iter().map(|m| m.1).collect();
collections.insert(id.clone(), json!({"id": id, "name": name, "modes": modes, "defaultModeId": default}));
}
}
}
for (canonical, more) in extra {
let Some(modes) = collections.get_mut(&canonical).and_then(|c| c.get_mut("modes")).and_then(Value::as_array_mut) else { continue };
for (_, m) in more {
if !modes.iter().any(|x| x["modeId"] == m["modeId"]) {
modes.push(m);
}
}
}
let mut variable_order = Vec::new();
let mut variables = Obj::new();
for (id, n) in of_type("VARIABLE", "sortPosition") {
let Some(set) = n.get("variableSetID").and_then(|s| self.resource_id(s)).filter(|s| collections.contains_key(s)) else { continue };
if super::VarCanon::superseded(id) {
continue;
}
let Some(set) = n.get("variableSetID").and_then(|s| self.collection_id(s)).filter(|s| collections.contains_key(s)) else { continue };
let ty = n.get("variableResolvedType").and_then(Kv::str).unwrap_or("");
if !matches!(ty, "BOOLEAN" | "FLOAT" | "STRING" | "COLOR") {
continue;
@@ -306,3 +315,19 @@ impl<'a> Mapper<'a> {
.then(|| json!({"variableCollections": collections, "variables": variables, "collectionOrder": order, "variableOrder": variable_order}))
}
}
/// A collection node's modes in their order (`sortPosition`), as `(position, {modeId, name})`.
fn sorted_modes(n: &Kv) -> Vec<(String, Value)> {
let mut modes: Vec<(String, Value)> = n
.get("variableSetModes")
.map(Kv::list)
.unwrap_or_default()
.iter()
.filter_map(|m| {
let mode = json!({"modeId": m.get("id").and_then(guid)?, "name": m.get("name").and_then(Kv::str).unwrap_or("Mode")});
Some((m.get("sortPosition").and_then(Kv::str).unwrap_or("").to_string(), mode))
})
.collect();
modes.sort_by(|a, b| a.0.cmp(&b.0));
modes
}
+7 -3
View File
@@ -2,6 +2,9 @@ use super::paint::color;
use super::{Kv, MAX_EXPR_DEPTH, Mapper, Obj, Value, View, guid, json, num, ref_guid};
impl<'a> Mapper<'a> {
/// Layer `v`'s variable bindings (`boundVariables`, and the text style's) and its explicitly
/// set modes (`explicitVariableModes`) into `o`, in the REST shape. Variables and collections
/// named by library asset resolve to their newest copy in the file.
pub(super) fn variable_bindings(&mut self, o: &mut Obj, v: &View<'a>) {
let mut bound = Obj::new();
let mut text_bound = Obj::new();
@@ -49,7 +52,7 @@ impl<'a> Mapper<'a> {
let mut lost = false;
for e in v.get("variableModeBySetMap").and_then(|m| m.get("entries")).map(Kv::list).unwrap_or_default() {
let (Some(set), Some(mode)) = (e.get("variableSetID"), e.get("variableModeID").and_then(guid)) else { continue };
match self.resource_id(set) {
match self.collection_id(set) {
Some(id) => {
modes.insert(id, json!(mode));
}
@@ -137,13 +140,14 @@ pub(super) fn variable_data(d: &Kv, depth: usize) -> Option<Value> {
Some(out)
}
/// The variable an `ALIAS` variable data points at.
/// The variable an `ALIAS` variable data points at: by guid, or by library asset (the newest copy
/// of it in the file).
pub(super) fn alias(d: Option<&Kv>) -> Option<String> {
let d = d?;
if d.get("dataType").and_then(Kv::str) != Some("ALIAS") {
return None;
}
d.get("value").and_then(|v| v.get("alias")).and_then(ref_guid)
d.get("value").and_then(|v| v.get("alias")).and_then(super::VarCanon::resolve)
}
pub(super) fn variable_value(d: &Kv) -> Option<Value> {
+237 -1
View File
@@ -28,6 +28,7 @@ enum StrokeCap NONE ROUND SQUARE ARROW_LINES ARROW_EQUILATERAL
enum StrokeJoin MITER BEVEL ROUND
enum VectorMirror NONE ANGLE ANGLE_AND_LENGTH
enum ShapeWithTextType SQUARE ELLIPSE
enum NodePhase CREATED REMOVED
enum ConnectorLineStyle ELBOWED STRAIGHT CURVED
enum ConnectorMagnet NONE AUTO TOP LEFT BOTTOM RIGHT CENTER
enum ExpressionFunction ADDITION SUBTRACTION RESOLVE_VARIANT MULTIPLY DIVIDE EQUALS
@@ -76,7 +77,7 @@ message Event InteractionType interactionType
message Action GUID transitionNodeID, TransitionType transitionType, float transitionDuration, EasingType easingType, ConnectionType connectionType, NavigationType navigationType, VariableTarget targetVariable, VariableData targetVariableData, VariableSetID targetVariableSetID, GUID targetVariableModeID, ConditionalActions[] conditionalActions
message Interaction Event event, Action[] actions
message StartingPoint string name, string position
message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, bool visible, float opacity, Vector size, Matrix transform, Paint[] fillPaints, Paint[] strokePaints, float strokeWeight, float cornerRadius, bool resizeToFit, bool internalOnly, float fontSize, FontName fontName, TextData textData, uint styleID, Path[] fillGeometry, Path[] strokeGeometry, StyleType styleType, StyleId styleIdForFill, SymbolData symbolData, PropDef[] componentPropDefs, PropAssignment[] componentPropAssignments, VariableDataMap parameterConsumptionMap, SetMode[] variableSetModes, VariableID variableSetID, ResolvedType variableResolvedType, Values variableDataValues, ModeMap variableModeBySetMap, Interaction[] prototypeInteractions, StartingPoint prototypeStartingPoint, GUIDPath guidPath, StackMode stackMode, float stackSpacing, float stackHorizontalPadding, float stackVerticalPadding, uint count, float starInnerScale, VectorData vectorData, StrokeCap strokeCap, StrokeJoin strokeJoin, VectorMirror handleMirroring, ShapeWithTextType shapeWithTextType, ConnectorEndpoint connectorStart, ConnectorEndpoint connectorEnd, StrokeCap connectorStartCap, StrokeCap connectorEndCap, ConnectorLineStyle connectorLineStyle, ConstraintType horizontalConstraint, ConstraintType verticalConstraint, NodeChange[] derivedSymbolData, string key, string version, LeadingTrim leadingTrim, float[] dashPattern, ConnectorControlPoint[] connectorControlPoints
message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, bool visible, float opacity, Vector size, Matrix transform, Paint[] fillPaints, Paint[] strokePaints, float strokeWeight, float cornerRadius, bool resizeToFit, bool internalOnly, float fontSize, FontName fontName, TextData textData, uint styleID, Path[] fillGeometry, Path[] strokeGeometry, StyleType styleType, StyleId styleIdForFill, SymbolData symbolData, PropDef[] componentPropDefs, PropAssignment[] componentPropAssignments, VariableDataMap parameterConsumptionMap, SetMode[] variableSetModes, VariableID variableSetID, ResolvedType variableResolvedType, Values variableDataValues, ModeMap variableModeBySetMap, Interaction[] prototypeInteractions, StartingPoint prototypeStartingPoint, GUIDPath guidPath, StackMode stackMode, float stackSpacing, float stackHorizontalPadding, float stackVerticalPadding, uint count, float starInnerScale, VectorData vectorData, StrokeCap strokeCap, StrokeJoin strokeJoin, VectorMirror handleMirroring, ShapeWithTextType shapeWithTextType, ConnectorEndpoint connectorStart, ConnectorEndpoint connectorEnd, StrokeCap connectorStartCap, StrokeCap connectorEndCap, ConnectorLineStyle connectorLineStyle, ConstraintType horizontalConstraint, ConstraintType verticalConstraint, NodeChange[] derivedSymbolData, string key, string version, LeadingTrim leadingTrim, float[] dashPattern, ConnectorControlPoint[] connectorControlPoints, NodePhase phase
message Blob byte[] bytes
message Message NodeChange[] nodeChanges, Blob[] blobs
";
@@ -1589,3 +1590,238 @@ fn dump_samples() {
#[path = "board_import_tests.rs"]
mod board;
/// A file that took several updates of a library holds a copy of its collection and of each of
/// its variables per version it met (same asset key, another version). Layers and modes name any
/// of them by asset, never by guid: they all become the newest copy, which has every mode — a page
/// set to a mode only the newer copies have keeps it, and fills bound to an older copy of a
/// variable follow its newest values.
#[test]
fn library_copies_of_several_versions_become_the_newest() {
let mode = |id, name: &'static str, pos: &'static str| o(vec![("id", g(id)), ("name", s(name)), ("sortPosition", s(pos))]);
let by_asset = |key: &'static str, version: &'static str| o(vec![("assetRef", o(vec![("key", s(key)), ("version", s(version))]))]);
let color_value = |c| o(vec![("value", o(vec![("colorValue", c)])), ("dataType", e("COLOR"))]);
let values = |pairs: Vec<(u64, Kv<'static>)>| {
o(vec![("entries", arr(pairs.into_iter().map(|(m, c)| o(vec![("modeID", g(m)), ("variableData", color_value(c))])).collect()))])
};
let asset_alias =
|key: &'static str, version: &'static str| o(vec![("value", o(vec![("alias", by_asset(key, version))])), ("dataType", e("ALIAS"))]);
let nodes = vec![
node(0, None, "DOCUMENT", "Document", vec![]),
// The page is set to "Purple", a mode the oldest copy of the collection didn't have yet.
node(
1,
Some((0, "!")),
"CANVAS",
"Page",
vec![(
"variableModeBySetMap",
o(vec![("entries", arr(vec![o(vec![("variableSetID", by_asset("products", "10:1")), ("variableModeID", g(92))])]))]),
)],
),
node(2, Some((0, "~")), "CANVAS", "Internal", vec![("internalOnly", Kv::Bool(true))]),
node(
4,
Some((2, "!")),
"VARIABLE_SET",
"Products",
// "Legacy" was removed from the library since: only this older copy has it.
vec![("variableSetModes", arr(vec![mode(90, "Blue", "!"), mode(93, "Legacy", "\"")])), ("key", s("products")), ("version", s("10:1"))],
),
node(
6,
Some((2, "#")),
"VARIABLE_SET",
"Products",
vec![("variableSetModes", arr(vec![mode(90, "Blue", "!"), mode(92, "Purple", "\"")])), ("key", s("products")), ("version", s("20:1"))],
),
node(
5,
Some((2, "\"")),
"VARIABLE",
"button-bg",
vec![
("variableSetID", by_asset("products", "10:1")),
("variableResolvedType", e("COLOR")),
("variableDataValues", values(vec![(90, rgb(0.0, 0.0, 1.0))])),
("key", s("button-bg")),
("version", s("10:1")),
],
),
node(
7,
Some((2, "$")),
"VARIABLE",
"button-bg",
vec![
("variableSetID", by_asset("products", "20:1")),
("variableResolvedType", e("COLOR")),
("variableDataValues", values(vec![(90, rgb(0.0, 0.0, 1.0)), (92, rgb(0.5, 0.0, 0.5))])),
("key", s("button-bg")),
("version", s("20:1")),
],
),
// A pasted layer names the variable by the library's own guid (not a node of this file)
// and by its asset: the asset wins.
node(
11,
Some((1, "\"")),
"FRAME",
"Pasted button",
vec![
("size", xy(100.0, 40.0)),
(
"fillPaints",
arr(vec![o(vec![
("type", e("SOLID")),
("color", rgb(0.0, 0.0, 1.0)),
(
"colorVar",
o(vec![
(
"value",
o(vec![(
"alias",
o(vec![("guid", g(777)), ("assetRef", o(vec![("key", s("button-bg")), ("version", s("20:1"))]))]),
)]),
),
("dataType", e("ALIAS")),
]),
),
])]),
),
],
),
// A page section set by a versionless reference, while the file holds several copies.
node(
12,
Some((1, "#")),
"FRAME",
"Versionless",
vec![
("size", xy(100.0, 40.0)),
(
"variableModeBySetMap",
o(vec![(
"entries",
arr(vec![o(vec![("variableSetID", o(vec![("assetRef", o(vec![("key", s("products"))]))])), ("variableModeID", g(92))])]),
)]),
),
],
),
// The button names the older copy of the variable.
node(
10,
Some((1, "!")),
"FRAME",
"Button",
vec![
("size", xy(100.0, 40.0)),
(
"fillPaints",
arr(vec![o(vec![("type", e("SOLID")), ("color", rgb(0.0, 0.0, 1.0)), ("colorVar", asset_alias("button-bg", "10:1"))])]),
),
],
),
];
let (doc, warnings) = import(&canvas(&schema(), &o(vec![("nodeChanges", arr(nodes))])), "x").unwrap();
assert_eq!(doc.variables.collections.len(), 1, "{:?}", doc.variables.collections);
let products = &doc.variables.collections[0];
assert_eq!(products.modes.iter().map(|m| m.name.as_str()).collect::<Vec<_>>(), ["Blue", "Purple", "Legacy"]);
assert_eq!(doc.variables.variables.len(), 1);
let var = &doc.variables.variables[0];
assert_eq!(var.collection, products.id);
// The newest copy's values: Purple included (a mode only it has), Legacy filled from the default.
assert_eq!(var.values.len(), 3, "{:?}", var.values);
assert!(matches!(var.values.get("1:92"), Some(figcraft_doc::VariableValue::Color(c)) if (c.r - 0.5).abs() < 1e-6), "{:?}", var.values);
let page = doc.nodes().find(|n| n.name == "Page").unwrap();
assert_eq!(page.var_modes.get(&products.id).map(String::as_str), Some("1:92"));
let button = named(&doc, "Button");
assert_eq!(button.bound.values().next(), Some(&var.id), "{:?}", button.bound);
assert_eq!(named(&doc, "Versionless").var_modes.get(&products.id).map(String::as_str), Some("1:92"));
let pasted = named(&doc, "Pasted button");
assert_eq!(pasted.bound.values().next(), Some(&var.id), "{:?}", pasted.bound);
assert!(!warnings.iter().any(|w| w.contains("variable modes of collections")), "{warnings:?}");
}
/// A copy of a library collection that a later change removed, followed by a live node for the
/// same asset key and version: the live one is the collection, not the removed one the asset
/// index met first.
#[test]
fn removed_library_collection_copies_give_way_to_live_ones() {
let mode = |id, name: &'static str, pos: &'static str| o(vec![("id", g(id)), ("name", s(name)), ("sortPosition", s(pos))]);
let by_asset = |version: &'static str| o(vec![("assetRef", o(vec![("key", s("products")), ("version", s(version))]))]);
let set = |id, version: &'static str, modes: Vec<Kv<'static>>| {
node(id, Some((2, "!")), "VARIABLE_SET", "Products", vec![("variableSetModes", arr(modes)), ("key", s("products")), ("version", s(version))])
};
let removed = o(vec![("guid", g(8)), ("phase", e("REMOVED"))]);
let nodes = vec![
node(0, None, "DOCUMENT", "Document", vec![]),
node(
1,
Some((0, "!")),
"CANVAS",
"Page",
vec![("variableModeBySetMap", o(vec![("entries", arr(vec![o(vec![("variableSetID", by_asset("30:1")), ("variableModeID", g(92))])]))]))],
),
node(2, Some((0, "~")), "CANVAS", "Internal", vec![("internalOnly", Kv::Bool(true))]),
set(4, "10:1", vec![mode(90, "Blue", "!")]),
set(8, "30:1", vec![mode(90, "Blue", "!"), mode(92, "Purple", "\"")]),
removed,
set(9, "30:1", vec![mode(90, "Blue", "!"), mode(92, "Purple", "\"")]),
];
let (doc, warnings) = import(&canvas(&schema(), &o(vec![("nodeChanges", arr(nodes))])), "x").unwrap();
assert_eq!(doc.variables.collections.len(), 1, "{:?}", doc.variables.collections);
let products = &doc.variables.collections[0];
assert_eq!(products.id, "1:9");
let page = doc.nodes().find(|n| n.name == "Page").unwrap();
assert_eq!(page.var_modes.get("1:9").map(String::as_str), Some("1:92"), "{warnings:?}");
}
/// Two copies of a library collection with the same version: the choice doesn't depend on the order
/// of the node changes (the higher guid wins), and the thread's lookup table is gone after the import.
#[test]
fn same_version_library_copies_resolve_deterministically_and_leave_no_table() {
let mode = |id, name: &'static str| o(vec![("id", g(id)), ("name", s(name)), ("sortPosition", s("!"))]);
let set = |id, modes: Vec<Kv<'static>>| {
node(id, Some((2, "!")), "VARIABLE_SET", "Products", vec![("variableSetModes", arr(modes)), ("key", s("products")), ("version", s("20:1"))])
};
let page = node(
1,
Some((0, "!")),
"CANVAS",
"Page",
vec![(
"variableModeBySetMap",
o(vec![(
"entries",
arr(vec![o(vec![("variableSetID", o(vec![("assetRef", o(vec![("key", s("products"))]))])), ("variableModeID", g(90))])]),
)]),
)],
);
let base = || {
vec![
node(0, None, "DOCUMENT", "Document", vec![]),
page.clone(),
node(2, Some((0, "~")), "CANVAS", "Internal", vec![("internalOnly", Kv::Bool(true))]),
]
};
let mut picked = Vec::new();
for swap in [false, true] {
let (a, b) = (set(4, vec![mode(90, "Blue")]), set(6, vec![mode(90, "Blue")]));
let mut nodes = base();
if swap {
nodes.extend([b, a]);
} else {
nodes.extend([a, b]);
}
let (doc, _) = import(&canvas(&schema(), &o(vec![("nodeChanges", arr(nodes))])), "x").unwrap();
assert_eq!(doc.variables.collections.len(), 1, "{:?}", doc.variables.collections);
let id = doc.variables.collections[0].id.clone();
let page = doc.nodes().find(|n| n.name == "Page").unwrap();
assert_eq!(page.var_modes.get(&id).map(String::as_str), Some("1:90"));
picked.push(id);
assert!(!super::map::VarCanon::installed());
}
assert_eq!(picked, ["1:6", "1:6"]);
}