Buildings and levels for the floor map, and make every identifier searchable
Some checks failed
CI / backend (push) Failing after 9s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 10s
CI / migrations-mysql (push) Failing after 7s

The map was one picture of one floor. A second floor was added, the blueprint
changed size, and machines moved, so a position now records WHICH DRAWING its
coordinates belong to.

Buildings and levels (ADR-017). Each level owns its blueprint per theme and its
own native pixel size; assets.mapx/mapy are pixels of assets.levelid, not of the
site. A position whose level is unknown renders "level unknown" and is never
drawn on the default level, because a marker on the wrong floor plan looks
entirely correct while pointing at the wrong place.

Repositioning in bulk: filter by unplaced, needs-review or level, search, place,
confirm. Landmark recalibration solves the transform PER AXIS from landmark
pairs and never from image dimensions - the canvas grew taller without
rescaling, so a dimension-derived scale would stretch Y by 1.57 and be wrong
everywhere. It defaults to a dry run, reports what would land off the drawing,
snapshots before applying, and clears mapverifiedat because a transform is a
guess awaiting review. Snapshots restore, including the level and the review
state, and a restore snapshots first so an undo is undoable.

Search: gaugelabreference was matched only for measuring tools and
maintenancereference was matched nowhere at all, for any asset type, while
Settings happily offers both identifiers on machines and PCs. A tag an operator
is told to record has to be findable or it is a write-only field. USB devices
and printed items were unreachable from search entirely - neither is an asset,
so the generic asset search could not see them and no searcher existed; they
now match on serial, asset tag, label, bin code and gage-lab tag, honouring
isactive, with Settings toggles and result labels to match.

The retired-application rule was half a rule: GET /api/knowledgebase hid
articles whose topic application is retired while global search still returned
them and printed the retired application as the subject. A filter is only real
if every path that reaches the row applies it.

Contract to 0.20.0 (additive): Asset gained levelid and mapverifiedat, Location
gained levelid, and resolve_asset_position returns the levelid belonging to
whichever source supplied the coordinates. The five plugins that write a map
position are re-pinned. The install-list text format gained levelid as a NINTH
field, appended, because the shipped Pascal installer reads fields 0-7 by index.

That installer still compiles in one drawing's dimensions and bundles one
blueprint, so its map is accurate for the default level only; /api/maplevels is
deliberately unauthenticated so it can read both at runtime once rebuilt.
Recorded in PRINTER-INSTALLER.md section 6 along with the other known gaps.

Migration 7d33 converts an existing single-map site into one building and one
default level carrying the old map_* settings, then assigns every placed asset
and location to it. Nothing moves on screen. Old settings rows are kept so a
rollback still finds them. Verified end to end on MySQL 5.6 from a
production-shaped database.
This commit is contained in:
cproudlock
2026-08-17 12:55:51 -04:00
parent 7d9a54ca0f
commit 3324dbd91e
60 changed files with 5313 additions and 895 deletions

View File

