Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
__pycache__/
*.py[cod]

# Runtime output (add-in logs and report exports land in the repo when it
# doubles as the installed add-in folder)
logs/
exports/
.venv/
env/
.env
Expand Down
609 changes: 201 additions & 408 deletions Fusion_System_Blocks.py

Large diffs are not rendered by default.

255 changes: 169 additions & 86 deletions README.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions docs/DETAILED_TESTING_DOCUMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ future-state checklist with a current-state verification plan.
- Updated: April 3, 2026
- Repository status: 18 milestones total; 16 complete; 2 not started
(Milestone 13 and Milestone 15)
- Latest local automated regression: `pytest -q` passed 707 tests
- Latest local automated regression: `pytest -q` passed 775 tests
- In-app diagnostics baseline: `DiagnosticsRunner` discovers 32 checks
- Companion smoke plan:
[FUSION_MANUAL_TEST_PLAN.md](FUSION_MANUAL_TEST_PLAN.md)
Expand Down Expand Up @@ -72,7 +72,7 @@ testing.
| Step | Action | Expected Result | Pass |
| --- | --- | --- | --- |
| 0.1 | Activate the project environment and confirm you are in the repo root. | Commands run against the correct workspace. | [ ] |
| 0.2 | Run `pytest -q`. | All tests pass. Current baseline on this repo state: 707 passed. | [ ] |
| 0.2 | Run `pytest -q`. | All tests pass. Current baseline on this repo state: 775 passed. | [ ] |
| 0.3 | Run `ruff check .`. | No lint failures. | [ ] |
| 0.4 | Review editor diagnostics for touched files. | No blocking syntax or import errors remain. | [ ] |
| 0.5 | If any preflight step fails, stop the manual pass and log the blocker. | Manual testing does not continue on a known-bad baseline. | [ ] |
Expand Down
4 changes: 2 additions & 2 deletions docs/FUSION_MANUAL_TEST_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ you need the longer, full-coverage plan.
- Updated: April 3, 2026
- Repository status: 18 milestones total; 16 complete; 2 not started
(Milestone 13 and Milestone 15)
- Latest automated baseline in this workspace: `pytest -q` passed 707 tests
- Latest automated baseline in this workspace: `pytest -q` passed 775 tests
- In-app diagnostics baseline: 32 checks discovered by `DiagnosticsRunner`
- Estimated total time: 30 to 40 minutes

Expand Down Expand Up @@ -48,7 +48,7 @@ you need the longer, full-coverage plan.
| Step | Action | Expected Result | Pass |
| --- | --- | --- | --- |
| 0.1 | Open the repository root in the configured environment. | Commands run against the correct workspace. | [ ] |
| 0.2 | Run `pytest -q`. | All tests pass. Current baseline: 707 passed. | [ ] |
| 0.2 | Run `pytest -q`. | All tests pass. Current baseline: 775 passed. | [ ] |
| 0.3 | Run `ruff check .`. | No lint failures. | [ ] |
| 0.4 | Review editor diagnostics for any touched files. | No blocking syntax or import errors remain. | [ ] |

