Add the dualpath-as-single-machine site toggle
Most facilities consider a Dualpath pair one physical dual-bay machine. New site setting dualpath_single_machine (default on): the machines list, dashboard counts, machines-by-type report, and floor map collapse each pair to its primary bay (lower assetnumber), with combined 2007 / 2008 labels; pagination totals stay honest. Detail pages remain per-bay and always show a dual-bay sibling banner linking the partner. Pair resolution lives in core services and joins the plugin contract surface (0.8.0 -> 0.9.0). On the WJ dataset: 31 pairs collapse, machine counts 262 -> 231, map 470 assets. Toggle verified live in both states, left on. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -247,3 +247,49 @@ def test_cli_backfill_propagates_and_is_idempotent(client, db, auth_headers, run
|
||||
sourceassetid=pc.assetid, targetassetid=bayb.assetid,
|
||||
relationshiptypeid=controls.relationshiptypeid).count()
|
||||
assert count == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI fix-controls-direction flips reversed legacy rows to PC -> machine
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_cli_fix_controls_direction(client, db, auth_headers, runner):
|
||||
atype, controls, dualpath, partof = _setup_types()
|
||||
ctype = AssetType(assettype='computer')
|
||||
db.session.add(ctype)
|
||||
db.session.flush()
|
||||
|
||||
pc = _make_asset('PC-7', ctype.assettypeid)
|
||||
pc2 = _make_asset('PC-8', ctype.assettypeid)
|
||||
bay = _make_asset('BAY-7', atype.assettypeid)
|
||||
bay2 = _make_asset('BAY-8', atype.assettypeid)
|
||||
|
||||
# reversed legacy row: machine -> PC
|
||||
db.session.add(AssetRelationship(sourceassetid=bay.assetid, targetassetid=pc.assetid,
|
||||
relationshiptypeid=controls.relationshiptypeid))
|
||||
# reversed row whose flip already exists -> reversed one gets deactivated
|
||||
db.session.add(AssetRelationship(sourceassetid=bay2.assetid, targetassetid=pc2.assetid,
|
||||
relationshiptypeid=controls.relationshiptypeid))
|
||||
db.session.add(AssetRelationship(sourceassetid=pc2.assetid, targetassetid=bay2.assetid,
|
||||
relationshiptypeid=controls.relationshiptypeid))
|
||||
db.session.commit()
|
||||
|
||||
result = runner.invoke(args=['relationships', 'fix-controls-direction'])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert 'Flipped 1' in result.output
|
||||
assert '1 reversed duplicate(s) deactivated' in result.output
|
||||
|
||||
# first row now reads PC -> machine
|
||||
assert _rel_exists(pc.assetid, bay.assetid, controls.relationshiptypeid)
|
||||
assert not _rel_exists(bay.assetid, pc.assetid, controls.relationshiptypeid)
|
||||
# duplicate pair: canonical row stays active, reversed one deactivated
|
||||
rev = AssetRelationship.query.filter_by(
|
||||
sourceassetid=bay2.assetid, targetassetid=pc2.assetid,
|
||||
relationshiptypeid=controls.relationshiptypeid).first()
|
||||
assert rev is not None and rev.isactive is False
|
||||
|
||||
# idempotent: second run finds nothing
|
||||
result2 = runner.invoke(args=['relationships', 'fix-controls-direction'])
|
||||
assert result2.exit_code == 0, result2.output
|
||||
assert 'Flipped 0' in result2.output
|
||||
assert '0 reversed duplicate(s) deactivated' in result2.output
|
||||
|
||||
163
tests/test_plugins/test_dualpath_single.py
Normal file
163
tests/test_plugins/test_dualpath_single.py
Normal file
@@ -0,0 +1,163 @@
|
||||
"""Dualpath single-machine collapse (site setting dualpath_single_machine).
|
||||
|
||||
A Dualpath pair is one physical dual-bay machine recorded as two asset rows.
|
||||
With the setting on (default), the machines list, dashboard machine count, and
|
||||
the floor map collapse the pair to the PRIMARY bay (lower natural-sort
|
||||
assetnumber) and annotate it with its hidden partner. With the setting off,
|
||||
both bays list and count separately. The data model always keeps both rows.
|
||||
|
||||
Settings are cached, so each test invalidates the cache after seeding.
|
||||
"""
|
||||
|
||||
from shopdb.extensions import db as _db
|
||||
from shopdb.core.models import Asset, AssetType, Setting
|
||||
from shopdb.core.models.relationship import RelationshipType, AssetRelationship
|
||||
from shopdb.core.api.settings import invalidate_settings_cache
|
||||
|
||||
from plugins.machines.models import Machine
|
||||
|
||||
|
||||
def _seed_dualpath_pair():
|
||||
"""Create a 'machine' AssetType, two machine assets (2007, 2008) placed on
|
||||
the map, and an active Dualpath relationship between them. 2007 is PRIMARY
|
||||
(lower natural-sort). Returns (primary_machine, secondary_machine)."""
|
||||
machinetype = AssetType(assettype='machine')
|
||||
_db.session.add(machinetype)
|
||||
_db.session.flush()
|
||||
|
||||
dualpath = RelationshipType(relationshiptype='Dualpath', isdirectional=False)
|
||||
_db.session.add(dualpath)
|
||||
_db.session.flush()
|
||||
|
||||
primary_asset = Asset(assetnumber='2007', assettypeid=machinetype.assettypeid,
|
||||
mapx=100, mapy=100)
|
||||
secondary_asset = Asset(assetnumber='2008', assettypeid=machinetype.assettypeid,
|
||||
mapx=110, mapy=110)
|
||||
_db.session.add_all([primary_asset, secondary_asset])
|
||||
_db.session.flush()
|
||||
|
||||
primary_machine = Machine(assetid=primary_asset.assetid)
|
||||
secondary_machine = Machine(assetid=secondary_asset.assetid)
|
||||
_db.session.add_all([primary_machine, secondary_machine])
|
||||
_db.session.flush()
|
||||
|
||||
_db.session.add(AssetRelationship(
|
||||
sourceassetid=primary_asset.assetid,
|
||||
targetassetid=secondary_asset.assetid,
|
||||
relationshiptypeid=dualpath.relationshiptypeid,
|
||||
))
|
||||
_db.session.commit()
|
||||
return primary_machine, secondary_machine
|
||||
|
||||
|
||||
def _set_toggle(value):
|
||||
Setting.set('dualpath_single_machine', value, valuetype='boolean',
|
||||
category='site')
|
||||
invalidate_settings_cache()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Setting ON (default): pair collapses to the primary bay
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_list_collapses_secondary_and_annotates_primary(client, db):
|
||||
invalidate_settings_cache() # default on (no row)
|
||||
_seed_dualpath_pair()
|
||||
|
||||
resp = client.get('/api/machines')
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
payload = resp.get_json()
|
||||
items = payload['data']
|
||||
numbers = [i['assetnumber'] for i in items]
|
||||
|
||||
# secondary bay (2008) is hidden; total reflects the collapse
|
||||
assert numbers == ['2007']
|
||||
assert payload['meta']['pagination']['total'] == 1
|
||||
|
||||
# the visible primary carries its partner
|
||||
partner = items[0]['dualpathpartner']
|
||||
assert partner is not None
|
||||
assert partner['assetnumber'] == '2008'
|
||||
assert partner['assetid'] is not None
|
||||
assert partner['machineid'] is not None
|
||||
|
||||
|
||||
def test_dashboard_machine_count_collapses(client, db):
|
||||
invalidate_settings_cache()
|
||||
_seed_dualpath_pair()
|
||||
|
||||
resp = client.get('/api/dashboard')
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
counts = resp.get_json()['data']
|
||||
# two machine rows, one physical machine
|
||||
assert counts['totalmachines'] == 1
|
||||
assert counts['counts']['machines'] == 1
|
||||
|
||||
|
||||
def test_map_excludes_secondary_marker(client, db):
|
||||
invalidate_settings_cache()
|
||||
_seed_dualpath_pair()
|
||||
|
||||
resp = client.get('/api/assets/map')
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assets = resp.get_json()['data']['assets']
|
||||
numbers = [a['assetnumber'] for a in assets]
|
||||
|
||||
assert '2007' in numbers
|
||||
assert '2008' not in numbers
|
||||
primary = next(a for a in assets if a['assetnumber'] == '2007')
|
||||
assert primary['dualpathpartner'] == '2008'
|
||||
|
||||
|
||||
def test_detail_banner_partner_shown_regardless_of_toggle(client, db):
|
||||
invalidate_settings_cache()
|
||||
primary_machine, secondary_machine = _seed_dualpath_pair()
|
||||
|
||||
# banner data is present on BOTH bays even with the toggle off
|
||||
_set_toggle(False)
|
||||
for machine_id, expected in (
|
||||
(primary_machine.machineid, '2008'),
|
||||
(secondary_machine.machineid, '2007'),
|
||||
):
|
||||
resp = client.get(f'/api/machines/{machine_id}')
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
partner = resp.get_json()['data']['dualpathpartner']
|
||||
assert partner is not None
|
||||
assert partner['assetnumber'] == expected
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Setting OFF: both bays list and count separately
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_list_shows_both_bays_when_disabled(client, db):
|
||||
_seed_dualpath_pair()
|
||||
_set_toggle(False)
|
||||
|
||||
resp = client.get('/api/machines')
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
payload = resp.get_json()
|
||||
numbers = sorted(i['assetnumber'] for i in payload['data'])
|
||||
|
||||
assert numbers == ['2007', '2008']
|
||||
assert payload['meta']['pagination']['total'] == 2
|
||||
|
||||
|
||||
def test_dashboard_counts_both_bays_when_disabled(client, db):
|
||||
_seed_dualpath_pair()
|
||||
_set_toggle(False)
|
||||
|
||||
resp = client.get('/api/dashboard')
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert resp.get_json()['data']['totalmachines'] == 2
|
||||
|
||||
|
||||
def test_map_shows_both_bays_when_disabled(client, db):
|
||||
_seed_dualpath_pair()
|
||||
_set_toggle(False)
|
||||
|
||||
resp = client.get('/api/assets/map')
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
numbers = [a['assetnumber'] for a in resp.get_json()['data']['assets']]
|
||||
assert '2007' in numbers
|
||||
assert '2008' in numbers
|
||||
Reference in New Issue
Block a user