@@ -46,42 +46,60 @@ def test_resolve_asset_position_uses_self_when_set():
class FakeLocation:
mapx = 10
mapy = 20
levelid = 9
class FakeAsset:
mapx = 100
mapy = 200
levelid = 7
location = FakeLocation()
result = resolve_asset_position(FakeAsset())
assert result == {'mapx': 100, 'mapy': 200, 'positionsource': 'self'}
assert result == {'mapx': 100, 'mapy': 200, 'levelid': 7,
'positionsource': 'self'}
def test_resolve_asset_position_falls_back_to_location():
"""When asset has no coords, falls back to location coords."""
"""When asset has no coords, falls back to location coords.
The level comes with them (ADR-017). The asset here carries a STALE levelid
of its own with no coordinates to go with it - a leftover from a position
that was cleared - and returning that level beside the location's
coordinates would draw the location's spot on the wrong drawing.
"""
class FakeLocation:
mapx = 50
mapy = 75
levelid = 9
class FakeAsset:
mapx = None
mapy = None
levelid = 7
location = FakeLocation()
result = resolve_asset_position(FakeAsset())
assert result == {'mapx': 50, 'mapy': 75, 'positionsource': 'location'}
assert result == {'mapx': 50, 'mapy': 75, 'levelid': 9,
'positionsource': 'location'}
def test_resolve_asset_position_handles_asset_without_mapx_attr():
"""Assets that don't yet have mapx/mapy columns degrade gracefully."""
"""Assets that don't yet have mapx/mapy columns degrade gracefully.
Nor a levelid attribute: a plugin extension row passed in here has none,
and reading it must not raise.
"""
class FakeLocation:
mapx = 1
mapy = 2
levelid = 4
class FakeAsset:
location = FakeLocation()
result = resolve_asset_position(FakeAsset())
assert result == {'mapx': 1, 'mapy': 2, 'positionsource': 'location'}
assert result == {'mapx': 1, 'mapy': 2, 'levelid': 4,
'positionsource': 'location'}
# --- Relationship-walk path (priority 2 in the chain) ----------------------
@@ -105,7 +123,8 @@ def _make_rel(rtype_name, neighbor, inheritsposition=True, isactive=True):
return r
def _make_asset(assetid, mapx=None, mapy=None, outgoing=None, incoming=None, location=None):
def _make_asset(assetid, mapx=None, mapy=None, levelid=None, outgoing=None,
incoming=None, location=None):
class FakeAsset:
pass
@@ -113,6 +132,7 @@ def _make_asset(assetid, mapx=None, mapy=None, outgoing=None, incoming=None, loc
a.assetid = assetid
a.mapx = mapx
a.mapy = mapy
a.levelid = levelid
a.outgoing_relationships = outgoing or []
a.incoming_relationships = incoming or []
a.location = location
@@ -121,24 +141,26 @@ def _make_asset(assetid, mapx=None, mapy=None, outgoing=None, incoming=None, loc
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)
parent = _make_asset(assetid=2, mapx=300, mapy=400, levelid=2)
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'}
assert result == {'mapx': 300, 'mapy': 400, 'levelid': 2,
'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)
partof_neighbor = _make_asset(assetid=10, mapx=11, mapy=12, levelid=3)
controls_neighbor = _make_asset(assetid=20, mapx=99, mapy=99, levelid=8)
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'}
assert result == {'mapx': 11, 'mapy': 12, 'levelid': 3,
'positionsource': 'related'}
def test_resolve_asset_position_skips_non_inheritable_type():
@@ -161,12 +183,14 @@ def test_resolve_asset_position_skips_when_inheritsposition_false():
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)
root = _make_asset(assetid=3, mapx=1, mapy=2, levelid=5)
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'}
# Carried back up two hops from the node that actually has coordinates.
assert result == {'mapx': 1, 'mapy': 2, 'levelid': 5,
'positionsource': 'related'}
def test_resolve_asset_position_breaks_cycles():
@@ -195,12 +219,13 @@ def test_resolve_asset_position_depth_cap_is_three():
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)
neighbor = _make_asset(assetid=2, mapx=99, mapy=99, levelid=9)
rel = _make_rel('partof', neighbor)
asset = _make_asset(assetid=1, mapx=1, mapy=2, outgoing=[rel])
asset = _make_asset(assetid=1, mapx=1, mapy=2, levelid=1, outgoing=[rel])
result = resolve_asset_position(asset)
assert result == {'mapx': 1, 'mapy': 2, 'positionsource': 'self'}
assert result == {'mapx': 1, 'mapy': 2, 'levelid': 1,
'positionsource': 'self'}
def test_resolve_asset_position_related_beats_location():
@@ -209,13 +234,18 @@ def test_resolve_asset_position_related_beats_location():
class FakeLocation:
mapx = 500
mapy = 600
levelid = 9
neighbor = _make_asset(assetid=2, mapx=10, mapy=20)
neighbor = _make_asset(assetid=2, mapx=10, mapy=20, levelid=2)
rel = _make_rel('partof', neighbor)
asset = _make_asset(assetid=1, outgoing=[rel], location=FakeLocation())
asset = _make_asset(assetid=1, levelid=7, outgoing=[rel],
location=FakeLocation())
result = resolve_asset_position(asset)
assert result == {'mapx': 10, 'mapy': 20, 'positionsource': 'related'}
# Three levels are in play - the asset's own stale 7, the location's 9, and
# the neighbour's 2. Only the one that owns the coordinates is correct.
assert result == {'mapx': 10, 'mapy': 20, 'levelid': 2,
'positionsource': 'related'}
def test_resolve_asset_position_inactive_edge_skipped():