Expand Down
43 changes: 39 additions & 4 deletions docs/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,38 @@
"description": "Schema version for migration support. Missing = '0.9' (pre-versioning).",
"enum": ["0.9", "1.0"]
},
"schema": { "type": "string" },
"schema": {
"type": "string",
"description": "Document-format identifier; canonical value is 'system-blocks-v2' (schemaVersion is the migration key)"
},
"id": { "type": "string" },
"name": { "type": "string" },
"metadata": {
"type": "object",
"description": "Diagram metadata (created/modified timestamps, parentBlockId for child diagrams, etc.)",
"additionalProperties": true
},
"groups": {
"type": "array",
"description": "Named block groups with optional nesting and metadata",
"items": { "type": "object" }
},
"namedStubs": {
"type": "array",
"description": "Net-label stubs; blocks sharing a netName are implicitly connected",
"items": { "type": "object" }
},
"annotations": {
"type": "array",
"description": "Canvas annotations (text, note, dimension, callout)",
"items": { "type": "object" }
},
"pages": {
"type": "array",
"description": "Multi-page diagrams: each page holds its own blocks/connections",
"items": { "type": "object" }
},
"activePageIndex": { "type": "number" },
"units": {
"type": "object",
"properties": {
Expand Down Expand Up @@ -130,17 +161,21 @@
},
"childDiagram": {
"type": "object",
"description": "A nested child diagram — structurally a full diagram (schemaVersion, metadata.parentBlockId, groups, ...), so unknown keys are permitted",
"properties": {
"blocks": {
"type": "array",
"items": { "$ref": "#/properties/blocks/items" }
},
"connections": {
"type": "array",
"type": "array",
"items": { "$ref": "#/properties/connections/items" }
}
},
"schemaVersion": { "type": "string" },
"schema": { "type": "string" },
"metadata": { "type": "object", "additionalProperties": true }
},
"additionalProperties": false
"additionalProperties": true
},
"visualization3D": {
"type": "object",
Expand Down
51 changes: 20 additions & 31 deletions fsb_core/delta.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,37 +152,26 @@ def _diff_list_by_id(
path: str,
ops: list[dict[str, Any]],
) -> None:
old_map = {item["id"]: (i, item) for i, item in enumerate(old)}
new_map = {item["id"]: (i, item) for i, item in enumerate(new)}

# Removed items — iterate in reverse index order so that earlier
# removals don't shift the indices of later ones.
removed_indices = sorted(
(old_map[item_id][0] for item_id in old_map if item_id not in new_map),
reverse=True,
)
for idx in removed_indices:
ops.append({"op": "remove", "path": f"{path}/{idx}"})

# Added items
for item_id in new_map:
if item_id not in old_map:
idx = new_map[item_id][0]
ops.append(
{
"op": "add",
"path": f"{path}/{idx}",
"value": new_map[item_id][1],
}
)

# Modified items (compare by matching id) — use the *new* index
# so sub-patch paths target the correct element in the final list
# (after removals and additions have shifted positions).
for item_id in old_map:
if item_id in new_map:
new_idx = new_map[item_id][0]
_diff(old_map[item_id][1], new_map[item_id][1], f"{path}/{new_idx}", ops)
"""Diff two id-keyed lists.

Mirrors DeltaUtils._diffListById in ``src/utils/delta-utils.js``:
any membership change or reorder invalidates index-based sub-paths
(a modify emitted at a *new* index would land on whatever element
happens to sit at that index in the *old* list — silent data
corruption). In that case, emit a single whole-list replace.
Only when the id sequence is identical is an index-by-index diff
safe.
"""
old_ids = [item["id"] for item in old]
new_ids = [item["id"] for item in new]

if old_ids != new_ids:
ops.append({"op": "replace", "path": path, "value": new})
return

# Same id sequence: safe to diff element-by-element.
for i in range(len(old)):
_diff(old[i], new[i], f"{path}/{i}", ops)


def _diff_list_by_index(
Expand Down
6 changes: 4 additions & 2 deletions fsb_core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -730,8 +730,9 @@ def get_all_port_ids(self) -> set[str]:
def block_fingerprint(block: Block) -> str:
"""Compute a deterministic hash of a block's observable state.

The fingerprint covers name, type, position, status, attributes,
ports, and links — everything that would matter for a visual diff.
The fingerprint covers name, type, position, rotation, status,
attributes, ports, and links — everything that would matter for a
visual diff.

Args:
block: The block to fingerprint.
Expand All @@ -744,6 +745,7 @@ def block_fingerprint(block: Block) -> str:
"type": block.block_type,
"x": block.x,
"y": block.y,
"rotation": block.rotation,
"status": block.status.value,
"attributes": block.attributes,
"ports": [
Expand Down
80 changes: 80 additions & 0 deletions fsb_core/serialization.py
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,86 @@ def _parse_connection(data: dict[str, Any]) -> Connection:
)


#: Connection attributes that the JS editor stores as top-level keys but
#: :func:`graph_to_dict` folds into the ``attributes`` dict.
_JS_TOP_LEVEL_CONN_ATTRS = (
"arrowDirection",
"renderAsStub",
"label",
"labelOffset",
"labelOffsetY",
)


def flatten_connections_for_js(data: dict[str, Any]) -> dict[str, Any]:
"""Convert Graph-style nested connections to the flat JS editor format.

The JS diagram editor stores connections as
``{fromBlock, toBlock, type, arrowDirection, ...}`` while
:func:`graph_to_dict` emits
``{from: {blockId, interfaceId}, to: {...}, kind, attributes}``.
A document that passed through the Graph model (e.g. a legacy
snapshot) must be normalised before it is handed back to the JS
editor — its import step drops every connection that has no
``fromBlock``/``toBlock`` keys.

Connections already in flat format are returned unchanged, so the
function is safe to apply unconditionally.

Args:
data: Diagram dictionary in either format.

Returns:
A shallow-copied diagram whose connections all use the flat
JS format. The input is not mutated.
"""
connections = data.get("connections")
if not isinstance(connections, list) or not connections:
return data

def _flatten(conn: Any) -> Any:
if not isinstance(conn, dict):
return conn
from_data = conn.get("from")
to_data = conn.get("to")
if not isinstance(from_data, dict) and not isinstance(to_data, dict):
return conn # already flat

flat = {
key: value
for key, value in conn.items()
if key not in ("from", "to", "kind", "attributes")
}
if isinstance(from_data, dict):
flat["fromBlock"] = from_data.get("blockId", "")
# dict_to_graph funnels the JS port side (e.g. "output")
# into interfaceId, so restoring it as fromPort/toPort
# recovers the original value for JS-authored diagrams.
if from_data.get("interfaceId"):
flat["fromPort"] = from_data["interfaceId"]
if isinstance(to_data, dict):
flat["toBlock"] = to_data.get("blockId", "")
if to_data.get("interfaceId"):
flat["toPort"] = to_data["interfaceId"]
if "type" not in flat:
flat["type"] = conn.get("kind", "data")

attrs = conn.get("attributes") or {}
leftover = {}
for key, value in attrs.items():
if key in _JS_TOP_LEVEL_CONN_ATTRS and key not in flat:
flat[key] = value
else:
leftover[key] = value
if leftover:
flat["attributes"] = leftover
return flat

result = {**data}
result["connections"] = [_flatten(conn) for conn in connections]
return result


def convert_legacy_diagram(diagram: dict[str, Any]) -> Graph:
"""Convert a legacy diagram dictionary to a Graph.

Expand Down
13 changes: 12 additions & 1 deletion fsb_core/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ class ValidationError:
port_id: ID of the affected port, if applicable.
connection_id: ID of the affected connection, if applicable.
details: Additional error-specific context.
severity: ``"error"`` for structural problems that must be fixed,
``"warning"`` for findings that may be intentional design
choices (e.g. feedback loops, unnamed placeholder blocks).
"""

code: ValidationErrorCode
Expand All @@ -87,6 +90,7 @@ class ValidationError:
port_id: str | None = None
connection_id: str | None = None
details: dict[str, Any] = field(default_factory=dict)
severity: str = "error"

def to_dict(self) -> dict[str, Any]:
"""Convert error to dictionary for serialization.
Expand All @@ -101,6 +105,7 @@ def to_dict(self) -> dict[str, Any]:
"port_id": self.port_id,
"connection_id": self.connection_id,
"details": self.details,
"severity": self.severity,
}


Expand Down Expand Up @@ -179,6 +184,7 @@ def _validate_block_ids(graph: Graph) -> list[ValidationError]:
code=ValidationErrorCode.EMPTY_BLOCK_NAME,
message=f"Block with ID '{block.id}' has an empty name.",
block_id=block.id,
severity="warning",
)
)

Expand Down Expand Up @@ -659,12 +665,17 @@ def dfs(node: str, path: list[str]) -> bool:
code=ValidationErrorCode.CYCLE_DETECTED,
message=(
f"Cycle detected in graph involving blocks: "
f"{' -> '.join(cycle_names)}"
f"{' -> '.join(cycle_names)} "
f"(may be an intentional feedback loop)"
),
details={
"cycle_block_ids": cycle_blocks.copy(),
"cycle_block_names": cycle_names,
},
# Feedback loops are legitimate in real systems
# (control loops, bidirectional buses) — flag for
# review rather than as a structural error.
severity="warning",
)
)
break # Report first cycle found
Expand Down
Loading
Loading