diff --git a/frontend/src/components/ShopFloorMap.vue b/frontend/src/components/ShopFloorMap.vue index 5ab64110..6857d3de 100644 --- a/frontend/src/components/ShopFloorMap.vue +++ b/frontend/src/components/ShopFloorMap.vue @@ -97,7 +97,7 @@ import L from 'leaflet' import 'leaflet/dist/leaflet.css' import { loadMapConfig, blueprintUrlFor, state as mapConfig } from '../composables/mapConfig' import { assetTypeLabel, assetDetailRoute } from '../utils/assetTypes' -import { getSubtypeId } from '../utils/mapColors' +import { getSubtypeId, markerRingColor, UNSPECIFIED_COLOR } from '../utils/mapColors' import api from '../api' const props = defineProps({ @@ -451,7 +451,7 @@ function renderMarkers() { // Unified asset mode - use subtype colors when a type is selected if (props.selectedAssetType) { const subtypeId = getSubtypeId(item) - color = (subtypeId && props.subtypeColors[subtypeId]) || '#BDBDBD' + color = (subtypeId && props.subtypeColors[subtypeId]) || UNSPECIFIED_COLOR typeName = (subtypeId && props.subtypeNames[subtypeId]) || item.assettype || '' } else { // Prefer the stored AssetType.color; fall back to the built-in map. @@ -477,7 +477,9 @@ function renderMarkers() { const marker = L.circleMarker([leafletY, leafletX], { radius: 6, fillColor: color, - color: 'rgba(255,255,255,0.8)', + // Ring picked from the fill, not fixed white - a white ring on a light + // marker vanished against the light (white) blueprint. + color: markerRingColor(color), weight: 2, fillOpacity: 1, renderer: canvasRenderer diff --git a/frontend/src/utils/mapColors.js b/frontend/src/utils/mapColors.js index 230aedb0..1913bd32 100644 --- a/frontend/src/utils/mapColors.js +++ b/frontend/src/utils/mapColors.js @@ -2,17 +2,27 @@ // the exact same colors the map shows on screen. Mirrors the maps defined in // ShopFloorMap.vue - keep the two in sync if the palette changes. +// Markers sit on TWO very different surfaces: the light blueprint on white, and +// the dark blueprint on the near-black navy card. Every step below clears 3:1 +// against both (#ffffff and the dark card, ~#060628), so no marker washes out on +// either. The hues keep their established meaning - only the step changed. The +// old values were far too light to survive white: orange #FF9800 sat at 2.16:1 +// and the grey default at 1.88:1. export const assetTypeColorsMap = { - machine: '#F44336', // Red - computer: '#2196F3', // Blue - printer: '#4CAF50', // Green - 'network device': '#FF9800', // Orange - network_device: '#FF9800', // Orange (alternate key) - measuring_tool: '#9C27B0', // Purple - 'measuring tool': '#9C27B0' // Purple (normalized key) + machine: '#e03131', // Red + computer: '#1976d2', // Blue + printer: '#2e7d32', // Green + 'network device': '#e65100', // Orange + network_device: '#e65100', // Orange (alternate key) + measuring_tool: '#9c4dcc', // Purple + 'measuring tool': '#9c4dcc' // Purple (normalized key) } -const DEFAULT_COLOR = '#BDBDBD' +// Fallback for an asset with no subtype. Reads as neutral without vanishing: +// 5.4:1 on white, 3.7:1 on the dark card (the old #BDBDBD was 1.88:1 on white). +export const UNSPECIFIED_COLOR = '#546e7a' + +const DEFAULT_COLOR = UNSPECIFIED_COLOR // Canonical form for comparing asset-type strings. The API sends the machine // type as 'network_device' but subtype keys and labels use 'network device', @@ -43,8 +53,23 @@ export function getSubtypeId(asset) { return null } +// Ring color for a marker of the given fill. A fixed white ring disappears on +// the white blueprint, so pick the ring by the FILL's lightness instead: a light +// fill gets a dark ring, a dark fill a light one. The marker then keeps a hard +// edge on either surface, and it also rescues a washed-out color a user picked +// by hand for a subtype - which the palette below cannot control. +export function markerRingColor(fill) { + const hex = (fill || '').replace('#', '') + if (hex.length !== 6) return '#ffffff' + const [r, g, b] = [0, 2, 4].map(i => parseInt(hex.slice(i, i + 2), 16) / 255) + // Relative luminance (WCAG), same formula the palette validator uses. + const channel = c => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4) + const luminance = 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b) + return luminance > 0.35 ? '#1b1b1b' : '#ffffff' +} + // Resolve the marker color for an asset the same way ShopFloorMap does: when a -// type is selected, color by subtype (grey fallback); otherwise by asset type. +// type is selected, color by subtype (neutral fallback); otherwise by asset type. export function resolveMarkerColor(asset, { selectedType, subtypeColors = {} }) { if (selectedType) { const id = getSubtypeId(asset) diff --git a/frontend/src/utils/mapColors.spec.js b/frontend/src/utils/mapColors.spec.js index 36b9233d..c96e6292 100644 --- a/frontend/src/utils/mapColors.spec.js +++ b/frontend/src/utils/mapColors.spec.js @@ -1,5 +1,26 @@ import { describe, it, expect } from 'vitest' -import { getSubtypeId, resolveMarkerColor } from './mapColors' +import { + getSubtypeId, resolveMarkerColor, markerRingColor, assetTypeColorsMap, + UNSPECIFIED_COLOR +} from './mapColors' + +// WCAG relative luminance / contrast, so the palette assertions below are +// computed rather than eyeballed. +function contrast(a, b) { + const lum = hex => { + const h = hex.replace('#', '') + const [r, g, b2] = [0, 2, 4].map(i => parseInt(h.slice(i, i + 2), 16) / 255) + const ch = c => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4) + return 0.2126 * ch(r) + 0.7152 * ch(g) + 0.0722 * ch(b2) + } + const [hi, lo] = [lum(a), lum(b)].sort((x, y) => y - x) + return (hi + 0.05) / (lo + 0.05) +} + +// The two surfaces a marker has to survive: the light blueprint (white) and the +// dark blueprint over the dark card (--bg-card over --bg). +const WHITE = '#ffffff' +const DARK_CARD = '#060628' // One case per asset type that carries a subtype. A missing branch here means // the map subtype filter silently drops every asset of that type. @@ -26,6 +47,34 @@ describe('getSubtypeId', () => { }) }) +describe('marker legibility', () => { + const palette = [...new Set(Object.values(assetTypeColorsMap)), UNSPECIFIED_COLOR] + + it.each(palette)('%s clears 3:1 on the white blueprint', (color) => { + expect(contrast(color, WHITE)).toBeGreaterThanOrEqual(3) + }) + + it.each(palette)('%s clears 3:1 on the dark card', (color) => { + expect(contrast(color, DARK_CARD)).toBeGreaterThanOrEqual(3) + }) + + it('rings a light fill dark and a dark fill light', () => { + expect(markerRingColor('#FFEB3B')).toBe('#1b1b1b') + expect(markerRingColor('#1565c0')).toBe('#ffffff') + }) + + it('gives every ring 3:1 against its own fill', () => { + for (const color of palette) { + expect(contrast(color, markerRingColor(color))).toBeGreaterThanOrEqual(3) + } + }) + + it('falls back to a light ring for a malformed color', () => { + expect(markerRingColor('')).toBe('#ffffff') + expect(markerRingColor('nonsense')).toBe('#ffffff') + }) +}) + describe('resolveMarkerColor', () => { it('colors a measuring tool by its subtype when a type is selected', () => { const asset = { assettype: 'Measuring Tool', typedata: { measuringtooltypeid: 7 } } diff --git a/frontend/src/utils/mapPdf.js b/frontend/src/utils/mapPdf.js index 8601eac8..175befe5 100644 --- a/frontend/src/utils/mapPdf.js +++ b/frontend/src/utils/mapPdf.js @@ -3,7 +3,10 @@ // each visible marker is placed at its scaled coordinate, so the PDF is a crisp // vector-over-raster page rather than a screen capture. import { jsPDF } from 'jspdf' -import { resolveMarkerColor, getSubtypeId, getAssetTypeColor } from './mapColors' +import { + resolveMarkerColor, getSubtypeId, getAssetTypeColor, markerRingColor, + UNSPECIFIED_COLOR +} from './mapColors' function loadImage(url) { return new Promise((resolve, reject) => { @@ -16,7 +19,7 @@ function loadImage(url) { } function hexToRgb(hex) { - const h = (hex || '#BDBDBD').replace('#', '') + const h = (hex || UNSPECIFIED_COLOR).replace('#', '') const v = h.length === 3 ? h.split('').map(c => c + c).join('') : h return [parseInt(v.slice(0, 2), 16), parseInt(v.slice(2, 4), 16), parseInt(v.slice(4, 6), 16)] } @@ -31,7 +34,7 @@ function buildLegend(assets, { selectedType, subtypeColors, subtypeNames }) { const id = getSubtypeId(a) key = id != null ? String(id) : 'none' label = (id != null && subtypeNames[id]) || 'Unspecified' - color = (id != null && subtypeColors[id]) || '#BDBDBD' + color = (id != null && subtypeColors[id]) || UNSPECIFIED_COLOR } else { key = (a.assettype || 'unknown').toLowerCase() label = a.assettype || 'Unknown' @@ -94,8 +97,9 @@ export async function exportMapPdf(opts) { if (lx + w > pageW - margin) { lx = margin; ly += lineH } const [r, g, b] = hexToRgb(entry.color) doc.setFillColor(r, g, b) - doc.setDrawColor('#ffffff'); doc.setLineWidth(0.4) - doc.rect(lx, ly - swatch + 1, swatch, swatch, 'F') + // Outline the swatch against the white page, same rule as the markers. + doc.setDrawColor(markerRingColor(entry.color)); doc.setLineWidth(0.4) + doc.rect(lx, ly - swatch + 1, swatch, swatch, 'FD') doc.setTextColor('#333333') doc.text(label, lx + swatch + 4, ly) lx += w @@ -124,9 +128,11 @@ export async function exportMapPdf(opts) { const x = imgX + (a.mapx / mapWidth) * imgW const y = imgY + (a.mapy / mapHeight) * imgH if (x < imgX || x > imgX + imgW || y < imgY || y > imgY + imgH) continue - const [r, g, b] = hexToRgb(resolveMarkerColor(a, { selectedType, subtypeColors })) + const fill = resolveMarkerColor(a, { selectedType, subtypeColors }) + const [r, g, b] = hexToRgb(fill) doc.setFillColor(r, g, b) - doc.setDrawColor('#ffffff'); doc.setLineWidth(0.6) + // The PDF prints on white, where a white ring is invisible - ring by fill. + doc.setDrawColor(markerRingColor(fill)); doc.setLineWidth(0.6) doc.circle(x, y, radius, 'FD') } diff --git a/frontend/src/views/MapView.vue b/frontend/src/views/MapView.vue index 913be7f8..31059eef 100644 --- a/frontend/src/views/MapView.vue +++ b/frontend/src/views/MapView.vue @@ -82,7 +82,7 @@ import ShopFloorMap from '../components/ShopFloorMap.vue' import { assetsApi } from '../api' import { currentTheme } from '../stores/theme' import { useAuthStore } from '../stores/auth' -import { loadMapConfig, state as mapConfig } from '../composables/mapConfig' +import { loadMapConfig, blueprintUrlFor, state as mapConfig } from '../composables/mapConfig' import { exportMapPdf } from '../utils/mapPdf' import { assetTypeLabel, assetDetailRoute } from '../utils/assetTypes' import { getSubtypeId } from '../utils/mapColors' @@ -140,12 +140,18 @@ const subtypeLabel = computed(() => { return labels[selectedType.value.toLowerCase().replace(/_/g, ' ')] || 'All Subtypes' }) -// Generate distinct colors for subtypes +// Auto colors for subtypes that have not been given one. Every step clears 3:1 +// against BOTH map surfaces (white blueprint and the dark navy card), so none +// washes out; the old list ran through pale yellows and limes (#FFEB3B was +// 1.1:1 on white) that were invisible on the light blueprint. Ordered by +// max-min perceptual separation, so the fewer subtypes are on screen the +// further apart their colors sit. Past roughly the fifth entry no palette can +// keep every pair distinguishable under color-blind simulation - the legend and +// the hover tooltip carry identity from there. const subtypeColorPalette = [ - '#F44336', '#E91E63', '#9C27B0', '#673AB7', '#3F51B5', - '#2196F3', '#03A9F4', '#00BCD4', '#009688', '#4CAF50', - '#8BC34A', '#CDDC39', '#FFEB3B', '#FFC107', '#FF9800', - '#FF5722', '#795548', '#607D8B', '#00ACC1', '#5C6BC0' + '#7c4dff', '#ef6c00', '#0097a7', '#c2185b', '#1565c0', + '#e03131', '#e0479e', '#00897b', '#827717', '#1976d2', + '#d81b60', '#388e3c', '#9c4dcc', '#2e7d32' ] // Map subtype IDs to colors: prefer each subtype's stored color; fall back to @@ -236,7 +242,10 @@ async function exportPdf() { await loadMapConfig() await exportMapPdf({ assets: filteredAssets.value, - blueprintUrl: mapConfig.blueprintLight, + // blueprintUrlFor applies withBase - the raw setting value is a + // root-relative /api path, which 404s under a subpath mount like /ops. + // The PDF always uses the light blueprint: it prints on white paper. + blueprintUrl: blueprintUrlFor('light'), mapWidth: mapConfig.width, mapHeight: mapConfig.height, selectedType: selectedType.value, diff --git a/plugins/computers/api/routes.py b/plugins/computers/api/routes.py index ba35708e..aac1eb35 100644 --- a/plugins/computers/api/routes.py +++ b/plugins/computers/api/routes.py @@ -363,14 +363,20 @@ def list_computers(): if exactassetnumber := request.args.get('assetnumber'): query = query.filter(Asset.assetnumber == exactassetnumber) - # Search filter + # Search filter. Covers the type name too - it is a column in the list, so + # searching 'Standard' must find the PCs of that type. Outer join so a PC + # with no type still matches on its own fields. if search := request.args.get('search'): - query = query.filter( + pattern = f'%{search}%' + query = query.outerjoin( + ComputerType, Computer.computertypeid == ComputerType.computertypeid + ).filter( db.or_( - Asset.assetnumber.ilike(f'%{search}%'), - Asset.name.ilike(f'%{search}%'), - Asset.serialnumber.ilike(f'%{search}%'), - Computer.hostname.ilike(f'%{search}%') + Asset.assetnumber.ilike(pattern), + Asset.name.ilike(pattern), + Asset.serialnumber.ilike(pattern), + Computer.hostname.ilike(pattern), + ComputerType.computertype.ilike(pattern) ) ) diff --git a/plugins/machines/api/routes.py b/plugins/machines/api/routes.py index f309e32f..a44bf6c8 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, AuditLog, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query, resolve_dualpath_pairs, dualpath_single_machine_enabled +from shopdb.api import db, Asset, AssetType, AuditLog, Vendor, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query, resolve_dualpath_pairs, dualpath_single_machine_enabled from ..models import Machine, MachineType @@ -174,13 +174,23 @@ def list_machines(): if exactassetnumber := request.args.get('assetnumber'): query = query.filter(Asset.assetnumber == exactassetnumber) - # Search filter + # Search filter. Covers what the list actually SHOWS - the asset fields plus + # the type and vendor names, which are columns in the table. Searching a type + # like 'Part Washer' used to return nothing. Outer joins so a machine with no + # type or vendor still matches on its own fields. if search := request.args.get('search'): - query = query.filter( + pattern = f'%{search}%' + query = query.outerjoin( + MachineType, Machine.machinetypeid == MachineType.machinetypeid + ).outerjoin( + Vendor, Machine.vendorid == Vendor.vendorid + ).filter( db.or_( - Asset.assetnumber.ilike(f'%{search}%'), - Asset.name.ilike(f'%{search}%'), - Asset.serialnumber.ilike(f'%{search}%') + Asset.assetnumber.ilike(pattern), + Asset.name.ilike(pattern), + Asset.serialnumber.ilike(pattern), + MachineType.machinetype.ilike(pattern), + Vendor.vendor.ilike(pattern) ) ) diff --git a/plugins/measuringtools/api/routes.py b/plugins/measuringtools/api/routes.py index 89c6fc62..ab69583e 100644 --- a/plugins/measuringtools/api/routes.py +++ b/plugins/measuringtools/api/routes.py @@ -168,11 +168,18 @@ def list_tools(): # Exact-match natural-key lookup for idempotent import (asset number). if exactassetnumber := request.args.get('assetnumber'): query = query.filter(Asset.assetnumber == exactassetnumber) + # Type is a column in the list, so it has to be searchable. Outer join so a + # tool with no type still matches on its own fields. if search := request.args.get('search'): - query = query.filter(db.or_( - Asset.assetnumber.ilike(f'%{search}%'), - Asset.name.ilike(f'%{search}%'), - Asset.serialnumber.ilike(f'%{search}%'), + pattern = f'%{search}%' + query = query.outerjoin( + MeasuringToolType, + MeasuringTool.measuringtooltypeid == MeasuringToolType.measuringtooltypeid + ).filter(db.or_( + Asset.assetnumber.ilike(pattern), + Asset.name.ilike(pattern), + Asset.serialnumber.ilike(pattern), + MeasuringToolType.name.ilike(pattern), )) if type_id := request.args.get('typeid', type=int): query = query.filter(MeasuringTool.measuringtooltypeid == type_id) diff --git a/plugins/network/api/routes.py b/plugins/network/api/routes.py index 751ae659..46516b3e 100644 --- a/plugins/network/api/routes.py +++ b/plugins/network/api/routes.py @@ -201,14 +201,24 @@ def list_network_devices(): if exactassetnumber := request.args.get('assetnumber'): query = query.filter(Asset.assetnumber == exactassetnumber) - # Search filter + # Search filter. Type and vendor are columns in the list, so both must be + # searchable. Outer joins so a device missing either still matches on its + # own fields. if search := request.args.get('search'): - query = query.filter( + pattern = f'%{search}%' + query = query.outerjoin( + NetworkDeviceType, + NetworkDevice.networkdevicetypeid == NetworkDeviceType.networkdevicetypeid + ).outerjoin( + Vendor, NetworkDevice.vendorid == Vendor.vendorid + ).filter( db.or_( - Asset.assetnumber.ilike(f'%{search}%'), - Asset.name.ilike(f'%{search}%'), - Asset.serialnumber.ilike(f'%{search}%'), - NetworkDevice.hostname.ilike(f'%{search}%') + Asset.assetnumber.ilike(pattern), + Asset.name.ilike(pattern), + Asset.serialnumber.ilike(pattern), + NetworkDevice.hostname.ilike(pattern), + NetworkDeviceType.networkdevicetype.ilike(pattern), + Vendor.vendor.ilike(pattern) ) ) diff --git a/plugins/printers/api/asset_routes.py b/plugins/printers/api/asset_routes.py index c5b52f68..e951e7e9 100644 --- a/plugins/printers/api/asset_routes.py +++ b/plugins/printers/api/asset_routes.py @@ -240,15 +240,24 @@ def list_printers(): if exactassetnumber := request.args.get('assetnumber'): query = query.filter(Asset.assetnumber == exactassetnumber) - # Search filter + # Search filter. Type and model are columns in the list, so searching + # 'Thermal' must find the thermal printers. Outer joins so a printer missing + # either still matches on its own fields. if search := request.args.get('search'): - query = query.filter( + pattern = f'%{search}%' + query = query.outerjoin( + PrinterType, Printer.printertypeid == PrinterType.printertypeid + ).outerjoin( + Model, Printer.modelnumberid == Model.modelnumberid + ).filter( db.or_( - Asset.assetnumber.ilike(f'%{search}%'), - Asset.name.ilike(f'%{search}%'), - Asset.serialnumber.ilike(f'%{search}%'), - Printer.hostname.ilike(f'%{search}%'), - Printer.windowsname.ilike(f'%{search}%') + Asset.assetnumber.ilike(pattern), + Asset.name.ilike(pattern), + Asset.serialnumber.ilike(pattern), + Printer.hostname.ilike(pattern), + Printer.windowsname.ilike(pattern), + PrinterType.printertype.ilike(pattern), + Model.modelnumber.ilike(pattern) ) ) diff --git a/shopdb/core/api/assets.py b/shopdb/core/api/assets.py index 2403765d..4081eff0 100644 --- a/shopdb/core/api/assets.py +++ b/shopdb/core/api/assets.py @@ -354,13 +354,17 @@ def list_assets(): if request.args.get('active', 'true').lower() != 'false': query = query.filter(Asset.isactive == True) - # Search filter + # Search filter. The asset type is a column in the list, so it has to be + # searchable. Matched via .has() (a correlated EXISTS) rather than a join, + # because the type-name filter below joins AssetType itself. if search := request.args.get('search'): + pattern = f'%{search}%' query = query.filter( db.or_( - Asset.assetnumber.ilike(f'%{search}%'), - Asset.name.ilike(f'%{search}%'), - Asset.serialnumber.ilike(f'%{search}%') + Asset.assetnumber.ilike(pattern), + Asset.name.ilike(pattern), + Asset.serialnumber.ilike(pattern), + Asset.assettype.has(AssetType.assettype.ilike(pattern)) ) ) diff --git a/tests/test_plugins/test_list_search_type_columns.py b/tests/test_plugins/test_list_search_type_columns.py new file mode 100644 index 00000000..1b7f7e94 --- /dev/null +++ b/tests/test_plugins/test_list_search_type_columns.py @@ -0,0 +1,166 @@ +"""Every asset list searches the type (and vendor/model) columns it displays. + +Each list shows a Type column, and printers/network also show Model/Vendor, but +the search filters only looked at the asset fields - so searching 'Standard' on +PCs or 'Thermal' on printers returned nothing. One test per list, each also +proving the outer joins did not turn into inner joins. +""" + +from shopdb.extensions import db as _db +from shopdb.core.models import Asset, AssetType +from shopdb.core.models.model import Model +from shopdb.core.models.vendor import Vendor + +from plugins.computers.models import Computer, ComputerType +from plugins.printers.models import Printer, PrinterType +from plugins.network.models import NetworkDevice, NetworkDeviceType +from plugins.measuringtools.models import MeasuringTool, MeasuringToolType + + +def _assettype(name): + assettype = AssetType(assettype=name) + _db.session.add(assettype) + _db.session.flush() + return assettype + + +def _numbers(resp): + return sorted(item['assetnumber'] for item in resp.get_json()['data']) + + +def test_pcs_search_matches_computer_type(client, db): + assettype = _assettype('computer') + standard = ComputerType(computertype='Standard') + _db.session.add(standard) + _db.session.flush() + + typed = Asset(assetnumber='PC-1', assettypeid=assettype.assettypeid) + untyped = Asset(assetnumber='PC-2', name='spare bench box', + assettypeid=assettype.assettypeid) + _db.session.add_all([typed, untyped]) + _db.session.flush() + _db.session.add_all([ + Computer(assetid=typed.assetid, computertypeid=standard.computertypeid), + Computer(assetid=untyped.assetid), + ]) + _db.session.commit() + + resp = client.get('/api/computers?search=Standard') + assert resp.status_code == 200, resp.get_json() + assert _numbers(resp) == ['PC-1'] + + # the typeless PC is still findable on its own fields + resp = client.get('/api/computers?search=bench') + assert _numbers(resp) == ['PC-2'] + + +def test_printers_search_matches_type_and_model(client, db): + assettype = _assettype('printer') + thermal = PrinterType(printertype='Thermal') + model = Model(modelnumber='ZT411') + _db.session.add_all([thermal, model]) + _db.session.flush() + + labeler = Asset(assetnumber='PR-1', assettypeid=assettype.assettypeid) + plain = Asset(assetnumber='PR-2', name='old plotter', + assettypeid=assettype.assettypeid) + _db.session.add_all([labeler, plain]) + _db.session.flush() + _db.session.add_all([ + Printer(assetid=labeler.assetid, printertypeid=thermal.printertypeid, + modelnumberid=model.modelnumberid), + Printer(assetid=plain.assetid), + ]) + _db.session.commit() + + for term in ['Thermal', 'ZT411']: + resp = client.get(f'/api/printers?search={term}') + assert resp.status_code == 200, resp.get_json() + assert _numbers(resp) == ['PR-1'], term + + resp = client.get('/api/printers?search=plotter') + assert _numbers(resp) == ['PR-2'] + + +def test_network_search_matches_type_and_vendor(client, db): + assettype = _assettype('network_device') + switchtype = NetworkDeviceType(networkdevicetype='Switch') + vendor = Vendor(vendor='Cisco') + _db.session.add_all([switchtype, vendor]) + _db.session.flush() + + switch = Asset(assetnumber='NET-1', assettypeid=assettype.assettypeid) + plain = Asset(assetnumber='NET-2', name='unmanaged hub', + assettypeid=assettype.assettypeid) + _db.session.add_all([switch, plain]) + _db.session.flush() + _db.session.add_all([ + NetworkDevice(assetid=switch.assetid, + networkdevicetypeid=switchtype.networkdevicetypeid, + vendorid=vendor.vendorid), + NetworkDevice(assetid=plain.assetid), + ]) + _db.session.commit() + + for term in ['Switch', 'Cisco']: + resp = client.get(f'/api/network?search={term}') + assert resp.status_code == 200, resp.get_json() + assert _numbers(resp) == ['NET-1'], term + + resp = client.get('/api/network?search=hub') + assert _numbers(resp) == ['NET-2'] + + +def test_measuringtools_search_matches_type(client, db): + assettype = _assettype('measuring_tool') + caliper = MeasuringToolType(name='Caliper') + _db.session.add(caliper) + _db.session.flush() + + typed = Asset(assetnumber='MT-1', assettypeid=assettype.assettypeid) + untyped = Asset(assetnumber='MT-2', name='height gage', + assettypeid=assettype.assettypeid) + _db.session.add_all([typed, untyped]) + _db.session.flush() + _db.session.add_all([ + MeasuringTool(assetid=typed.assetid, + measuringtooltypeid=caliper.measuringtooltypeid), + MeasuringTool(assetid=untyped.assetid), + ]) + _db.session.commit() + + resp = client.get('/api/measuringtools?search=Caliper') + assert resp.status_code == 200, resp.get_json() + assert _numbers(resp) == ['MT-1'] + + resp = client.get('/api/measuringtools?search=height') + assert _numbers(resp) == ['MT-2'] + + +def test_core_assets_search_matches_asset_type(client, db): + """The unified asset list shows a Type column too.""" + machine_type = _assettype('machine') + printer_type = _assettype('printer') + _db.session.add_all([ + Asset(assetnumber='A-1', assettypeid=machine_type.assettypeid), + Asset(assetnumber='A-2', assettypeid=printer_type.assettypeid), + ]) + _db.session.commit() + + resp = client.get('/api/assets?search=machine') + assert resp.status_code == 200, resp.get_json() + assert _numbers(resp) == ['A-1'] + + +def test_core_assets_search_type_filter_still_works(client, db): + """The type-name filter joins AssetType; search uses EXISTS, so both apply.""" + machine_type = _assettype('machine') + _db.session.add_all([ + Asset(assetnumber='A-1', name='mill', assettypeid=machine_type.assettypeid), + Asset(assetnumber='A-2', name='lathe', assettypeid=machine_type.assettypeid), + ]) + _db.session.commit() + + resp = client.get('/api/assets?type=machine&search=mill') + assert resp.status_code == 200, resp.get_json() + assert _numbers(resp) == ['A-1'] diff --git a/tests/test_plugins/test_machines_search.py b/tests/test_plugins/test_machines_search.py new file mode 100644 index 00000000..1ef0537b --- /dev/null +++ b/tests/test_plugins/test_machines_search.py @@ -0,0 +1,95 @@ +"""Machines list search covers the columns the list actually shows. + +The table displays Machine #, Name, Serial Number, Type and Vendor, so all of +them must be searchable. Searching a type name ('Part Washer') used to return +zero rows because the filter only looked at the asset fields. +""" + +from shopdb.extensions import db as _db +from shopdb.core.models import Asset, AssetType +from shopdb.core.models.vendor import Vendor + +from plugins.machines.models import Machine, MachineType + + +def _seed_machines(): + """Two machines: one Part Washer by Acme, one Lathe with no type or vendor. + + The typeless machine guards the outer joins - it must still be findable by + its own asset fields. + """ + assettype = AssetType(assettype='machine') + _db.session.add(assettype) + _db.session.flush() + + washertype = MachineType(machinetype='Part Washer') + lathe_vendor = Vendor(vendor='Acme Industrial') + _db.session.add_all([washertype, lathe_vendor]) + _db.session.flush() + + washer_asset = Asset(assetnumber='0410', name='Cell 4 washer', + serialnumber='SN-WASH-1', assettypeid=assettype.assettypeid) + plain_asset = Asset(assetnumber='0999', name='Old lathe', + serialnumber='SN-LATHE-9', assettypeid=assettype.assettypeid) + _db.session.add_all([washer_asset, plain_asset]) + _db.session.flush() + + _db.session.add_all([ + Machine(assetid=washer_asset.assetid, + machinetypeid=washertype.machinetypeid, + vendorid=lathe_vendor.vendorid), + Machine(assetid=plain_asset.assetid), + ]) + _db.session.commit() + + +def _numbers(resp): + return sorted(item['assetnumber'] for item in resp.get_json()['data']) + + +def test_search_matches_machine_type_name(client, db): + _seed_machines() + + resp = client.get('/api/machines?search=Part Washer') + + assert resp.status_code == 200, resp.get_json() + assert _numbers(resp) == ['0410'] + + +def test_search_matches_vendor_name(client, db): + _seed_machines() + + resp = client.get('/api/machines?search=Acme') + + assert resp.status_code == 200, resp.get_json() + assert _numbers(resp) == ['0410'] + + +def test_search_still_matches_asset_fields(client, db): + _seed_machines() + + for term, expected in [('0999', ['0999']), + ('Old lathe', ['0999']), + ('SN-WASH', ['0410'])]: + resp = client.get(f'/api/machines?search={term}') + assert resp.status_code == 200, resp.get_json() + assert _numbers(resp) == expected, term + + +def test_search_does_not_drop_machines_without_type_or_vendor(client, db): + """The outer joins must not turn into inner joins.""" + _seed_machines() + + resp = client.get('/api/machines?search=lathe') + + assert resp.status_code == 200, resp.get_json() + assert _numbers(resp) == ['0999'] + + +def test_search_miss_returns_empty(client, db): + _seed_machines() + + resp = client.get('/api/machines?search=zzzznotathing') + + assert resp.status_code == 200, resp.get_json() + assert _numbers(resp) == []