View File

@@ -0,0 +1,236 @@
"""The landmark transform, and the wrong answer it must not give.
The case this exists for: a site's blueprint goes from 3300x2550 to 3308x4000
because a second level was added below the first. The existing floor is drawn at
the same scale - so the correct transform is identity, or identity plus an
offset. A transform derived from the ratio of image dimensions would scale Y by
4000/2550 = 1.5686 and be wrong for every marker on the level, while looking
like arithmetic somebody had thought about.
So the first test here is not that the transform works. It is that it does not
produce that particular plausible wrong answer.
"""
from datetime import datetime
import pytest
from shopdb.core.models import Asset, AssetType, Building, MapLevel
from shopdb.core.api.mappositions import derive_transform, solve_axis
# -- the arithmetic, with no app or database involved ------------------------
def test_two_landmarks_give_identity_when_the_drawing_did_not_move():
"""A canvas that grew taller with the old floor untouched: scale 1, no offset.
This is the assertion that rules out the dimension-derived answer. Nothing
about these landmarks mentions 2550 or 4000, and the result must not either.
"""
transform, problem = derive_transform([
{'fromx': 100, 'fromy': 100, 'tox': 100, 'toy': 100},
{'fromx': 3000, 'fromy': 2000, 'tox': 3000, 'toy': 2000},
])
assert problem is None
assert transform['scalex'] == pytest.approx(1.0)
assert transform['scaley'] == pytest.approx(1.0)
assert transform['offsetx'] == pytest.approx(0.0)
assert transform['offsety'] == pytest.approx(0.0)
# The wrong answer, stated so a regression names itself.
assert transform['scaley'] != pytest.approx(4000 / 2550, abs=0.01)
def test_a_level_added_above_gives_a_pure_y_offset():
"""Existing floor pushed down by 1450px: identity scale, offset 1450."""
transform, problem = derive_transform([
{'fromx': 100, 'fromy': 100, 'tox': 100, 'toy': 1550},
{'fromx': 3000, 'fromy': 2000, 'tox': 3000, 'toy': 3450},
])
assert problem is None
assert transform['scaley'] == pytest.approx(1.0)
assert transform['offsety'] == pytest.approx(1450.0)
assert transform['scalex'] == pytest.approx(1.0)
def test_a_genuine_rescale_is_derived_per_axis():
"""X and Y can scale differently, which one uniform factor cannot express."""
transform, _ = derive_transform([
{'fromx': 0, 'fromy': 0, 'tox': 0, 'toy': 0},
{'fromx': 1000, 'fromy': 1000, 'tox': 2000, 'toy': 1500},
])
assert transform['scalex'] == pytest.approx(2.0)
assert transform['scaley'] == pytest.approx(1.5)
def test_a_third_landmark_averages_measurement_error():
"""Points picked by eye carry a few pixels of error; more points cancel it
rather than accumulating it."""
transform, _ = derive_transform([
{'fromx': 0, 'fromy': 0, 'tox': 2, 'toy': -2},
{'fromx': 1000, 'fromy': 1000, 'tox': 999, 'toy': 1001},
{'fromx': 2000, 'fromy': 2000, 'tox': 2001, 'toy': 1999},
])
assert transform['scalex'] == pytest.approx(1.0, abs=0.01)
assert transform['scaley'] == pytest.approx(1.0, abs=0.01)
def test_landmarks_on_one_column_are_refused_not_guessed():
"""Two points with the same X cannot determine a horizontal scale.
Returning 1.0 here would look like success and silently leave X unscaled,
which is the failure a user would discover after the write.
"""
transform, problem = derive_transform([
{'fromx': 500, 'fromy': 100, 'tox': 600, 'toy': 100},
{'fromx': 500, 'fromy': 2000, 'tox': 600, 'toy': 2000},
])
assert transform is None
assert 'X' in problem
def test_one_landmark_is_not_enough():
transform, problem = derive_transform([
{'fromx': 1, 'fromy': 1, 'tox': 2, 'toy': 2}])
assert transform is None
assert 'two landmarks' in problem
def test_solve_axis_reports_an_undetermined_axis():
assert solve_axis([(5, 9), (5, 11)]) is None
assert solve_axis([(0, 0), (10, 20)]) == (2.0, 0.0)
# -- end to end, against the endpoints --------------------------------------
@pytest.fixture
def floor(db):
"""One building, two levels sized like the real before and after."""
assettype = AssetType.query.filter_by(assettype='machine').first()
if not assettype:
assettype = AssetType(assettype='machine')
db.session.add(assettype)
db.session.flush()
building = Building(buildingname='Main')
db.session.add(building)
db.session.flush()
ground = MapLevel(buildingid=building.buildingid, levelname='Ground floor',
sortorder=0, isdefault=True, mapwidth=3300, mapheight=2550)
second = MapLevel(buildingid=building.buildingid, levelname='Second floor',
sortorder=1, mapwidth=3308, mapheight=4000)
db.session.add_all([ground, second])
db.session.flush()
assets = []
for index, (x, y) in enumerate([(100, 100), (1500, 1200), (3200, 2500)]):
asset = Asset(assetnumber='M%03d' % index, assettypeid=assettype.assettypeid,
mapx=x, mapy=y, levelid=ground.levelid,
mapverifiedat=datetime(2026, 1, 1))
db.session.add(asset)
assets.append(asset)
db.session.commit()
return {'ground': ground, 'second': second, 'assets': assets}
def _transform(client, headers, **body):
return client.post('/api/mappositions/transform', json=body, headers=headers)
def test_a_dry_run_writes_nothing(client, db, floor, auth_headers):
before = [(a.mapx, a.mapy, a.mapverifiedat) for a in floor['assets']]
resp = _transform(client, auth_headers,
levelid=floor['ground'].levelid,
landmarks=[{'fromx': 0, 'fromy': 0, 'tox': 0, 'toy': 1450},
{'fromx': 1000, 'fromy': 1000, 'tox': 1000, 'toy': 2450}])
assert resp.status_code == 200
body = resp.get_json()['data']
assert body['dryrun'] is True
assert body['assetcount'] == 3
db.session.expire_all()
after = [(a.mapx, a.mapy, a.mapverifiedat)
for a in Asset.query.order_by(Asset.assetid).all()]
assert after == before
def test_applying_moves_markers_clears_review_and_snapshots(
client, db, floor, auth_headers):
resp = _transform(client, auth_headers,
levelid=floor['ground'].levelid, dryrun=False,
landmarks=[{'fromx': 0, 'fromy': 0, 'tox': 0, 'toy': 1450},
{'fromx': 1000, 'fromy': 1000, 'tox': 1000, 'toy': 2450}])
assert resp.status_code == 200
body = resp.get_json()['data']
assert body['dryrun'] is False
assert body['snapshotid']
db.session.expire_all()
moved = Asset.query.filter_by(assetnumber='M000').first()
assert (moved.mapx, moved.mapy) == (100, 1550)
# A transform is a guess, so the review state it had is gone.
assert moved.mapverifiedat is None
def test_markers_pushed_off_the_canvas_are_counted(client, db, floor, auth_headers):
"""A marker at y=2500 offset by 1600 lands at 4100, past the 4000px canvas.
Worth checking because a bad landmark pair is most likely to show up as
markers leaving the drawing entirely, and a preview that does not count them
lets that through.
"""
resp = _transform(client, auth_headers,
levelid=floor['ground'].levelid,
tolevelid=floor['second'].levelid,
landmarks=[{'fromx': 0, 'fromy': 0, 'tox': 0, 'toy': 1600},
{'fromx': 1000, 'fromy': 1000, 'tox': 1000, 'toy': 2600}])
body = resp.get_json()['data']
assert body['outofboundscount'] == 1
offender = next(m for m in body['moves'] if m['outofbounds'])
assert offender['toy'] > floor['second'].mapheight
def test_restore_puts_positions_and_review_state_back(
client, db, floor, auth_headers):
original = {a.assetnumber: (a.mapx, a.mapy, a.mapverifiedat)
for a in floor['assets']}
applied = _transform(client, auth_headers,
levelid=floor['ground'].levelid, dryrun=False,
landmarks=[{'fromx': 0, 'fromy': 0, 'tox': 0, 'toy': 1450},
{'fromx': 1000, 'fromy': 1000, 'tox': 1000, 'toy': 2450}])
snapshotid = applied.get_json()['data']['snapshotid']
resp = client.post('/api/mappositions/snapshots/%d/restore' % snapshotid,
headers=auth_headers)
assert resp.status_code == 200
assert resp.get_json()['data']['restored'] == 3
db.session.expire_all()
for asset in Asset.query.all():
assert (asset.mapx, asset.mapy, asset.mapverifiedat) == \
original[asset.assetnumber]
def test_a_position_without_a_level_is_refused(client, db, floor, auth_headers):
"""The rule the whole model rests on: a position with no level cannot be
stored, because it cannot be rendered."""
resp = client.post('/api/mappositions/positions', json={
'positions': [{'assetid': floor['assets'][0].assetid,
'mapx': 10, 'mapy': 20}]}, headers=auth_headers)
assert resp.status_code == 400
assert 'levelid' in resp.get_json()['data']['error']['message']
def test_placing_by_hand_counts_as_review(client, db, floor, auth_headers):
asset = floor['assets'][0]
client.post('/api/mappositions/verify',
json={'assetids': [asset.assetid], 'unverify': True},
headers=auth_headers)
db.session.expire_all()
assert Asset.query.get(asset.assetid).mapverifiedat is None
resp = client.post('/api/mappositions/positions', json={
'positions': [{'assetid': asset.assetid, 'mapx': 42, 'mapy': 43,
'levelid': floor['second'].levelid}]},
headers=auth_headers)
assert resp.status_code == 200
db.session.expire_all()
moved = Asset.query.get(asset.assetid)
assert (moved.mapx, moved.mapy, moved.levelid) == (42, 43, floor['second'].levelid)
assert moved.mapverifiedat is not None

