@@ -264,6 +274,22 @@ function formatDate(dateStr) {
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
}
+.dualpath-banner {
+ margin-bottom: 16px;
+ padding: 10px 14px;
+ border-radius: 6px;
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-left: 4px solid var(--primary);
+ color: var(--text);
+ font-size: 0.9rem;
+}
+
+.dualpath-banner a {
+ color: var(--link);
+ font-weight: 600;
+}
+
.feature-tag {
display: inline-block;
padding: 0.3rem 0.625rem;
diff --git a/frontend/src/views/machines/MachinesList.vue b/frontend/src/views/machines/MachinesList.vue
index 23d8642..3147e8f 100644
--- a/frontend/src/views/machines/MachinesList.vue
+++ b/frontend/src/views/machines/MachinesList.vue
@@ -36,7 +36,9 @@
- | {{ item.assetnumber }} |
+
+ {{ item.assetnumber }} / {{ item.dualpathpartner.assetnumber }}
+ |
{{ item.name || '-' }} |
{{ item.serialnumber || '-' }} |
{{ item.machine?.machinetypename || '-' }} |
diff --git a/frontend/src/views/settings/SiteSettings.vue b/frontend/src/views/settings/SiteSettings.vue
index ae48730..c8e77f4 100644
--- a/frontend/src/views/settings/SiteSettings.vue
+++ b/frontend/src/views/settings/SiteSettings.vue
@@ -44,7 +44,8 @@ const LABELS = {
facility_name: 'Facility Name',
pc_access_domain: 'PC Access Domain',
employeeid_pattern: 'Employee ID Pattern',
- printer_hostname_template: 'Printer Hostname Template'
+ printer_hostname_template: 'Printer Hostname Template',
+ dualpath_single_machine: 'Dualpath as Single Machine'
}
function prettyLabel(key) {
return LABELS[key] || key
@@ -53,7 +54,8 @@ function prettyLabel(key) {
// Inline help for site fields that need more than the stored description.
const HELP = {
employeeid_pattern: 'Regular expression that a scanned/typed employee ID must match to be recognized. Default: ^\\d{9}$ (9 digits). An invalid regex is ignored and the default is used.',
- printer_hostname_template: 'Template for generating printer hostnames from an IP. Use {ip} where the dash-separated IP goes. Example: Printer-{ip}.printer.geaerospace.net'
+ printer_hostname_template: 'Template for generating printer hostnames from an IP. Use {ip} where the dash-separated IP goes. Example: Printer-{ip}.printer.geaerospace.net',
+ dualpath_single_machine: 'Treat a Dualpath pair (a dual-bay machine with one controller) as a single machine in lists, counts, and the floor map. Both bay records are always kept; detail pages stay per-bay with a sibling banner. Enter true or false. Default: true.'
}
function fieldHelp(key) {
return HELP[key] || ''
diff --git a/plugins/machines/api/routes.py b/plugins/machines/api/routes.py
index 53c192f..d9a0327 100644
--- a/plugins/machines/api/routes.py
+++ b/plugins/machines/api/routes.py
@@ -3,7 +3,7 @@
from flask import Blueprint, request
from flask_jwt_extended import jwt_required
-from shopdb.api import db, Asset, AssetType, Vendor, Model, AuditLog, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query
+from shopdb.api import db, Asset, AssetType, Vendor, Model, AuditLog, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query, resolve_dualpath_pairs, dualpath_single_machine_enabled
from ..models import Machine, MachineType
@@ -213,18 +213,61 @@ def list_machines():
query = query.order_by(col.desc() if sort_dir == 'desc' else col)
+ # Dualpath single-machine collapse: hide the secondary bay so a dual-bay
+ # pair lists (and paginates) as one machine. Excluding before pagination
+ # keeps totals honest. Gated on the site setting (default on).
+ collapse = None
+ if dualpath_single_machine_enabled():
+ collapse = resolve_dualpath_pairs()
+ if collapse.secondaryassetids:
+ query = query.filter(Asset.assetid.notin_(collapse.secondaryassetids))
+
items, total = paginate_query(query, page, per_page)
+ # Resolve partner machineids for this page's primaries in one query.
+ machineidbyasset = {}
+ if collapse:
+ partnerassetids = [
+ collapse.partnerbyasset[m.assetid]['assetid']
+ for m in items if m.assetid in collapse.partnerbyasset
+ ]
+ if partnerassetids:
+ for partner in Machine.query.filter(
+ Machine.assetid.in_(partnerassetids)).all():
+ machineidbyasset[partner.assetid] = partner.machineid
+
# Build response with both asset and machine data
data = []
for mach in items:
item = mach.asset.to_dict() if mach.asset else {}
item['machine'] = mach.to_dict()
+ # annotate the visible bay with its hidden partner (for '2007 / 2008')
+ partner = collapse.partnerbyasset.get(mach.assetid) if collapse else None
+ item['dualpathpartner'] = {
+ 'assetid': partner['assetid'],
+ 'machineid': machineidbyasset.get(partner['assetid']),
+ 'assetnumber': partner['assetnumber'],
+ } if partner else None
data.append(item)
return paginated_response(data, page, per_page, total)
+def _dualpath_partner_for(assetid):
+ """Resolve this machine's Dualpath sibling, or None. Always evaluated
+ (independent of the collapse toggle) so the detail-page banner shows even
+ when a site lists both bays. Returns {assetid, machineid, assetnumber}."""
+ partner = resolve_dualpath_pairs().partnerbyasset.get(assetid)
+ if not partner:
+ return None
+ partnermach = Machine.query.filter_by(assetid=partner['assetid']).first()
+ return {
+ 'assetid': partner['assetid'],
+ 'machineid': partnermach.machineid if partnermach else None,
+ 'assetnumber': partner['assetnumber'],
+ }
+
+
@machines_bp.route('/', methods=['GET'])
@jwt_required(optional=True)
def get_machine(machine_id: int):
@@ -240,6 +283,7 @@ def get_machine(machine_id: int):
result = mach.asset.to_dict() if mach.asset else {}
result['machine'] = mach.to_dict()
+ result['dualpathpartner'] = _dualpath_partner_for(mach.assetid)
return success_response(result)
@@ -259,6 +303,7 @@ def get_machine_by_asset(asset_id: int):
result = mach.asset.to_dict() if mach.asset else {}
result['machine'] = mach.to_dict()
+ result['dualpathpartner'] = _dualpath_partner_for(mach.assetid)
return success_response(result)
@@ -462,20 +507,30 @@ def delete_machine(machine_id: int):
@jwt_required(optional=True)
def dashboard_summary():
"""Get machine dashboard summary data."""
+ # Dualpath single-machine collapse: hide the secondary bay from counts so a
+ # dual-bay pair counts once. Gated on the site setting (default on).
+ secondaryassetids = set()
+ if dualpath_single_machine_enabled():
+ secondaryassetids = resolve_dualpath_pairs().secondaryassetids
+
# Total active machine count
- total = db.session.query(Machine).join(Asset).filter(
+ total_query = db.session.query(Machine).join(Asset).filter(
Asset.isactive == True
- ).count()
+ )
+ if secondaryassetids:
+ total_query = total_query.filter(Asset.assetid.notin_(secondaryassetids))
+ total = total_query.count()
# Count by machine type
- by_type = db.session.query(
+ by_type_query = db.session.query(
MachineType.machinetype,
db.func.count(Machine.machineid)
).join(Machine, Machine.machinetypeid == MachineType.machinetypeid
).join(Asset, Asset.assetid == Machine.assetid
- ).filter(Asset.isactive == True
- ).group_by(MachineType.machinetype
- ).all()
+ ).filter(Asset.isactive == True)
+ if secondaryassetids:
+ by_type_query = by_type_query.filter(Asset.assetid.notin_(secondaryassetids))
+ by_type = by_type_query.group_by(MachineType.machinetype).all()
# Count by status
from shopdb.api import AssetStatus
diff --git a/shopdb/__init__.py b/shopdb/__init__.py
index a4777a0..d0684e4 100644
--- a/shopdb/__init__.py
+++ b/shopdb/__init__.py
@@ -23,7 +23,11 @@ from .plugins import plugin_manager
# 0.7.0: added the four ADR-010 frontend-contribution hooks (get_settings_cards,
# get_asset_panels, get_map_overlays, get_asset_presentation), consumed by the
# GET /api/pluginui/* endpoints. Four additive optional hooks, one minor bump.
-__contract_version__ = '0.8.0'
+# 0.9.0: added the dualpath single-machine collapse helpers to shopdb.api
+# (resolve_dualpath_pairs, dualpath_single_machine_enabled), consumed by the
+# machines plugin list/detail to collapse dual-bay pairs. Two additive names,
+# minor bump.
+__contract_version__ = '0.9.0'
# Product release version (see ADR-007). The product version and the
# plugin-contract version above are distinct series with independent
diff --git a/shopdb/api/__init__.py b/shopdb/api/__init__.py
index ccae455..8ce20ae 100644
--- a/shopdb/api/__init__.py
+++ b/shopdb/api/__init__.py
@@ -65,6 +65,12 @@ from shopdb.utils.import_mode import (
parse_import_datetime,
)
+# Dualpath single-machine collapse (a dual-bay pair is one physical machine)
+from shopdb.core.services.dualpath import (
+ resolve_dualpath_pairs,
+ dualpath_single_machine_enabled,
+)
+
# Legacy employee directory lookup (read-only) used by notifications
from shopdb.utils.employee_db import employee_connection
@@ -214,6 +220,8 @@ __all__ = [
# Helpers
'audit_log',
'resolve_asset_position',
+ 'resolve_dualpath_pairs',
+ 'dualpath_single_machine_enabled',
# Infrastructure
'db',
'cache',
diff --git a/shopdb/core/api/assets.py b/shopdb/core/api/assets.py
index cd16d15..340a835 100644
--- a/shopdb/core/api/assets.py
+++ b/shopdb/core/api/assets.py
@@ -920,6 +920,18 @@ def get_assets_map():
Asset.mapy.isnot(None)
)
+ # Dualpath single-machine collapse: hide the secondary bay marker so a
+ # dual-bay pair shows one marker; the primary carries the partner label.
+ # Gated on the site setting (default on).
+ from shopdb.core.services.dualpath import (
+ resolve_dualpath_pairs, dualpath_single_machine_enabled)
+ dualpath_collapse = None
+ if dualpath_single_machine_enabled():
+ dualpath_collapse = resolve_dualpath_pairs()
+ if dualpath_collapse.secondaryassetids:
+ query = query.filter(
+ Asset.assetid.notin_(dualpath_collapse.secondaryassetids))
+
selected_assettype = request.args.get('assettype')
# Filter by asset type name
@@ -1037,6 +1049,11 @@ def get_assets_map():
if type_data:
item['typedata'] = type_data
+ # combined-label partner for a collapsed dual-bay pair (label only)
+ if dualpath_collapse:
+ partner = dualpath_collapse.partnerbyasset.get(asset.assetid)
+ item['dualpathpartner'] = partner['assetnumber'] if partner else None
+
data.append(item)
# Get filter options - these are small reference tables, no N+1 concern
diff --git a/shopdb/core/api/dashboard.py b/shopdb/core/api/dashboard.py
index a97e30c..79cac0c 100644
--- a/shopdb/core/api/dashboard.py
+++ b/shopdb/core/api/dashboard.py
@@ -19,10 +19,21 @@ _TYPE_CATEGORY = {
def _count_by_type(assettype):
- return db.session.query(Asset).join(AssetType).filter(
+ query = db.session.query(Asset).join(AssetType).filter(
Asset.isactive == True,
AssetType.assettype == assettype
- ).count()
+ )
+ # Dualpath single-machine collapse: subtract the hidden secondary bays so a
+ # dual-bay machine counts once. Only machines are ever Dualpath-paired, but
+ # the AssetType filter above keeps this correct for any type. Default on.
+ if assettype == 'machine':
+ from shopdb.core.services.dualpath import (
+ resolve_dualpath_pairs, dualpath_single_machine_enabled)
+ if dualpath_single_machine_enabled():
+ secondaryassetids = resolve_dualpath_pairs().secondaryassetids
+ if secondaryassetids:
+ query = query.filter(Asset.assetid.notin_(secondaryassetids))
+ return query.count()
@dashboard_bp.route('/summary', methods=['GET'])
diff --git a/shopdb/core/api/reports.py b/shopdb/core/api/reports.py
index 2315532..73c8f4d 100644
--- a/shopdb/core/api/reports.py
+++ b/shopdb/core/api/reports.py
@@ -64,6 +64,17 @@ def machines_by_type():
if bu_id := request.args.get('businessunitid'):
query = query.filter(Asset.businessunitid == int(bu_id))
+ # Dualpath single-machine collapse: exclude the secondary bay so a dual-bay
+ # pair counts once per type bucket. Gated on the site setting (default on).
+ from shopdb.core.services.dualpath import (
+ resolve_dualpath_pairs, dualpath_single_machine_enabled)
+ if dualpath_single_machine_enabled():
+ secondaryassetids = resolve_dualpath_pairs().secondaryassetids
+ if secondaryassetids:
+ query = query.filter(
+ db.or_(Machine.assetid.is_(None),
+ Machine.assetid.notin_(secondaryassetids)))
+
query = query.group_by(
MachineType.machinetypeid,
MachineType.machinetype,
diff --git a/shopdb/core/api/settings.py b/shopdb/core/api/settings.py
index 85673b2..34c173d 100644
--- a/shopdb/core/api/settings.py
+++ b/shopdb/core/api/settings.py
@@ -418,6 +418,13 @@ def build_default_settings():
'category': 'site',
'description': 'Regex a search term must match to be treated as an employee id. Invalid regex falls back to the default and never errors.'
},
+ {
+ 'key': 'dualpath_single_machine',
+ 'value': 'true',
+ 'valuetype': 'boolean',
+ 'category': 'site',
+ 'description': 'Treat a Dualpath pair (a dual-bay machine with one controller) as a single machine in lists, counts, and the floor map. The data model always keeps both bay records; detail pages stay per-bay with a sibling banner. Off = list and count both bays separately.'
+ },
{
'key': 'printer_hostname_template',
'value': 'Printer-{ip}.printer.geaerospace.net',
diff --git a/shopdb/core/services/dualpath.py b/shopdb/core/services/dualpath.py
new file mode 100644
index 0000000..5370a35
--- /dev/null
+++ b/shopdb/core/services/dualpath.py
@@ -0,0 +1,116 @@
+"""Dualpath single-machine collapse resolution.
+
+A Dualpath relationship pair is ONE physical dual-bay machine (single
+controller, one bay-selector switch) recorded as two asset rows. Most
+facilities want lists, counts, and the map to show that pair as a single
+machine; the data model always keeps both rows. This module resolves the
+pairs so the consumers (machines list, dashboard/report counts, floor map)
+can collapse them, and is exposed via the plugin contract surface
+(shopdb.api) so the machines plugin can reach it contract-purely.
+
+PRIMARY = the pair member with the lower natural-sort assetnumber; the other
+member is SECONDARY and is the one hidden when collapsing. Pairs are derived
+from active assetrelationships rows whose type is named 'Dualpath' (either
+direction; mirrored rows in both directions collapse to one pair). Only pairs
+where BOTH assets are active are resolved.
+"""
+
+import re
+from collections import namedtuple
+
+from shopdb.extensions import db
+from shopdb.core.models import Asset, AssetRelationship, RelationshipType
+
+# secondaryassetids: set of the non-primary bay asset ids (hide when collapsing)
+# partnerbyasset: {assetid -> {'assetid', 'assetnumber'}} for EVERY pair member,
+# primary and secondary alike, so detail pages can show a sibling
+# banner from whichever bay you land on.
+DualpathCollapse = namedtuple('DualpathCollapse', ['secondaryassetids', 'partnerbyasset'])
+
+DUALPATH_TYPE_NAME = 'Dualpath'
+
+
+def _naturalkey(assetnumber):
+ # split into digit/non-digit chunks so 2007 sorts before 2008 and before 10a
+ parts = re.split(r'(\d+)', assetnumber or '')
+ return [int(p) if p.isdigit() else p.lower() for p in parts]
+
+
+def resolve_dualpath_pairs():
+ """Resolve active Dualpath pairs into a DualpathCollapse.
+
+ Direction-blind and dedup-safe: rows stored in either direction (or both)
+ for the same two assets collapse to one pair. Ignores the site toggle;
+ callers gate on dualpath_single_machine_enabled() where the collapse should
+ only apply when the setting is on (the detail-page banner shows always).
+ """
+ reltype = RelationshipType.query.filter_by(
+ relationshiptype=DUALPATH_TYPE_NAME).first()
+ if not reltype:
+ return DualpathCollapse(set(), {})
+
+ rows = AssetRelationship.query.filter(
+ AssetRelationship.relationshiptypeid == reltype.relationshiptypeid,
+ AssetRelationship.isactive == True,
+ ).all()
+ if not rows:
+ return DualpathCollapse(set(), {})
+
+ # batch-load the involved assets (assetnumber + active flag) in one query
+ involved = set()
+ for row in rows:
+ involved.add(row.sourceassetid)
+ involved.add(row.targetassetid)
+ assetbyid = {
+ a.assetid: a
+ for a in Asset.query.filter(Asset.assetid.in_(involved)).all()
+ }
+
+ secondaryassetids = set()
+ partnerbyasset = {}
+ seenpairs = set()
+ for row in rows:
+ aid, bid = row.sourceassetid, row.targetassetid
+ if aid == bid:
+ continue # defensive: no self-pairs
+ pairkey = frozenset((aid, bid))
+ if pairkey in seenpairs:
+ continue # mirrored row already handled
+ seenpairs.add(pairkey)
+
+ aone = assetbyid.get(aid)
+ atwo = assetbyid.get(bid)
+ # collapse only affects visible/counted (active) assets
+ if not aone or not atwo or not aone.isactive or not atwo.isactive:
+ continue
+
+ # PRIMARY = lower natural-sort assetnumber; SECONDARY is the other bay
+ if _naturalkey(aone.assetnumber) <= _naturalkey(atwo.assetnumber):
+ primary, secondary = aone, atwo
+ else:
+ primary, secondary = atwo, aone
+
+ secondaryassetids.add(secondary.assetid)
+ partnerbyasset[primary.assetid] = {
+ 'assetid': secondary.assetid,
+ 'assetnumber': secondary.assetnumber,
+ }
+ partnerbyasset[secondary.assetid] = {
+ 'assetid': primary.assetid,
+ 'assetnumber': primary.assetnumber,
+ }
+
+ return DualpathCollapse(secondaryassetids, partnerbyasset)
+
+
+def dualpath_single_machine_enabled():
+ """True when the site treats Dualpath pairs as one machine (default true).
+
+ Reads the cached settings; the row is absent on an un-seeded site, in which
+ case the default (true - 'most places' consider a dual-bay pair one machine)
+ applies.
+ """
+ # imported here to avoid a settings<->api import cycle at module load
+ from shopdb.core.api.settings import get_cached_settings
+ settings = get_cached_settings()
+ return bool(settings.get('dualpath_single_machine', True))
diff --git a/tests/test_core/test_relationship_propagation.py b/tests/test_core/test_relationship_propagation.py
index da4b84e..c9611cb 100644
--- a/tests/test_core/test_relationship_propagation.py
+++ b/tests/test_core/test_relationship_propagation.py
@@ -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
diff --git a/tests/test_plugins/test_dualpath_single.py b/tests/test_plugins/test_dualpath_single.py
new file mode 100644
index 0000000..551d498
--- /dev/null
+++ b/tests/test_plugins/test_dualpath_single.py
@@ -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