Phase 7A: wire ADR-001 asset position contract surface
Lock the position-resolution columns from ADR-001 in code so
resolve_asset_position's relationship walk activates.
Schema
- Asset.mapleft -> Asset.mapx, Asset.maptop -> Asset.mapy
- Location.mapx / Location.mapy added (fallback for priority 3 of the
ADR-001 resolution chain)
- AssetRelationship.label (free-text nuance per ADR-001)
- AssetRelationship.inheritsposition (bool, server_default true, controls
whether the resolved-position walk follows the edge)
- RelationshipType.propagatesthroughid (self-FK; sibling-propagation rail)
Seeds
- Three canonical ADR-001 relationship types created idempotently:
partof, controls, connectedto
- controls.propagatesthroughid wired to partof (partof + connectedto stay
null per ADR-001 table). Both via Alembic migration AND CLI seed command
so a fresh test fixture and a sister-site deploy both end up correct.
- Legacy connection types (Serial Cable, Direct Ethernet, USB, WiFi,
Dualpath) retained for backward compat with pre-1.0 relationship rows.
Resolver
- shopdb.api.resolve_asset_position now walks inheritsposition=true edges
of type partof (then controls), recursively, depth-capped at 3 with
visited-set cycle protection. Inactive edges + non-inheritable types
are skipped. Falls through to the existing location fallback when the
walk yields nothing.
Tests
- 11 new test_api_namespace cases cover: partof walk, controls-after-
partof ordering, connectedto skipped, inheritsposition=false skipped,
recursion, cycle break, depth-3 cap, self-beats-related, related-beats-
location, inactive-edge skip.
- 111 tests pass. Naming/style check green.
Migration
- migrations/versions/7a01_adr001_position_contract.py:
- alter_column renames on assets (no data loss)
- add_column on locations + relationshiptypes + assetrelationships
- idempotent seed of three ADR types + propagation FK wire-up
- downgrade reverses + best-effort deletion of seeded types that have
no FK refs
Backend rename (mapleft/maptop -> mapx/mapy)
- shopdb/core/api/assets.py
- plugins/{computers,equipment,network,printers}/api/...
- scripts/migration/migrate_assets.py
- Legacy Machine model + machines API + import_from_mysql.py UNCHANGED
(per ADR-001 Machine retires; not part of the asset contract)
Frontend rename
- frontend/src/components/ShopFloorMap.vue
- frontend/src/views/{MapEditor.vue, pcs/{PCDetail,PCForm}.vue,
printers/{PrinterDetail,PrinterForm}.vue,
machines/{MachineDetail,MachineForm}.vue,
network/NetworkDeviceForm.vue}
- Form field labels + v-model bindings + computed flags switched in
lockstep with the backend.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -84,6 +84,149 @@ def test_resolve_asset_position_handles_asset_without_mapx_attr():
|
||||
assert result == {'mapx': 1, 'mapy': 2, 'positionsource': 'location'}
|
||||
|
||||
|
||||
# --- Relationship-walk path (priority 2 in the chain) ----------------------
|
||||
|
||||
def _make_rel(rtype_name, neighbor, inheritsposition=True, isactive=True):
|
||||
"""Build a fake AssetRelationship-shaped object pointing at neighbor.
|
||||
`neighbor` is wired as both source and target so the walk helper finds
|
||||
it regardless of direction; tests pick which list to attach it to."""
|
||||
class FakeRelType:
|
||||
relationshiptype = rtype_name
|
||||
|
||||
class FakeRel:
|
||||
pass
|
||||
|
||||
r = FakeRel()
|
||||
r.relationshiptype = FakeRelType()
|
||||
r.inheritsposition = inheritsposition
|
||||
r.isactive = isactive
|
||||
r.targetasset = neighbor
|
||||
r.sourceasset = neighbor
|
||||
return r
|
||||
|
||||
|
||||
def _make_asset(assetid, mapx=None, mapy=None, outgoing=None, incoming=None, location=None):
|
||||
class FakeAsset:
|
||||
pass
|
||||
|
||||
a = FakeAsset()
|
||||
a.assetid = assetid
|
||||
a.mapx = mapx
|
||||
a.mapy = mapy
|
||||
a.outgoing_relationships = outgoing or []
|
||||
a.incoming_relationships = incoming or []
|
||||
a.location = location
|
||||
return a
|
||||
|
||||
|
||||
def test_resolve_asset_position_walks_partof_edge():
|
||||
"""Priority 2: inheritsposition=true partof edge resolves from neighbor."""
|
||||
parent = _make_asset(assetid=2, mapx=300, mapy=400)
|
||||
rel = _make_rel('partof', parent)
|
||||
child = _make_asset(assetid=1, outgoing=[rel])
|
||||
|
||||
result = resolve_asset_position(child)
|
||||
assert result == {'mapx': 300, 'mapy': 400, 'positionsource': 'related'}
|
||||
|
||||
|
||||
def test_resolve_asset_position_walks_controls_after_partof():
|
||||
"""Priority 2 ordering: partof beats controls when both have coords."""
|
||||
partof_neighbor = _make_asset(assetid=10, mapx=11, mapy=12)
|
||||
controls_neighbor = _make_asset(assetid=20, mapx=99, mapy=99)
|
||||
rel_partof = _make_rel('partof', partof_neighbor)
|
||||
rel_controls = _make_rel('controls', controls_neighbor)
|
||||
asset = _make_asset(assetid=1, outgoing=[rel_controls, rel_partof])
|
||||
|
||||
result = resolve_asset_position(asset)
|
||||
assert result == {'mapx': 11, 'mapy': 12, 'positionsource': 'related'}
|
||||
|
||||
|
||||
def test_resolve_asset_position_skips_non_inheritable_type():
|
||||
"""connectedto edges are never walked even if inheritsposition is true."""
|
||||
neighbor = _make_asset(assetid=2, mapx=5, mapy=6)
|
||||
rel = _make_rel('connectedto', neighbor, inheritsposition=True)
|
||||
asset = _make_asset(assetid=1, outgoing=[rel])
|
||||
|
||||
assert resolve_asset_position(asset) is None
|
||||
|
||||
|
||||
def test_resolve_asset_position_skips_when_inheritsposition_false():
|
||||
"""An edge with inheritsposition=false is not walked."""
|
||||
neighbor = _make_asset(assetid=2, mapx=5, mapy=6)
|
||||
rel = _make_rel('partof', neighbor, inheritsposition=False)
|
||||
asset = _make_asset(assetid=1, outgoing=[rel])
|
||||
|
||||
assert resolve_asset_position(asset) is None
|
||||
|
||||
|
||||
def test_resolve_asset_position_walks_recursively():
|
||||
"""The walk recurses: child -> middle -> root, where only root has coords."""
|
||||
root = _make_asset(assetid=3, mapx=1, mapy=2)
|
||||
middle = _make_asset(assetid=2, outgoing=[_make_rel('partof', root)])
|
||||
child = _make_asset(assetid=1, outgoing=[_make_rel('partof', middle)])
|
||||
|
||||
result = resolve_asset_position(child)
|
||||
assert result == {'mapx': 1, 'mapy': 2, 'positionsource': 'related'}
|
||||
|
||||
|
||||
def test_resolve_asset_position_breaks_cycles():
|
||||
"""A cycle A<->B with no coords anywhere returns None without recursing
|
||||
forever."""
|
||||
a = _make_asset(assetid=1)
|
||||
b = _make_asset(assetid=2)
|
||||
a.outgoing_relationships = [_make_rel('partof', b)]
|
||||
b.outgoing_relationships = [_make_rel('partof', a)]
|
||||
|
||||
assert resolve_asset_position(a) is None
|
||||
|
||||
|
||||
def test_resolve_asset_position_depth_cap_is_three():
|
||||
"""Past depth 3 the walk gives up. Build a chain of 5 nodes where only
|
||||
the last has coords; expect None."""
|
||||
coords_node = _make_asset(assetid=5, mapx=99, mapy=99)
|
||||
n4 = _make_asset(assetid=4, outgoing=[_make_rel('partof', coords_node)])
|
||||
n3 = _make_asset(assetid=3, outgoing=[_make_rel('partof', n4)])
|
||||
n2 = _make_asset(assetid=2, outgoing=[_make_rel('partof', n3)])
|
||||
root = _make_asset(assetid=1, outgoing=[_make_rel('partof', n2)])
|
||||
|
||||
assert resolve_asset_position(root) is None
|
||||
|
||||
|
||||
def test_resolve_asset_position_self_beats_related():
|
||||
"""Priority 1 beats priority 2: asset's own coords win even when a
|
||||
related neighbor would also resolve."""
|
||||
neighbor = _make_asset(assetid=2, mapx=99, mapy=99)
|
||||
rel = _make_rel('partof', neighbor)
|
||||
asset = _make_asset(assetid=1, mapx=1, mapy=2, outgoing=[rel])
|
||||
|
||||
result = resolve_asset_position(asset)
|
||||
assert result == {'mapx': 1, 'mapy': 2, 'positionsource': 'self'}
|
||||
|
||||
|
||||
def test_resolve_asset_position_related_beats_location():
|
||||
"""Priority 2 beats priority 3: a related neighbor's coords win over
|
||||
the asset's location coords."""
|
||||
class FakeLocation:
|
||||
mapx = 500
|
||||
mapy = 600
|
||||
|
||||
neighbor = _make_asset(assetid=2, mapx=10, mapy=20)
|
||||
rel = _make_rel('partof', neighbor)
|
||||
asset = _make_asset(assetid=1, outgoing=[rel], location=FakeLocation())
|
||||
|
||||
result = resolve_asset_position(asset)
|
||||
assert result == {'mapx': 10, 'mapy': 20, 'positionsource': 'related'}
|
||||
|
||||
|
||||
def test_resolve_asset_position_inactive_edge_skipped():
|
||||
"""Soft-deleted (isactive=false) relationships are not walked."""
|
||||
neighbor = _make_asset(assetid=2, mapx=5, mapy=6)
|
||||
rel = _make_rel('partof', neighbor, isactive=False)
|
||||
asset = _make_asset(assetid=1, outgoing=[rel])
|
||||
|
||||
assert resolve_asset_position(asset) is None
|
||||
|
||||
|
||||
def test_plugin_get_setting_returns_default_when_unset(db, app):
|
||||
"""A plugin reading an unset setting gets the default."""
|
||||
from shopdb.plugins import plugin_manager
|
||||
|
||||
Reference in New Issue
Block a user