View File

@@ -0,0 +1,201 @@
"""What global search can reach, and what it must not return.
Two plugins own records that are NOT assets - USB devices and printed items -
so the generic asset search cannot see them, and nothing else did either: those
records were unreachable from search entirely while their list pages existed and
their labels were printed on physical bins and sticks.
The other half of this file is the retired-application rule. Hiding an article
about a decommissioned application from the plugin's own listing while global
search still returned it is not a rule at all - the article stayed two
keystrokes away, and the result printed the retired application as its subject.
A filter is only real if every path that reaches the row applies it.
"""
import pytest
from shopdb.core.models import Application
def search(client, query):
"""Global search rows for a query."""
resp = client.get(f'/api/search?q={query}')
assert resp.status_code == 200, resp.get_json()
return resp.get_json()['data']['results']
def titles(client, query, resulttype=None):
return [r['title'] for r in search(client, query)
if resulttype is None or r['type'] == resulttype]
# --- USB devices (usb plugin, not an asset) ---------------------------------
@pytest.fixture
def usbdevices(db):
"""One live device and one deactivated one, distinctive enough that only the
USB searcher can match them."""
from plugins.usb.models import USBDevice
live = USBDevice(serialnumber='ZZSERIAL1234', label='Loaner stick 4',
assetnumber='USB-0004', productname='Kingston DataTraveler',
isactive=True)
retired = USBDevice(serialnumber='ZZSERIAL9999', label='Dead stick',
assetnumber='USB-0009', productname='Kingston DataTraveler',
isactive=False)
db.session.add_all([live, retired])
db.session.commit()
return {'live': live.usbdeviceid, 'retired': retired.usbdeviceid}
def test_a_usb_device_is_found_by_serial(client, usbdevices):
"""The serial is what is printed on the stick in someone's hand."""
rows = [r for r in search(client, 'ZZSERIAL1234') if r['type'] == 'usb_device']
assert len(rows) == 1
assert rows[0]['id'] == usbdevices['live']
assert rows[0]['url'] == f"/usb/{usbdevices['live']}"
# An exact serial is the strongest possible match for this domain.
assert rows[0]['relevance'] == 100
def test_a_usb_device_is_found_by_label_and_asset_tag(client, usbdevices):
assert 'Loaner stick 4' in titles(client, 'Loaner', 'usb_device')
assert 'Loaner stick 4' in titles(client, 'USB-0004', 'usb_device')
def test_a_deactivated_usb_device_is_not_found(client, usbdevices):
assert titles(client, 'ZZSERIAL9999', 'usb_device') == []
# And it does not ride along on a query that matches both devices.
assert 'Dead stick' not in titles(client, 'Kingston', 'usb_device')
def test_a_usb_holder_is_not_searchable_by_name(client, db):
"""currentusername records who holds a device. Making search a way to list
what a named person checked out is a different feature, deliberately not
built into this searcher."""
from plugins.usb.models import USBDevice
db.session.add(USBDevice(serialnumber='ZZSERIAL5555', label='Held stick',
currentusername='Distinctivesurname', isactive=True))
db.session.commit()
assert titles(client, 'Distinctivesurname', 'usb_device') == []
# --- Printed items (printedparts plugin, not an asset) ----------------------
@pytest.fixture
def printeditems(db):
from plugins.printedparts.models import PrintedItem
live = PrintedItem(itemcode='3DP-8001', gagelabtag='WJRP8001',
itemname='Fixture clamp', itemdescription='Holds a part',
binlocation='Bin 12', quantityonhand=4,
lowstockthreshold=2, isactive=True)
retired = PrintedItem(itemcode='3DP-8009', gagelabtag='WJRP8009',
itemname='Obsolete clamp', quantityonhand=0,
lowstockthreshold=1, isactive=False)
db.session.add_all([live, retired])
db.session.commit()
return {'live': live.printeditemid, 'retired': retired.printeditemid}
def test_a_printed_item_is_found_by_bin_code(client, printeditems):
"""itemcode is the bin label someone reads off the shelf."""
rows = [r for r in search(client, '3DP-8001') if r['type'] == 'printed_item']
assert len(rows) == 1
assert rows[0]['id'] == printeditems['live']
assert rows[0]['url'] == f"/printedparts/{printeditems['live']}"
assert rows[0]['relevance'] == 100
def test_a_printed_item_is_found_by_gage_lab_tag(client, printeditems):
rows = [r for r in search(client, 'WJRP8001') if r['type'] == 'printed_item']
assert [r['title'] for r in rows] == ['Fixture clamp']
def test_a_printed_item_is_found_by_name(client, printeditems):
assert 'Fixture clamp' in titles(client, 'Fixture clamp', 'printed_item')
def test_a_printed_item_subtitle_locates_it(client, printeditems):
"""A hit is only useful if it says where to go and get the part."""
rows = [r for r in search(client, '3DP-8001') if r['type'] == 'printed_item']
assert 'Bin 12' in rows[0]['subtitle']
def test_a_deactivated_printed_item_is_not_found(client, printeditems):
assert titles(client, '3DP-8009', 'printed_item') == []
assert 'Obsolete clamp' not in titles(client, 'clamp', 'printed_item')
# --- The retired-application rule reaches global search too ----------------
@pytest.fixture
def kbarticles(db):
from plugins.knowledgebase.models import KnowledgeBase
live = Application(appname='Livesearchapp', isactive=True)
retired = Application(appname='Retiredsearchapp', isactive=False)
db.session.add_all([live, retired])
db.session.flush()
db.session.add_all([
KnowledgeBase(shortdescription='Zzrunbook for the live one', linkurl='u',
keywords='zzkeyword', appid=live.appid, isactive=True),
KnowledgeBase(shortdescription='Zzrunbook for the retired one', linkurl='u',
keywords='zzkeyword', appid=retired.appid, isactive=True),
KnowledgeBase(shortdescription='Zzrunbook with no topic', linkurl='u',
keywords='zzkeyword', appid=None, isactive=True),
])
db.session.commit()
def test_global_search_hides_an_article_about_a_retired_application(client, kbarticles):
found = titles(client, 'zzkeyword', 'knowledgebase')
assert 'Zzrunbook for the retired one' not in found
assert 'Zzrunbook for the live one' in found
def test_global_search_keeps_an_article_with_no_topic(client, kbarticles):
"""A null topic is not a retired one."""
assert 'Zzrunbook with no topic' in titles(client, 'zzkeyword', 'knowledgebase')
def test_global_search_hides_a_retired_application_itself(client, kbarticles):
"""The application domain has always filtered isactive; pinned here so the
two rules stay together and cannot drift apart again."""
assert titles(client, 'Retiredsearchapp', 'application') == []
assert titles(client, 'Livesearchapp', 'application') == ['Livesearchapp']
# --- The optional asset identifiers (ADR-001) -------------------------------
@pytest.fixture
def taggedmachine(client, db, auth_headers):
"""A machine carrying both optional identifiers.
A MACHINE on purpose, not a measuring tool: gaugelabreference was searchable
only through the measuring-tools searcher, so the same tag on a machine
matched nothing even though Settings offers the identifier for machines.
"""
from shopdb.core.models import AssetType, Asset
if not AssetType.query.filter_by(assettype='machine').first():
db.session.add(AssetType(assettype='machine', pluginname='machines',
tablename='machines', description='m'))
db.session.commit()
resp = client.post('/api/machines', json={'assetnumber': 'ZZMACH01'},
headers=auth_headers)
assert resp.status_code == 201, resp.get_json()
asset = Asset.query.filter_by(assetnumber='ZZMACH01').first()
asset.gaugelabreference = 'ZZGAUGE777'
asset.maintenancereference = 'ZZMAINT888'
db.session.commit()
return asset.assetid
def test_a_machine_is_found_by_its_gauge_lab_reference(client, taggedmachine):
rows = [r for r in search(client, 'ZZGAUGE777') if r['id'] == taggedmachine
or r['title'] == 'ZZMACH01']
assert rows, 'a gauge-lab tag on a machine matched nothing'
def test_a_machine_is_found_by_its_maintenance_reference(client, taggedmachine):
"""maintenancereference was searched by nothing at all, for any asset
type - a field the UI collects and search could not find."""
rows = [r for r in search(client, 'ZZMAINT888') if r['title'] == 'ZZMACH01']
assert rows, 'a maintenance reference matched nothing'

