浏览代码

Live modeling: run-time model and design model are kept in separate branches

Joeri Exelmans 2 年之前
父节点
当前提交
26b2569f78
共有 4 个文件被更改,包括 268 次插入152 次删除
  1. 236 131
      src/frontend/demos/demo_live.tsx
  2. 2 2
      src/frontend/versioned_model/single_model.tsx
  3. 28 17
      src/onion/composite_delta.ts
  4. 2 2
      src/onion/primitive_delta.ts

+ 236 - 131
src/frontend/demos/demo_live.tsx

@@ -11,6 +11,8 @@ import {OnionContext} from "../onion_context";
 import {mockUuid} from "onion/test_helpers";
 import {PrimitiveRegistry, PrimitiveDelta} from "onion/primitive_delta";
 import {INodeState, IValueState} from "onion/graph_state";
+import {embed, Version, VersionRegistry} from "onion/version";
+import {GraphState} from "onion/graph_state"; 
 
 export const demo_Live_description = <>
   <Title order={4}>
@@ -27,45 +29,58 @@ const getStateName = s => (s.getOutgoingEdges().get("name") as IValueState).valu
 export function getDemoLive() {
   const primitiveRegistry = new PrimitiveRegistry();
   const generateUUID = mockUuid();
-  const model = newVersionedModel({readonly: true});
-  const modelId = generateUUID();
+  const versionRegistry = new VersionRegistry();
+  const model = newVersionedModel({readonly: true}, versionRegistry);
+
+  // To efficiently create a delta in the context of the design model only
+  const designModelGraphState = new GraphState();
+
+  // Mapping from run-time model to design model version
+  const rt2d = new Map([[versionRegistry.initialVersion, versionRegistry.initialVersion]]);
 
 
+  const modelId = generateUUID();
+
   return function DemoSem() {
 
     const [modelState, setDesignModelState] = React.useState<VersionedModelState>(model.initialState);
 
+    const gotoMatchingDesignVersion = (rtVersion: Version) => {
+      const currentDesignVersion = rt2d.get(modelState.version)!;
+      const newDesignVersion = rt2d.get(rtVersion)!;
+      const path = currentDesignVersion.findPathTo(newDesignVersion)!;
+      for (const [linkType, delta] of path) {
+        if (linkType === 'p') {
+          designModelGraphState.unexec(delta);
+        }
+        else if (linkType === 'c') {
+          designModelGraphState.exec(delta);
+        }
+      }
+    }
+
     const modelReducer = model.getReducer(setDesignModelState);
 
     // We start out with the 'createList' delta already having occurred:
     React.useEffect(() => {
         // idempotent:
         const modelCreation = primitiveRegistry.newNodeCreation(modelId);
-        modelReducer.createAndGotoNewVersion([
+        const newVersion = modelReducer.createAndGotoNewVersion([
             modelCreation,
             primitiveRegistry.newEdgeCreation(modelCreation, "type", "DesignModel"),
         ], "createDesignModel", model.initialState.version);
+        rt2d.set(newVersion, newVersion);
+        gotoMatchingDesignVersion(newVersion);
     }, []);
 
-
-    const [dotGraph, setDotGraph] = React.useState("digraph {}");
-
-    const [initialState, setInitialState] = React.useState<string|null>(null);
-    const [currentState, setCurrentState] = React.useState<string|null>(null);
-
-    // Whenever the current version changes, we calculate a bunch of values that are needed in the UI and in its callbacks
-    const [states, transitions, modelNode, initial,
-      runtimeModelNode, current]: [[string,INodeState][], [string,string,string,INodeState][], INodeState|null, INodeState|null, INodeState|null, INodeState|null] = React.useMemo(() => {
+    const getDesignModelStuff = graphState => {
       const states: Array<[string, INodeState]> = [];
       const transitions: Array<[string, string, string, INodeState]> = [];
       let modelNode: INodeState|null = null;
-      let runtimeModelNode: INodeState|null = null;
+      let initial: INodeState|undefined;      
       let initialStateName : string|null = null;
-      let currentStateName : string|null = null;
-      let initial;
-      let current;
 
-      for (const nodeState of model.graphState.nodes.values()) {
+      for (const nodeState of graphState.nodes.values()) {
         if (nodeState.isDeleted) {
           continue;
         }
@@ -86,40 +101,92 @@ export function getDemoLive() {
             modelNode = nodeState;
             initial = nodeState.getOutgoingEdges().get("initial") as INodeState;
             initialStateName = initial ? getStateName(initial) : null;
-            setInitialState(initialStateName);
           },
+        }[nodeType])?.();
+      }
+
+      const result: {
+        states: [string,INodeState][],
+        transitions: [string,string,string,INodeState][],
+        modelNode: INodeState|null,
+        initial: INodeState|undefined,
+        initialStateName: string|null,
+      } = {
+        states,
+        transitions,
+        modelNode,
+        initial,
+        initialStateName,
+      };
+      return result;
+    }
+
+    const getRuntimeModelStuff = graphState => {
+      const designModelStuff = getDesignModelStuff(graphState);
+      let runtimeModelNode: INodeState|null = null;
+      let currentStateName : string|null = null;
+      let current;
+
+      for (const nodeState of graphState.nodes.values()) {
+        if (nodeState.isDeleted) {
+          continue;
+        }
+        const nodeType = (nodeState.getOutgoingEdges().get("type") as IValueState)?.value as string;
+        ({
           RuntimeModel: () => {
             runtimeModelNode = nodeState;
             current = nodeState.getOutgoingEdges().get("current") as INodeState;
             currentStateName = current ? getStateName(current) : null;
-            setCurrentState(currentStateName);            
-          }
-        }[nodeType])();
+          },
+        }[nodeType])?.();
       }
 
-      setDotGraph(`
+      const dotGraph = `
         digraph {
           bgcolor="transparent";
           rankdir="LR";
-          ${states.map(([name])=>name
-            + (name === initialStateName ? `[color=blue, style=filled, fontcolor=white]`:`[fillcolor=white, style=filled]`)
+          ${designModelStuff.states.map(([name])=>name
+            + (name === designModelStuff.initialStateName ? `[color=blue, style=filled, fontcolor=white]`:`[fillcolor=white, style=filled]`)
             + (name === currentStateName ? `[shape=doublecircle]`:`[shape=circle]`)
           ).join(' ')}
-          ${transitions.map(([src,tgt,label])=> src + ' -> ' + tgt + ' [label='+label+']').join('\n')}
-        }`);
+          ${designModelStuff.transitions.map(([src,tgt,label])=> src + ' -> ' + tgt + ' [label='+label+']').join('\n')}
+        }`;
+
+      const result: {
+        runtimeModelNode: INodeState|null,
+        current: INodeState|null,
+        currentStateName: string|null,
+        dotGraph: string
+      } = {
+        runtimeModelNode,
+        current,
+        currentStateName,
+        dotGraph};
+      return Object.assign(result, designModelStuff);
+    };
+
+    // Whenever the current version changes, we calculate a bunch of values that are needed in the UI and in its callbacks
+    const runtimeStuff = React.useMemo(() => getRuntimeModelStuff(model.graphState),
+      [modelState.version]); // memoize the values for each version
+
+    const designStuff = React.useMemo(() => getDesignModelStuff(designModelGraphState),
+      [rt2d.get(modelState.version)]); // memoize the values for each version
 
-      return [states, transitions, modelNode, initial,
-        runtimeModelNode, current];
-    }, [modelState.version]); // memoize the values for each version
 
     const modelComponents = model.getReactComponents(modelState, setDesignModelState, {
-        onUserEdit: (deltas, description) => {
-            const newVersion = modelReducer.createAndGotoNewVersion(deltas, description);
-        },
-        onUndoClicked: modelReducer.undo,
-        onRedoClicked: modelReducer.redo,
-        onVersionClicked: modelReducer.gotoVersion,
-        onMerge: modelReducer.appendVersions,
+      onUndoClicked: (parentVersion, deltaToUndo) => {
+        gotoMatchingDesignVersion(parentVersion);
+        modelReducer.undo(parentVersion, deltaToUndo);
+      },
+      onRedoClicked: (childVersion, deltaToRedo) => {
+        gotoMatchingDesignVersion(childVersion);
+        modelReducer.redo(childVersion, deltaToRedo);
+      },
+      onVersionClicked: (version: Version) => {
+        gotoMatchingDesignVersion(version);
+        modelReducer.gotoVersion(version);
+      },
+      onMerge: modelReducer.appendVersions,
     });
 
     const [addStateName, setAddStateName] = React.useState<string>("A");
@@ -127,122 +194,158 @@ export function getDemoLive() {
     const [addTransitionTgt, setAddTransitionTgt] = React.useState<string|null>(null);
     const [addTransitionEvent, setAddTransitionEvent] = React.useState<string>("e");
 
-    function addState() {
-      if (modelNode !== null) {
-        model.graphState.pushState();
+    const execTransaction = (graphState, modelNode, callback) => {
+      graphState.pushState();
+      const {compositeLabel} = callback(graphState, modelNode);
+      const deltas = graphState.popState();
+      return {compositeLabel, deltas};
+    }
 
-        const nodeId = generateUUID();
-        const nodeCreation = primitiveRegistry.newNodeCreation(nodeId);
-        model.graphState.exec(nodeCreation);
-        model.graphState.exec(primitiveRegistry.newEdgeCreation(nodeCreation, "type", "State"));
-        model.graphState.exec(primitiveRegistry.newEdgeCreation(nodeCreation, "name", addStateName));
+    const editDesignModel = callback => {
+      if (runtimeStuff.modelNode !== null) {
+        const currentDesignVersion = rt2d.get(modelState.version)!;
 
-        modelNode.getDeltasForSetEdge(primitiveRegistry, "contains-"+JSON.stringify(nodeId.value), nodeCreation).forEach(d => model.graphState.exec(d));
+        // perform edit on run-time model:
+        const {compositeLabel, deltas} = execTransaction(model.graphState, runtimeStuff.modelNode, callback);
 
-        const deltas = model.graphState.popState();
-        modelReducer.createAndGotoNewVersion(deltas, "addState:"+addStateName);
+        // see if the edit can be applied also on the design model:
+        let newDesignVersion;
+        try {
+          newDesignVersion = modelReducer.addDeltasAndVersion(deltas, "design:"+compositeLabel, currentDesignVersion.hash)!; // may throw
+        }
+        catch (e) {
+          // alert("could not apply this exact same delta on the design model - probably there's a missing dependency:\n" + e.toString() + "\nTherefore, I will attempt to create a new delta with the same /intent/ on the design model.");
 
-        // Auto-increment state names
-        if (addStateName.match(/[A-Z]/i)) {
-          setAddStateName(String.fromCharCode(addStateName.charCodeAt(0) + 1));
+          const {compositeLabel, deltas} = execTransaction(designModelGraphState, designStuff.modelNode, callback);
+          newDesignVersion = modelReducer.addDeltasAndVersion(deltas, "design':"+compositeLabel, currentDesignVersion.hash)!; // won't throw this time
         }
+
+        const newRtVersion = modelReducer.createAndGotoNewVersion(deltas, "design:"+compositeLabel);
+        rt2d.set(newRtVersion, newDesignVersion);
+        rt2d.set(newDesignVersion, newDesignVersion);
+        gotoMatchingDesignVersion(newRtVersion);
       }
     }
+    const editRuntimeModel = callback => {
+      const currentDesignVersion = rt2d.get(modelState.version)!;
 
-    const getState = name => {
-      const s = states.find(([n]) => n === name);
-      if (s !== undefined) return s[1].creation;
+      // perform edit on run-time model:
+      const {compositeLabel, deltas} = execTransaction(model.graphState, runtimeStuff.modelNode, callback);
+      const newRtVersion = modelReducer.createAndGotoNewVersion(deltas, "runtime:"+compositeLabel);
+
+      // the run-time change did not change the design model:
+      rt2d.set(newRtVersion, currentDesignVersion);
     }
 
-    function addTransition() {
-      if (modelNode !== null) {
-        model.graphState.pushState();
+    const findState = (graphState: GraphState, stateName: string | null) => {
+      for (const nodeState of graphState.nodes.values()) {
+        if (nodeState.isDeleted) {
+          continue;
+        }
+        const nodeType = (nodeState.getOutgoingEdges().get("type") as IValueState)?.value as string;
+        if (nodeType !== "State") {
+          continue;
+        }
+        const name = (nodeState.getOutgoingEdges().get("name") as IValueState)?.value as string;
+        if (name === stateName) {
+          return nodeState;
+        }
+      }
+    }
 
+    function addState() {
+      editDesignModel((graphState, modelNode) => {
         const nodeId = generateUUID();
         const nodeCreation = primitiveRegistry.newNodeCreation(nodeId);
-        model.graphState.exec(nodeCreation);
-        model.graphState.exec(primitiveRegistry.newEdgeCreation(nodeCreation, "type", "Transition"));
-        model.graphState.exec(primitiveRegistry.newEdgeCreation(nodeCreation, "event", addTransitionEvent));
-        model.graphState.exec(primitiveRegistry.newEdgeCreation(nodeCreation, "src", getState(addTransitionSrc)!));
-        model.graphState.exec(primitiveRegistry.newEdgeCreation(nodeCreation, "tgt", getState(addTransitionTgt)!));
+        graphState.exec(nodeCreation);
+        graphState.exec(primitiveRegistry.newEdgeCreation(nodeCreation, "type", "State"));
+        graphState.exec(primitiveRegistry.newEdgeCreation(nodeCreation, "name", addStateName));
 
-        modelNode.getDeltasForSetEdge(primitiveRegistry, "contains-"+JSON.stringify(nodeId.value), nodeCreation).forEach(d => model.graphState.exec(d));
+        modelNode.getDeltasForSetEdge(primitiveRegistry, "contains-"+JSON.stringify(nodeId.value), nodeCreation).forEach(d => graphState.exec(d));
 
-        const deltas = model.graphState.popState();
-        modelReducer.createAndGotoNewVersion(deltas, "addTransition:"+addTransitionSrc+"--"+addTransitionEvent+"->"+addTransitionTgt);
-      }
+        // Bonus feature: Auto-increment state names
+        if (addStateName.match(/[A-Z]/i)) {
+          setAddStateName(String.fromCharCode(addStateName.charCodeAt(0) + 1));
+        }
+
+        return {compositeLabel: "addState:"+addStateName};
+      });
     }
+    function addTransition() {
+      editDesignModel((graphState, modelNode) => {
+        const nodeId = generateUUID();
+        const nodeCreation = primitiveRegistry.newNodeCreation(nodeId);
+        graphState.exec(nodeCreation);
+        graphState.exec(primitiveRegistry.newEdgeCreation(nodeCreation, "type", "Transition"));
+        graphState.exec(primitiveRegistry.newEdgeCreation(nodeCreation, "event", addTransitionEvent));
+        graphState.exec(primitiveRegistry.newEdgeCreation(nodeCreation, "src", findState(graphState, addTransitionSrc!)!.creation));
+        graphState.exec(primitiveRegistry.newEdgeCreation(nodeCreation, "tgt", findState(graphState, addTransitionTgt!)!.creation));
 
-    function onDeleteState(name: string, nodeState: INodeState) {
-      if (modelNode !== null) {
-        model.graphState.pushState();
+        modelNode.getDeltasForSetEdge(primitiveRegistry, "contains-"+JSON.stringify(nodeId.value), nodeCreation).forEach(d => graphState.exec(d));
+
+        return {compositeLabel: "addTransition:"+addTransitionSrc+"--"+addTransitionEvent+"->"+addTransitionTgt};
+      })
+    }
+    function onDeleteState(name: string) {
+      editDesignModel((graphState) => {
+        const nodeState = findState(graphState, name)!;
         while (true) {
           // Delete all incoming and outgoing transitions:
           const found = nodeState.getIncomingEdges().find(([label]) => label === "src" || label === "tgt");
           if (found === undefined) break;
           const [_, from] = found;
           const deltas = from.getDeltasForDelete(primitiveRegistry);
-          console.log({deltas});
-          deltas.forEach(d => model.graphState.exec(d));
+          deltas.forEach(d => graphState.exec(d));
         }
-        nodeState.getDeltasForDelete(primitiveRegistry).forEach(d => model.graphState.exec(d));
-        const deltas = model.graphState.popState();
-        console.log({deltas});
-        modelReducer.createAndGotoNewVersion(deltas, "deleteState:"+name);
-      }
+        nodeState.getDeltasForDelete(primitiveRegistry).forEach(d => {
+          console.log(d);
+          graphState.exec(d);
+        });
+        return {compositeLabel: "deleteState:"+name};
+      });
     }
-
     function onInitialStateChange(value) {
-      if (modelNode !== null) {
-        model.graphState.pushState();
-        modelNode.getDeltasForSetEdge(primitiveRegistry, "initial", getState(value) || null).forEach(d => model.graphState.exec(d));
-        const deltas = model.graphState.popState();
-        modelReducer.createAndGotoNewVersion(deltas, "setInitial:"+value);
-        setInitialState(value);
-      }
+      editDesignModel((graphState, modelNode) => {
+        modelNode.getDeltasForSetEdge(primitiveRegistry, "initial", findState(graphState, value)?.creation || null).forEach(d => graphState.exec(d));
+        return {compositeLabel: "setInitial:"+value};
+      });
     }
 
     function onInitialize() {
-      if (modelNode !== null && initial != null) {
-        model.graphState.pushState();
-        const runtimeId = generateUUID();
-        const nodeCreation = primitiveRegistry.newNodeCreation(runtimeId);
-        model.graphState.exec(nodeCreation);
-        model.graphState.exec(primitiveRegistry.newEdgeCreation(nodeCreation, "type", "RuntimeModel"));
-        model.graphState.exec(primitiveRegistry.newEdgeCreation(nodeCreation, "design", modelNode.creation));
-        model.graphState.exec(primitiveRegistry.newEdgeCreation(nodeCreation, "current", initial.creation));
-        const deltas = model.graphState.popState();
-        modelReducer.createAndGotoNewVersion(deltas, "initialize");
+      if (runtimeStuff.modelNode !== null && runtimeStuff.initial !== null) {
+        editRuntimeModel((graphState) => {
+          const runtimeId = generateUUID();
+          const nodeCreation = primitiveRegistry.newNodeCreation(runtimeId);
+          model.graphState.exec(nodeCreation);
+          model.graphState.exec(primitiveRegistry.newEdgeCreation(nodeCreation, "type", "RuntimeModel"));
+          model.graphState.exec(primitiveRegistry.newEdgeCreation(nodeCreation, "design", runtimeStuff.modelNode!.creation));
+          model.graphState.exec(primitiveRegistry.newEdgeCreation(nodeCreation, "current", runtimeStuff.initial!.creation));
+          return {compositeLabel: "initialize"};
+        });
       }
     }
-
     function onAbort() {
-      if (runtimeModelNode !== null) {
-        model.graphState.pushState();
-        runtimeModelNode.getDeltasForDelete(primitiveRegistry).forEach(d => model.graphState.exec(d));
-        const deltas = model.graphState.popState();
-        modelReducer.createAndGotoNewVersion(deltas, "abort");
-        setCurrentState(null);
+      if (runtimeStuff.runtimeModelNode !== null) {
+        editRuntimeModel((graphState) => {
+          runtimeStuff.runtimeModelNode!.getDeltasForDelete(primitiveRegistry).forEach(d => graphState.exec(d));
+          return {compositeLabel: "abort"};
+        });
       }
     }
-
     function onCurrentStateChange(value) {
-      if (runtimeModelNode !== null) {
-        model.graphState.pushState();
-        runtimeModelNode.getDeltasForSetEdge(primitiveRegistry, "current", getState(value) || null).forEach(d => model.graphState.exec(d));
-        const deltas = model.graphState.popState();
-        modelReducer.createAndGotoNewVersion(deltas, "setCurrent:"+value);
-        setCurrentState(value);
+      if (runtimeStuff.runtimeModelNode !== null) {
+        editRuntimeModel((graphState) => {
+          runtimeStuff.runtimeModelNode!.getDeltasForSetEdge(primitiveRegistry, "current", findState(graphState, value)?.creation || null).forEach(d => graphState.exec(d));
+          return {compositeLabel: "setCurrent:"+value}
+        });
       }
     }
-
     function onExecuteStep(srcName, tgtName, label, transition: INodeState) {
-      if (runtimeModelNode !== null) {
-        model.graphState.pushState();
-        runtimeModelNode.getDeltasForSetEdge(primitiveRegistry, "current", getState(tgtName) || null).forEach(d => model.graphState.exec(d));
-        const deltas = model.graphState.popState();
-        modelReducer.createAndGotoNewVersion(deltas, "executeStep:"+transitionKey([srcName, tgtName, label]));
-        setCurrentState(tgtName);
+      if (runtimeStuff.runtimeModelNode !== null) {
+        editRuntimeModel((graphState) => {
+          runtimeStuff.runtimeModelNode!.getDeltasForSetEdge(primitiveRegistry, "current", findState(graphState, tgtName)?.creation || null).forEach(d => graphState.exec(d));
+          return {compositeLabel: "executeStep:"+transitionKey([srcName, tgtName, label])};
+        });
       }
     }
 
@@ -258,7 +361,7 @@ export function getDemoLive() {
             </Group>
             <Divider label="(Projectional) Model Editor" labelPosition="centered" />
             {
-            modelNode === null ?
+            runtimeStuff.modelNode === null ?
               <Alert icon={<Icons.IconAlertCircle/>} title="No design model!" color="blue">
                 Did you undo its creation?
               </Alert>
@@ -268,40 +371,40 @@ export function getDemoLive() {
             <Paper shadow="xs" p="xs" withBorder>
             <Group grow>
               <TextInput value={addStateName} onChange={e => setAddStateName(e.currentTarget.value)} label="State Name" withAsterisk/>
-              <Button onClick={addState} disabled={addStateName===""||modelNode===null||states.some(([name]) => name ===addStateName)} leftIcon={<Icons.IconPlus/>} color="green">State</Button>
+              <Button onClick={addState} disabled={addStateName===""||runtimeStuff.modelNode===null||runtimeStuff.states.some(([name]) => name ===addStateName)} leftIcon={<Icons.IconPlus/>} color="green">State</Button>
             </Group>
             </Paper>
-              <Select disabled={modelNode===null} searchable clearable label="Initial State" data={states.map(([stateName]) => ({value:stateName, label:stateName}))} value={initialState} onChange={onInitialStateChange}/>
+              <Select disabled={runtimeStuff.modelNode===null} searchable clearable label="Initial State" data={runtimeStuff.states.map(([stateName]) => ({value:stateName, label:stateName}))} value={runtimeStuff.initialStateName} onChange={onInitialStateChange}/>
             </Group>
             <div style={{minHeight: 26}}>
               <Group>{
-                states.map(([stateName, stateNodeState]) => {
-                  return <Button compact color="red" key={stateName} leftIcon={<Icons.IconX/>} onClick={() => onDeleteState(stateName, stateNodeState)}>State {stateName}</Button>
+                runtimeStuff.states.map(([stateName]) => {
+                  return <Button compact color="red" key={stateName} leftIcon={<Icons.IconX/>} onClick={() => onDeleteState(stateName)}>State {stateName}</Button>
                 })
               }</Group>
             </div>
             <Paper shadow="xs" p="xs" withBorder>
             <Group grow>
-              <Select searchable withAsterisk clearable label="Source" data={states.map(([stateName]) => ({value:stateName, label:stateName}))} value={addTransitionSrc} onChange={setAddTransitionSrc}/>
-              <Select searchable withAsterisk clearable label="Target" data={states.map(([stateName]) => ({value:stateName, label:stateName}))} value={addTransitionTgt} onChange={setAddTransitionTgt}/>
+              <Select searchable withAsterisk clearable label="Source" data={runtimeStuff.states.map(([stateName]) => ({value:stateName, label:stateName}))} value={addTransitionSrc} onChange={setAddTransitionSrc}/>
+              <Select searchable withAsterisk clearable label="Target" data={runtimeStuff.states.map(([stateName]) => ({value:stateName, label:stateName}))} value={addTransitionTgt} onChange={setAddTransitionTgt}/>
               <TextInput value={addTransitionEvent} onChange={e => setAddTransitionEvent(e.currentTarget.value)}  label="Event" />
               <Button disabled={addTransitionSrc === null || addTransitionTgt === null} onClick={addTransition} leftIcon={<Icons.IconPlus/>} color="green">Transition</Button>
             </Group>
             </Paper>
             <div style={{minHeight: 26}}>
               <Group>{
-                transitions.map(([srcName, tgtName, label, tNodeState]) => {
+                runtimeStuff.transitions.map(([srcName, tgtName, label, tNodeState]) => {
                   const key = srcName+'--('+label+')-->'+tgtName;
-                  return <Button compact color="red" key={key} leftIcon={<Icons.IconX/>} onClick={() => onDeleteState(key, tNodeState)}>Transition {key}</Button>
+                  return <Button compact color="red" key={key} leftIcon={<Icons.IconX/>} onClick={() => onDeleteState(key)}>Transition {key}</Button>
                 })
               }</Group>
             </div>
 
             <Divider label="Execution" labelPosition="centered" />
             <Group grow>
-              <Button disabled={initialState === null || runtimeModelNode !== null} onClick={onInitialize} leftIcon={<Icons.IconPlayerPlay/>}>Init</Button>
-              <Button color="red" disabled={runtimeModelNode === null} onClick={onAbort} leftIcon={<Icons.IconPlayerStop/>}>Abort</Button>
-              <Select disabled={currentState === null || runtimeModelNode === null} data={transitions.filter(([srcName]) => srcName === currentState).map(t => {
+              <Button disabled={runtimeStuff.initialStateName === null || runtimeStuff.runtimeModelNode !== null} onClick={onInitialize} leftIcon={<Icons.IconPlayerPlay/>}>Init</Button>
+              <Button color="red" disabled={runtimeStuff.runtimeModelNode === null} onClick={onAbort} leftIcon={<Icons.IconPlayerStop/>}>Abort</Button>
+              <Select disabled={runtimeStuff.currentStateName === null || runtimeStuff.runtimeModelNode === null} data={runtimeStuff.transitions.filter(([srcName]) => srcName === runtimeStuff.currentStateName).map(t => {
                   // @ts-ignore:
                   const key = transitionKey(t);
                   return {
@@ -309,14 +412,14 @@ export function getDemoLive() {
                     label: key,
                   }
               })} label="Execute Step" onChange={key => {
-                const t = transitions.find(t =>
+                const t = runtimeStuff.transitions.find(t =>
                   // @ts-ignore:
                   transitionKey(t) == key);
                 onExecuteStep(...t!);
               }}/>
-              <Select disabled={runtimeModelNode === null} searchable clearable label={<Group>Current State <InfoHoverCard>
+              <Select disabled={runtimeStuff.runtimeModelNode === null} searchable clearable label={<Group>Current State <InfoHoverCard>
                 <Text>Current state updates when executing an execution step, but can also be overridden at any point in time ("god event").</Text>
-              </InfoHoverCard></Group>} data={states.map(([stateName]) => ({value:stateName, label:stateName}))} value={currentState} onChange={onCurrentStateChange}/>
+              </InfoHoverCard></Group>} data={runtimeStuff.states.map(([stateName]) => ({value:stateName, label:stateName}))} value={runtimeStuff.currentStateName} onChange={onCurrentStateChange}/>
             </Group>
 
             <InfoHoverCardOverlay contents={<>
@@ -324,8 +427,8 @@ export function getDemoLive() {
                 <Text>Powered by GraphViz and WebAssembly.</Text>
               </>}>
               <div style={{backgroundColor:"#eee"}}>{
-                states.length > 0 ?
-                <Graphviz dot={dotGraph} options={{fit:false, width:null, height:null}} />
+                runtimeStuff.states.length > 0 ?
+                <Graphviz dot={runtimeStuff.dotGraph} options={{fit:false, width:null, height:null}} />
                 : <Center position="center" style={{padding:20}}>
                     <Text>FSA will appear here.</Text>
                   </Center>
@@ -335,6 +438,8 @@ export function getDemoLive() {
           <Stack>
             {modelComponents.makeTabs("state", ["state", "merge", "deltaL1", "deltaL0"])}
             {modelComponents.makeTabs("deltaL1", ["state", "merge", "deltaL1", "deltaL0"])}
+            Run-time model version: {modelState.version.hash.toString('hex').substring(0,8)}<br/>
+            DesignModel version: {rt2d.get(modelState.version)!.hash.toString('hex').substring(0,8)}
           </Stack>
         </SimpleGrid>
       </OnionContext.Provider>

+ 2 - 2
src/frontend/versioned_model/single_model.tsx

@@ -55,8 +55,8 @@ interface VersionedModelCallbacks {
 //  - History graph (+undo/redo buttons)
 //  - Delta graph
 // , their state, and callbacks for updating their state.
-export function newVersionedModel({readonly}) {
-  const versionRegistry = new VersionRegistry();
+export function newVersionedModel({readonly}, vReg?: VersionRegistry) {
+  const versionRegistry = vReg || new VersionRegistry();
   const graphState = new GraphState();
   const compositeLevel = new CompositeLevel();
 

+ 28 - 17
src/onion/composite_delta.ts

@@ -56,7 +56,7 @@ export class CompositeDelta implements Delta {
 // A "registry" of composite deltas.
 // When creating a new CompositeDelta, it will figure out what other CompositeDeltas to depend on, and to conflict with.
 export class CompositeLevel {
-  containedBy: Map<Delta, CompositeDelta> = new Map();
+  containedBy: Map<Delta, CompositeDelta[]> = new Map();
   composites: Map<string, CompositeDelta> = new Map();
 
   createComposite(deltas: Array<Delta>, description: string = deltas.map(d=>d.getDescription()).join(",")): CompositeDelta {
@@ -79,15 +79,21 @@ export class CompositeLevel {
 
     for (const delta of deltas) {
       if (this.containedBy.has(delta)) {
-        throw new Error("Assertion failed: delta " + delta.getDescription() + " already part of another composite");
+        // Originally, the assumption was that any delta could only be part of one composite.
+        // We found that we have to relax this...
+        // throw new Error("Assertion failed: delta " + delta.getDescription() + " already part of another composite");
       }
       for (const [dependency, dependencyType] of delta.getTypedDependencies()) {
         if (!deltas.includes(dependency)) {
           // We got ourselves an inter-composite dependency.
-          const compositeDependency = this.containedBy.get(dependency);
-          if (compositeDependency === undefined) {
+          const compositeDependencies = this.containedBy.get(dependency) || [];
+          if (compositeDependencies.length > 1) {
+            throw new Error("Currently unsupported: One of the composite's dependencies is contained by multiple composites.")
+          }
+          if (compositeDependencies.length === 0) {
             throw new Error("Assertion failed: delta " + delta.getDescription() + " depends on " + dependency.getDescription() + " but this dependency could not be found in a composite.");
           }
+          const [compositeDependency] = compositeDependencies;
           const existingDependency = typedDependencies.find(([dep,_]) => dep === compositeDependency);
           if (existingDependency !== undefined) {
             // existingDependency[1] += ","+dependencyType;
@@ -98,19 +104,15 @@ export class CompositeLevel {
           }
         }
       }
-      for (const conflict of delta.getConflicts()) {
-        if (deltas.includes(conflict)) {
-          console.log("Conflict between", conflict, "and", delta);
+      for (const conflictingDelta of delta.getConflicts()) {
+        if (deltas.includes(conflictingDelta)) {
+          console.log("Conflict between", conflictingDelta, "and", delta);
           throw new Error("Cannot create a composite delta out of conflicting deltas");
         }
-        const compositeConflict = this.containedBy.get(conflict);
-        if (compositeConflict === undefined) {
-          // We used to treat this as an error, however, it's possible that a conflicting delta simply isn't part yet of a composite delta...
-
-          // throw new Error("Assertion failed: cannot find composite of " + conflict.getDescription());
-        } else {
-          if (!conflicts.includes(compositeConflict)) {
-            conflicts.push(compositeConflict);
+        const compositeConflict = this.containedBy.get(conflictingDelta) || [];
+        for (const otherComposite of compositeConflict) {
+          if (!conflicts.includes(otherComposite)) {
+            conflicts.push(otherComposite);
           }
         }
       }
@@ -119,16 +121,25 @@ export class CompositeLevel {
     if (dependencies.some(dependency => conflicts.includes(dependency))) {
       throw new Error("Assertion failed: Attempted to create a composite delta that conflicts with one of its dependencies.");
     }
+    
+    // Success...
 
     const composite = new CompositeDelta(deltas, dependencies, typedDependencies, conflicts, hash, description);
 
     this.composites.set(hashBase64, composite);
 
     for (const delta of deltas) {
-      this.containedBy.set(delta, composite);
+      // this.containedBy.set(delta, composite);
+      const containedBy = this.containedBy.get(delta) || (() => {
+        const newList = [];
+        this.containedBy.set(delta, newList);
+        return newList;
+      })();
+      containedBy.push(composite);
     }
+
+    // Conflicts are symmetric, so newly created conflicts are also added to the other:
     for (const compositeConflict of conflicts) {
-      // Conflicts are symmetric, so newly created conflicts are also added to the other:
       compositeConflict.conflicts.push(composite);
     }
     return composite;

+ 2 - 2
src/onion/primitive_delta.ts

@@ -419,7 +419,7 @@ export class EdgeCreation implements PrimitiveDelta {
     this.label = label;
     this.target = makeSetsTarget(target, this);
 
-    this.description = "EDG("+this.label+")";
+    this.description = "U("+this.label+")";
 
     // Detect conflicts
 
@@ -519,7 +519,7 @@ export class EdgeUpdate implements PrimitiveDelta {
     this.overwrites = overwrites;
     this.target = makeSetsTarget(newTarget, this);
 
-    this.description = "EDG";
+    this.description = this.getCreation().description;
 
     // Detect conflicts