Per-type expiry tail on the shopfloor board, and a 2x2 recertification block

This commit is contained in:
2026-08-07 09:28:10 -04:00
parent dd6ccd7a55
commit 2f15610cfd
7 changed files with 277 additions and 25 deletions

View File

@@ -0,0 +1,141 @@
"""Tests for the per-type post-expiry window on the shopfloor feed.
The board used to keep every ended notification for a hardcoded 30 minutes,
flagged resolved. A card with an 8:00 end time was therefore still up at 8:29,
and in the carousel/grid/banner sections nothing marked it as ended at all. The
tail is now notificationtypes.gracewindowminutes, default 0: an end time means
the card leaves the board then, and a type opts in if its cards are worth
acknowledging afterwards.
"""
from datetime import datetime, timedelta, timezone
from plugins.notifications.models import Notification, NotificationType
def _utcnow():
return datetime.now(timezone.utc).replace(tzinfo=None)
def _make_type(db, typename, grace=0, displaystyle='standard'):
t = NotificationType(typename=typename, typecolor='#17a2b8', isactive=True,
displaystyle=displaystyle, gracewindowminutes=grace)
db.session.add(t)
db.session.commit()
return t
def _make_note(db, ntype, ended_minutes_ago=None, text='msg'):
"""Shopfloor note that ended N minutes ago (None = still running)."""
now = _utcnow()
n = Notification(
notificationtypeid=ntype.notificationtypeid,
notification=text,
businessunitid=None,
isactive=True,
isshopfloor=True,
starttime=now - timedelta(hours=2),
endtime=None if ended_minutes_ago is None
else now - timedelta(minutes=ended_minutes_ago),
)
db.session.add(n)
db.session.commit()
return n
def _current(client):
resp = client.get('/api/notifications/shopfloor')
assert resp.status_code == 200, resp.get_json()
return resp.get_json()['data']['current']
def test_default_type_drops_the_card_at_its_end_time(client, db):
"""The reported bug: an 8:00 end time meant gone at 8:30."""
ntype = _make_type(db, 'General')
_make_note(db, ntype, ended_minutes_ago=1)
assert _current(client) == []
def test_new_type_defaults_to_no_tail(client, db):
"""A type created without naming the field gets 0, not a surprise tail."""
ntype = NotificationType(typename='Fresh', typecolor='#17a2b8', isactive=True)
db.session.add(ntype)
db.session.commit()
assert (ntype.gracewindowminutes or 0) == 0
def test_running_card_is_unaffected(client, db):
ntype = _make_type(db, 'General')
_make_note(db, ntype)
current = _current(client)
assert len(current) == 1
assert current[0]['resolved'] is False
def test_type_with_a_tail_keeps_the_card_marked_resolved(client, db):
ntype = _make_type(db, 'Incident', grace=30)
_make_note(db, ntype, ended_minutes_ago=10)
current = _current(client)
assert len(current) == 1
assert current[0]['resolved'] is True
def test_card_leaves_once_past_its_own_tail(client, db):
ntype = _make_type(db, 'Incident', grace=30)
_make_note(db, ntype, ended_minutes_ago=31)
assert _current(client) == []
def test_each_type_is_held_to_its_own_window(client, db):
"""The widest tail must not leak onto a type that asked for none.
The query widens to the largest configured window, so without the per-row
check the no-tail type would ride along on the incident type's 60 minutes.
"""
lingering = _make_type(db, 'Incident', grace=60)
immediate = _make_type(db, 'General', grace=0)
_make_note(db, lingering, ended_minutes_ago=20, text='incident')
_make_note(db, immediate, ended_minutes_ago=20, text='general')
texts = {c['notification'] for c in _current(client)}
assert texts == {'incident'}
def test_carousel_type_obeys_the_window_too(client, db):
"""Recognition is a carousel card, where nothing renders `resolved`."""
ntype = _make_type(db, 'Recognition', grace=0, displaystyle='carousel')
_make_note(db, ntype, ended_minutes_ago=5)
assert _current(client) == []
def test_api_rejects_a_negative_window(client, db, auth_headers):
resp = client.post('/api/notifications/types',
json={'typename': 'Bad', 'gracewindowminutes': -5},
headers=auth_headers)
assert resp.status_code == 400
assert 'gracewindowminutes' in resp.get_data(as_text=True)
def test_api_round_trips_the_window(client, db, auth_headers):
create = client.post('/api/notifications/types',
json={'typename': 'Incident', 'gracewindowminutes': 45},
headers=auth_headers)
assert create.status_code in (200, 201), create.get_json()
typeid = create.get_json()['data']['notificationtypeid']
listed = client.get('/api/notifications/types', headers=auth_headers)
row = next(t for t in listed.get_json()['data']
if t['notificationtypeid'] == typeid)
assert row['gracewindowminutes'] == 45
update = client.put(f'/api/notifications/types/{typeid}',
json={'gracewindowminutes': 0}, headers=auth_headers)
assert update.status_code == 200, update.get_json()
assert update.get_json()['data']['gracewindowminutes'] == 0