View File

@@ -0,0 +1,106 @@
"""What a knowledge base article's topic decides about seeing the article.
An article's topic is an Application. When that application is retired the
article describes something no longer in service, and showing it beside live
documentation reads as though it were current - so it does not show at all.
`isactive` is the ONLY property of an application that decides this. In
particular `ishidden`, which governs whether an application appears on the tiles
page, says nothing about whether it can be the subject of an article.
"""
import pytest
from shopdb.core.models import Application
from plugins.knowledgebase.models import KnowledgeBase
@pytest.fixture
def library(db):
"""One application of each interesting shape, with an article each."""
applications = {
'live': Application(appname='Live App', isinstallable=True,
ishidden=False, isactive=True),
'notinstallable': Application(appname='Manual App', isinstallable=False,
ishidden=False, isactive=True),
'hidden': Application(appname='Hidden App', isinstallable=False,
ishidden=True, isactive=True),
'retired': Application(appname='Retired App', isinstallable=True,
ishidden=False, isactive=False),
}
db.session.add_all(applications.values())
db.session.flush()
articles = {
'live': KnowledgeBase(shortdescription='Live runbook', linkurl='u',
appid=applications['live'].appid, clicks=3,
isactive=True),
'hidden': KnowledgeBase(shortdescription='Hidden runbook', linkurl='u',
appid=applications['hidden'].appid, clicks=2,
isactive=True),
'retired': KnowledgeBase(shortdescription='Retired runbook', linkurl='u',
appid=applications['retired'].appid, clicks=5,
isactive=True),
'topicless': KnowledgeBase(shortdescription='General note', linkurl='u',
appid=None, clicks=1, isactive=True),
}
db.session.add_all(articles.values())
db.session.commit()
return {'applications': applications, 'articles': articles}
def titles(client, url='/api/knowledgebase'):
return sorted(row['shortdescription']
for row in client.get(url).get_json()['data'])
def test_an_article_about_a_retired_application_does_not_show(client, library):
assert 'Retired runbook' not in titles(client)
def test_an_article_with_no_topic_still_shows(client, library):
"""A null topic is not a retired one. Not every article is about an
application, and those must not be collateral damage."""
assert 'General note' in titles(client)
def test_a_hidden_application_is_still_a_valid_topic(client, library):
"""ishidden keeps an application off the tiles page. It says nothing about
documentation, and isactive is the only filter that applies here."""
assert 'Hidden runbook' in titles(client)
def test_searching_a_retired_application_name_finds_nothing(client, library):
"""The topic search matched on application name without checking isactive,
which surfaced retired articles and printed the retired application as their
subject."""
assert titles(client, '/api/knowledgebase?search=Retired App') == []
def test_searching_a_title_does_not_resurrect_a_retired_article(client, library):
"""Hiding it from the list and finding it by title would be no rule at all."""
found = titles(client, '/api/knowledgebase?search=runbook')
assert 'Retired runbook' not in found
assert 'Live runbook' in found
def test_the_counts_agree_with_the_list(client, library):
"""A total that includes articles nobody can see is a total nobody can
reconcile against the page."""
visible = titles(client)
stats = client.get('/api/knowledgebase/stats').get_json()['data']
total = stats.get('total_articles', stats.get('totalarticles'))
clicks = stats.get('total_clicks', stats.get('totalclicks'))
assert total == len(visible) == 3
# 3 + 2 + 1 from the live, hidden-topic and topicless articles; the retired
# article's 5 clicks are excluded along with the article.
assert clicks == 6
def test_the_topic_list_offers_every_active_application(client, library):
"""What the article form's topic dropdown fetches. Installable or not,
hidden or not - only retired is excluded."""
offered = sorted(row['appname'] for row in client.get(
'/api/applications?perpage=100&showhidden=true').get_json()['data'])
assert offered == ['Hidden App', 'Live App', 'Manual App']
assert 'Retired App' not in offered

View File

@@ -66,7 +66,14 @@ def test_install_list_vendorname_resolves_via_model(client, db, auth_headers,
def test_install_list_text_format(client, db, auth_headers, printer_assettype):
"""format=text returns one pipe-delimited line per printer (fixed field
order) so the Inno installers split() instead of parsing JSON."""
order) so the Inno installers split() instead of parsing JSON.
FIELD ORDER 0..7 IS FROZEN. The shipped installer reads them by index
(`GetField(Line, 6)` for mapx), so a field inserted anywhere but the end
silently shifts every later one: the installer would read a model number as
a coordinate and still run. levelid is appended as field 8, which installers
built before levels existed simply never read.
"""
client.post('/api/printers', json={
'assetnumber': 'CSF01-HP', 'windowsname': 'CSF01-HP', 'hostname': 'wjprn01',
'mapx': 120, 'mapy': 240,
@@ -80,10 +87,14 @@ def test_install_list_text_format(client, db, auth_headers, printer_assettype):
assert line is not None
cols = line.split('|')
# printerid|windowsname|vendorname|modelnumber|hostname|ipaddress|mapx|mapy
assert len(cols) == 8
# |levelid
assert len(cols) == 9
assert cols[1] == 'CSF01-HP' # windowsname (falls back to assetnumber name)
assert cols[4] == 'wjprn01' # hostname
assert cols[6] == '120' and cols[7] == '240'
# levelid last, and empty rather than absent when the printer has no level:
# the field count must not vary between rows or a positional split breaks.
assert cols[8] == ''
def test_install_list_excludes_usb_only_printer(client, db, auth_headers,