mirror of
https://github.com/zvx-echo6/central.git
synced 2026-05-21 18:14:44 +02:00
Merge pull request #48 from zvx-echo6/feat/l-c-events-table-readable
feat(L-c): operator /events table polish — readable Time, Location, Subject, Adapter columns; sortable; plain-language summaries
This commit is contained in:
commit
496dd1626f
17 changed files with 381 additions and 17 deletions
|
|
@ -4,7 +4,7 @@ import base64
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
logger = logging.getLogger("central.gui.routes")
|
logger = logging.getLogger("central.gui.routes")
|
||||||
|
|
@ -2870,6 +2870,31 @@ def _geometry_summary(geometry: dict | None) -> str:
|
||||||
return geom_type
|
return geom_type
|
||||||
|
|
||||||
|
|
||||||
|
def _format_event_time(iso: str | None) -> str:
|
||||||
|
"""Format an ISO-8601 timestamp as 'MM-DD-YYYY HH:MM UTC' (24h, no seconds)."""
|
||||||
|
if not iso:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(iso).astimezone(timezone.utc)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return iso
|
||||||
|
return dt.strftime("%m-%d-%Y %H:%M") + " UTC"
|
||||||
|
|
||||||
|
|
||||||
|
def _decorate_table_events(events: list[dict]) -> None:
|
||||||
|
"""Add display-only fields used by the HTML events table (in place).
|
||||||
|
|
||||||
|
These are for the table chrome only and are deliberately NOT added in
|
||||||
|
_fetch_events, so the /events.json payload is unchanged. adapter_display
|
||||||
|
is sourced from the registry (display_name), with the bare name as fallback.
|
||||||
|
"""
|
||||||
|
display = {cls.name: cls.display_name for cls in discover_adapters().values()}
|
||||||
|
for event in events:
|
||||||
|
event["geometry_summary"] = _geometry_summary(event.get("geometry"))
|
||||||
|
event["time_human"] = _format_event_time(event.get("time"))
|
||||||
|
event["adapter_display"] = display.get(event.get("adapter"), event.get("adapter"))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/events.json")
|
@router.get("/events.json")
|
||||||
async def events_json(request: Request):
|
async def events_json(request: Request):
|
||||||
|
|
@ -2958,9 +2983,8 @@ async def events_list(request: Request) -> HTMLResponse:
|
||||||
events = result.events
|
events = result.events
|
||||||
next_cursor = result.next_cursor
|
next_cursor = result.next_cursor
|
||||||
|
|
||||||
# Add geometry summary to each event
|
# Add table-only display fields (time_human, adapter_display, geometry_summary)
|
||||||
for event in events:
|
_decorate_table_events(events)
|
||||||
event["geometry_summary"] = _geometry_summary(event.get("geometry"))
|
|
||||||
|
|
||||||
# Registry-derived adapter list for the filter <select> and map legend.
|
# Registry-derived adapter list for the filter <select> and map legend.
|
||||||
# Sorted by name for stable ordering; index drives the legend color palette.
|
# Sorted by name for stable ordering; index drives the legend color palette.
|
||||||
|
|
@ -3022,9 +3046,8 @@ async def events_rows(request: Request) -> HTMLResponse:
|
||||||
events = result.events
|
events = result.events
|
||||||
next_cursor = result.next_cursor
|
next_cursor = result.next_cursor
|
||||||
|
|
||||||
# Add geometry summary to each event
|
# Add table-only display fields (time_human, adapter_display, geometry_summary)
|
||||||
for event in events:
|
_decorate_table_events(events)
|
||||||
event["geometry_summary"] = _geometry_summary(event.get("geometry"))
|
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request=request,
|
request=request,
|
||||||
|
|
|
||||||
2
src/central/gui/templates/_event_summaries/_default.html
Normal file
2
src/central/gui/templates/_event_summaries/_default.html
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
{# No per-adapter summary; the Subject cell falls back to "—" and the map
|
||||||
|
popup omits the subject line. #}
|
||||||
2
src/central/gui/templates/_event_summaries/eonet.html
Normal file
2
src/central/gui/templates/_event_summaries/eonet.html
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
{% set d = (event.data.get('data') or {}).get('data') or {} %}
|
||||||
|
{%- if d.get('title') %}{{ d.title }}{% endif -%}
|
||||||
2
src/central/gui/templates/_event_summaries/firms.html
Normal file
2
src/central/gui/templates/_event_summaries/firms.html
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
{% set d = (event.data.get('data') or {}).get('data') or {} %}
|
||||||
|
{%- if d.get('frp') is not none or d.get('confidence') %}Fire detected{% if d.get('frp') is not none %} — {{ d.frp }} MW radiative power{% endif %}{% if d.get('confidence') == 'high' %} (high confidence){% endif %}{% endif -%}
|
||||||
2
src/central/gui/templates/_event_summaries/gdacs.html
Normal file
2
src/central/gui/templates/_event_summaries/gdacs.html
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
{% set d = (event.data.get('data') or {}).get('data') or {} %}
|
||||||
|
{%- if d.get('title') %}{{ d.title }}{% endif %}{% if d.get('alertlevel') %} — {{ d.alertlevel }} alert{% endif -%}
|
||||||
2
src/central/gui/templates/_event_summaries/inciweb.html
Normal file
2
src/central/gui/templates/_event_summaries/inciweb.html
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
{% set d = (event.data.get('data') or {}).get('data') or {} %}
|
||||||
|
{%- if d.get('title') %}{{ d.title }}{% endif -%}
|
||||||
2
src/central/gui/templates/_event_summaries/nwis.html
Normal file
2
src/central/gui/templates/_event_summaries/nwis.html
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
{% set d = (event.data.get('data') or {}).get('data') or {} %}
|
||||||
|
{%- if d.get('value') is not none %}Water reading: {{ d.value }}{% if d.get('unit_of_measure') %} {{ d.unit_of_measure }}{% endif %}{% endif -%}
|
||||||
2
src/central/gui/templates/_event_summaries/nws.html
Normal file
2
src/central/gui/templates/_event_summaries/nws.html
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
{% set d = (event.data.get('data') or {}).get('data') or {} %}
|
||||||
|
{%- if d.get('event') %}{{ d.event }}{% endif %}{% if d.get('severity') %} — {{ d.severity }}{% endif -%}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
{% set d = (event.data.get('data') or {}).get('data') or {} %}
|
||||||
|
{%- if d.get('product_id') or d.get('message') %}Space weather alert{% if d.get('product_id') %} {{ d.product_id }}{% endif %}{% if d.get('message') %}: {{ d.message | replace('\r', ' ') | replace('\n', ' ') | truncate(80) }}{% endif %}{% endif -%}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
{% set d = (event.data.get('data') or {}).get('data') or {} %}
|
||||||
|
{%- if d.get('Kp') is not none %}Geomagnetic activity (Kp index): {{ d.Kp }}{% endif -%}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
{% set d = (event.data.get('data') or {}).get('data') or {} %}
|
||||||
|
{%- if d.get('flux') is not none %}Solar proton flux: {{ d.flux | round(2) }} pfu{% if d.get('energy') %} at {{ d.energy }}{% endif %}{% endif -%}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
{% set d = (event.data.get('data') or {}).get('data') or {} %}
|
||||||
|
{%- if d.get('magnitude') is not none %}Magnitude {{ d.magnitude | round(1) }}{% if d.get('place') %} — {{ d.place }}{% endif %}{% endif -%}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
{% set d = (event.data.get('data') or {}).get('data') or {} %}
|
||||||
|
{%- if d.get('county') or d.get('state') %}Wildfire incident — {% if d.get('county') %}{{ d.county }}{% if d.get('state') %}, {% endif %}{% endif %}{% if d.get('state') %}{{ d.state }}{% endif %}{% endif -%}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
{% set d = (event.data.get('data') or {}).get('data') or {} %}
|
||||||
|
{%- if d.get('county') or d.get('state') %}Wildfire perimeter — {% if d.get('county') %}{{ d.county }}{% if d.get('state') %}, {% endif %}{% endif %}{% if d.get('state') %}{{ d.state }}{% endif %}{% endif -%}
|
||||||
|
|
@ -10,30 +10,40 @@
|
||||||
<tr>
|
<tr>
|
||||||
<th style="width: 2rem;"></th>
|
<th style="width: 2rem;"></th>
|
||||||
<th>Time</th>
|
<th>Time</th>
|
||||||
<th>Adapter</th>
|
<th>Location</th>
|
||||||
<th>Category</th>
|
|
||||||
<th>Geometry</th>
|
|
||||||
<th>Subject</th>
|
<th>Subject</th>
|
||||||
|
<th>Adapter</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for event in events %}
|
{% for event in events %}
|
||||||
|
{# Per-adapter one-line summary, dispatched by adapter name with a generic
|
||||||
|
fallback (no hardcoded list). Captured once so it serves both the
|
||||||
|
Subject cell and the map popup (via data-subject). #}
|
||||||
|
{% set subject_summary %}{% include ["_event_summaries/" ~ event.adapter ~ ".html", "_event_summaries/_default.html"] %}{% endset %}
|
||||||
|
{# Location: generic _enriched.geocoder reader, then top-level named
|
||||||
|
fields, then coordinates. No adapter-specific logic. #}
|
||||||
|
{% set d = (event.data.get('data') or {}).get('data') or {} %}
|
||||||
|
{% set gc = (d.get('_enriched') or {}).get('geocoder') or {} %}
|
||||||
|
{% set loc_local = gc.get('city') or d.get('city') or gc.get('county') or d.get('county') %}
|
||||||
|
{% set loc_state = gc.get('state') or d.get('state') %}
|
||||||
|
{% set loc_country = gc.get('country') or d.get('country') %}
|
||||||
|
{% set loc_parts = [loc_local, loc_state, loc_country] | select | list %}
|
||||||
<tr class="event-row" data-row-idx="{{ loop.index0 }}"
|
<tr class="event-row" data-row-idx="{{ loop.index0 }}"
|
||||||
data-event-id="{{ event.id }}"
|
data-event-id="{{ event.id }}"
|
||||||
data-adapter="{{ event.adapter }}"
|
data-adapter="{{ event.adapter }}"
|
||||||
data-category="{{ event.category }}"
|
data-category="{{ event.category }}"
|
||||||
data-time="{{ event.time }}"
|
data-time="{{ event.time }}"
|
||||||
data-subject="{{ event.subject or '' }}"
|
data-subject="{{ subject_summary | trim }}"
|
||||||
{% if event.geometry %}data-geometry='{{ event.geometry | tojson }}'{% endif %}>
|
{% if event.geometry %}data-geometry='{{ event.geometry | tojson }}'{% endif %}>
|
||||||
<td><button type="button" class="expand-row" aria-label="Expand">▸</button></td>
|
<td><button type="button" class="expand-row" aria-label="Expand">▸</button></td>
|
||||||
<td>{{ event.time }}</td>
|
<td title="{{ event.time }}">{{ event.time_human }}</td>
|
||||||
<td>{{ event.adapter }}</td>
|
<td>{% if loc_parts %}{{ loc_parts | join(', ') }}{% elif gc.get('landclass') %}{{ gc.landclass }}{% else %}—{% endif %}</td>
|
||||||
<td>{{ event.category }}</td>
|
<td>{{ subject_summary | trim or '—' }}</td>
|
||||||
<td>{{ event.geometry_summary }}</td>
|
<td>{{ event.adapter_display }}</td>
|
||||||
<td>{{ event.subject or '—' }}</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr class="event-detail" hidden>
|
<tr class="event-detail" hidden>
|
||||||
<td colspan="6">
|
<td colspan="5">
|
||||||
<dl class="event-detail-list">
|
<dl class="event-detail-list">
|
||||||
<dt>Event ID</dt>
|
<dt>Event ID</dt>
|
||||||
<dd><code>{{ event.id }}</code></dd>
|
<dd><code>{{ event.id }}</code></dd>
|
||||||
|
|
|
||||||
|
|
@ -465,8 +465,67 @@
|
||||||
// Fit to results button
|
// Fit to results button
|
||||||
document.getElementById("fit-to-results").addEventListener("click", fitToAllLayers);
|
document.getElementById("fit-to-results").addEventListener("click", fitToAllLayers);
|
||||||
|
|
||||||
|
// Client-side sort of the displayed rows; state persists across HTMX swaps.
|
||||||
|
var sortState = { col: null, dir: 1 }; // dir: 1 asc, -1 desc
|
||||||
|
|
||||||
|
function sortKey(row, col) {
|
||||||
|
// Time sorts on the ISO timestamp for true chronological order.
|
||||||
|
if (col === 1) return row.dataset.time || "";
|
||||||
|
var cell = row.children[col];
|
||||||
|
return cell ? cell.textContent.trim().toLowerCase() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySort() {
|
||||||
|
if (sortState.col === null) return;
|
||||||
|
var tbody = document.querySelector("#events-rows table.events-table tbody");
|
||||||
|
if (!tbody) return;
|
||||||
|
var pairs = [];
|
||||||
|
tbody.querySelectorAll("tr.event-row").forEach(function(r) {
|
||||||
|
pairs.push([r, r.nextElementSibling]); // main row + its detail row
|
||||||
|
});
|
||||||
|
// Array.prototype.sort is stable (ES2019+), so equal keys keep order.
|
||||||
|
pairs.sort(function(a, b) {
|
||||||
|
var ka = sortKey(a[0], sortState.col), kb = sortKey(b[0], sortState.col);
|
||||||
|
if (ka < kb) return -sortState.dir;
|
||||||
|
if (ka > kb) return sortState.dir;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
pairs.forEach(function(p) {
|
||||||
|
tbody.appendChild(p[0]);
|
||||||
|
if (p[1]) tbody.appendChild(p[1]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSortIndicators(ths) {
|
||||||
|
ths.forEach(function(th, idx) {
|
||||||
|
th.querySelectorAll(".sort-ind").forEach(function(s) { s.remove(); });
|
||||||
|
if (idx === sortState.col) {
|
||||||
|
var s = document.createElement("span");
|
||||||
|
s.className = "sort-ind";
|
||||||
|
s.textContent = sortState.dir === 1 ? " ▲" : " ▼";
|
||||||
|
th.appendChild(s);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindSortHandlers() {
|
||||||
|
var ths = document.querySelectorAll("#events-rows table.events-table thead th");
|
||||||
|
ths.forEach(function(th, idx) {
|
||||||
|
if (idx === 0) return; // expand column is not sortable
|
||||||
|
th.style.cursor = "pointer";
|
||||||
|
th.onclick = function() {
|
||||||
|
if (sortState.col === idx) { sortState.dir *= -1; }
|
||||||
|
else { sortState.col = idx; sortState.dir = 1; }
|
||||||
|
updateSortIndicators(ths);
|
||||||
|
applySort();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
updateSortIndicators(ths);
|
||||||
|
}
|
||||||
|
|
||||||
// Initial load - bind layers and fit bounds
|
// Initial load - bind layers and fit bounds
|
||||||
rebindEventLayers(); // Initial load only
|
rebindEventLayers(); // Initial load only
|
||||||
|
bindSortHandlers();
|
||||||
if (false) { // DISABLED: map never auto-fits
|
if (false) { // DISABLED: map never auto-fits
|
||||||
fitToAllLayers();
|
fitToAllLayers();
|
||||||
isInitialLoad = false;
|
isInitialLoad = false;
|
||||||
|
|
@ -477,6 +536,9 @@
|
||||||
document.body.addEventListener("htmx:afterSwap", function(evt) {
|
document.body.addEventListener("htmx:afterSwap", function(evt) {
|
||||||
if (evt.detail.target.id === "events-rows") {
|
if (evt.detail.target.id === "events-rows") {
|
||||||
rebindEventLayers();
|
rebindEventLayers();
|
||||||
|
// Re-bind sort handlers to the new rows and re-apply the active sort.
|
||||||
|
bindSortHandlers();
|
||||||
|
applySort();
|
||||||
// Do NOT call fitToAllLayers - preserve user viewport
|
// Do NOT call fitToAllLayers - preserve user viewport
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -842,3 +842,246 @@ class TestMapAllAdapterGeometry:
|
||||||
).read_text()
|
).read_text()
|
||||||
assert "// rebindEventLayers(); // DISABLED" not in src
|
assert "// rebindEventLayers(); // DISABLED" not in src
|
||||||
assert "isDegenerate" in src
|
assert "isDegenerate" in src
|
||||||
|
|
||||||
|
|
||||||
|
# --- PR L-c: readable Time / Location / Subject / Adapter columns ---------
|
||||||
|
|
||||||
|
|
||||||
|
def _first_row_cells(html):
|
||||||
|
"""Visible <td> text of the first event-row (before its detail row).
|
||||||
|
|
||||||
|
Splits on the detail row's class attribute (not the bare string
|
||||||
|
'event-detail', which also appears in the page's <style> block).
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
body = html.split('class="event-detail"')[0]
|
||||||
|
return [
|
||||||
|
re.sub(r"<[^>]+>", "", c).strip()
|
||||||
|
for c in re.findall(r"<td[^>]*>(.*?)</td>", body, re.S)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class TestEventTimeFormat:
|
||||||
|
"""(A) Server-side 'MM-DD-YYYY HH:MM UTC' formatting (24h, no seconds)."""
|
||||||
|
|
||||||
|
def test_format_basic_utc(self):
|
||||||
|
from central.gui.routes import _format_event_time
|
||||||
|
assert _format_event_time("2026-05-21T06:00:00+00:00") == "05-21-2026 06:00 UTC"
|
||||||
|
|
||||||
|
def test_format_converts_offset_to_utc(self):
|
||||||
|
from central.gui.routes import _format_event_time
|
||||||
|
# 19:30 at -06:00 is 01:30 UTC the next day.
|
||||||
|
assert _format_event_time("2026-05-20T19:30:00-06:00") == "05-21-2026 01:30 UTC"
|
||||||
|
|
||||||
|
def test_format_empty_and_none(self):
|
||||||
|
from central.gui.routes import _format_event_time
|
||||||
|
assert _format_event_time("") == ""
|
||||||
|
assert _format_event_time(None) == ""
|
||||||
|
|
||||||
|
def test_format_no_seconds_no_offset_suffix(self):
|
||||||
|
from central.gui.routes import _format_event_time
|
||||||
|
out = _format_event_time("2026-01-02T03:04:59+00:00")
|
||||||
|
assert out == "01-02-2026 03:04 UTC"
|
||||||
|
assert ":59" not in out and "+00" not in out
|
||||||
|
|
||||||
|
|
||||||
|
class TestTableDisplayDecoration:
|
||||||
|
"""(A)/(D) _decorate_table_events adds display fields; /events.json unaffected."""
|
||||||
|
|
||||||
|
def test_adapter_display_matches_registry(self):
|
||||||
|
from central.adapter_discovery import discover_adapters
|
||||||
|
from central.gui.routes import _decorate_table_events, _format_event_time
|
||||||
|
registry = discover_adapters()
|
||||||
|
events = [_event(name) for name in registry]
|
||||||
|
_decorate_table_events(events)
|
||||||
|
for ev in events:
|
||||||
|
cls = registry[ev["adapter"]]
|
||||||
|
assert ev["adapter_display"] == cls.display_name
|
||||||
|
assert ev["time_human"] == _format_event_time(ev["time"])
|
||||||
|
|
||||||
|
def test_unknown_adapter_display_falls_back_to_name(self):
|
||||||
|
from central.gui.routes import _decorate_table_events
|
||||||
|
events = [_event("not_a_real_adapter")]
|
||||||
|
_decorate_table_events(events)
|
||||||
|
assert events[0]["adapter_display"] == "not_a_real_adapter"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_events_json_has_no_table_only_fields(self):
|
||||||
|
# No /events.json schema change: display fields must not leak into JSON.
|
||||||
|
mock_request = MagicMock()
|
||||||
|
mock_request.state.operator = MagicMock(id=1, username="admin")
|
||||||
|
mock_request.query_params = {}
|
||||||
|
|
||||||
|
mock_conn = AsyncMock()
|
||||||
|
mock_conn.fetch.return_value = [{
|
||||||
|
"id": "e1",
|
||||||
|
"time": datetime(2026, 5, 21, 6, 0, tzinfo=timezone.utc),
|
||||||
|
"received": datetime(2026, 5, 21, 6, 0, tzinfo=timezone.utc),
|
||||||
|
"adapter": "nwis",
|
||||||
|
"category": "hydro",
|
||||||
|
"subject": None,
|
||||||
|
"geometry": None,
|
||||||
|
"data": {},
|
||||||
|
"regions": [],
|
||||||
|
}]
|
||||||
|
mock_pool = MagicMock()
|
||||||
|
mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
|
||||||
|
mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
with patch("central.gui.routes.get_pool", return_value=mock_pool):
|
||||||
|
resp = await events_json(mock_request)
|
||||||
|
|
||||||
|
ev = json.loads(resp.body)["events"][0]
|
||||||
|
assert "time_human" not in ev
|
||||||
|
assert "adapter_display" not in ev
|
||||||
|
|
||||||
|
|
||||||
|
class TestLocationColumn:
|
||||||
|
"""(B) Generic _enriched/top-level location reader — no adapter logic."""
|
||||||
|
|
||||||
|
def _location(self, inner):
|
||||||
|
return _first_row_cells(_render_rows([_event("usgs_quake", inner=inner)]))[2]
|
||||||
|
|
||||||
|
def test_city_state_country(self):
|
||||||
|
loc = self._location({"_enriched": {"geocoder": {
|
||||||
|
"city": "Trinidad", "state": "Colorado", "country": "United States"}}})
|
||||||
|
assert loc == "Trinidad, Colorado, United States"
|
||||||
|
|
||||||
|
def test_state_country_when_city_null(self):
|
||||||
|
loc = self._location({"_enriched": {"geocoder": {
|
||||||
|
"city": None, "state": "Missouri", "country": "United States"}}})
|
||||||
|
assert loc == "Missouri, United States"
|
||||||
|
|
||||||
|
def test_top_level_country_fallback(self):
|
||||||
|
# gdacs-style: no geocoder, country at top level.
|
||||||
|
assert self._location({"country": "Austria"}) == "Austria"
|
||||||
|
|
||||||
|
def test_top_level_state_only_fallback(self):
|
||||||
|
# wfigs-style: state code at top level, no country.
|
||||||
|
assert self._location({"state": "CO"}) == "CO"
|
||||||
|
|
||||||
|
def test_landclass_fallback(self):
|
||||||
|
loc = self._location({"_enriched": {"geocoder": {
|
||||||
|
"city": None, "state": None, "country": None,
|
||||||
|
"landclass": "Ridgecrest Field Office"}}})
|
||||||
|
assert loc == "Ridgecrest Field Office"
|
||||||
|
|
||||||
|
def test_coordinates_alone_are_not_a_location(self):
|
||||||
|
# Bare lat/lon is a position, not a place name -> "—".
|
||||||
|
assert self._location({"latitude": 35.3603324, "longitude": -117.7854995}) == "—"
|
||||||
|
|
||||||
|
def test_none_when_nothing_available(self):
|
||||||
|
assert self._location({}) == "—"
|
||||||
|
|
||||||
|
|
||||||
|
class TestSubjectColumn:
|
||||||
|
"""(C) Per-adapter one-line summaries, registry-derived dispatch + fallback."""
|
||||||
|
|
||||||
|
def test_every_adapter_has_a_summary_partial(self):
|
||||||
|
from central.adapter_discovery import discover_adapters
|
||||||
|
from central.gui import templates as gui_templates
|
||||||
|
for name in discover_adapters():
|
||||||
|
gui_templates.env.get_template("_event_summaries/%s.html" % name)
|
||||||
|
|
||||||
|
def test_default_summary_partial_exists(self):
|
||||||
|
from central.gui import templates as gui_templates
|
||||||
|
gui_templates.env.get_template("_event_summaries/_default.html")
|
||||||
|
|
||||||
|
def test_every_adapter_summary_renders_without_error(self):
|
||||||
|
from central.adapter_discovery import discover_adapters
|
||||||
|
for name in discover_adapters():
|
||||||
|
_render_rows([_event(name)]) # must not raise
|
||||||
|
|
||||||
|
def test_usgs_quake_summary_is_plain_language(self):
|
||||||
|
cells = _first_row_cells(_render_rows([_event(
|
||||||
|
"usgs_quake", inner={"magnitude": 1.347, "magType": "ml",
|
||||||
|
"place": "14 km W of Johannesburg, CA"})]))
|
||||||
|
assert cells[3] == "Magnitude 1.3 — 14 km W of Johannesburg, CA"
|
||||||
|
assert "ml" not in cells[3] # scale code dropped
|
||||||
|
|
||||||
|
def test_nwis_summary_drops_parameter_code(self):
|
||||||
|
cells = _first_row_cells(_render_rows([_event(
|
||||||
|
"nwis", inner={"parameter_code": "00060", "value": 111.0,
|
||||||
|
"unit_of_measure": "ft^3/s"})]))
|
||||||
|
assert cells[3] == "Water reading: 111.0 ft^3/s"
|
||||||
|
assert "00060" not in cells[3] # opaque pcode dropped
|
||||||
|
|
||||||
|
def test_unknown_adapter_summary_is_dash(self):
|
||||||
|
cells = _first_row_cells(_render_rows([_event("not_a_real_adapter", inner={"x": 1})]))
|
||||||
|
assert cells[3] == "—"
|
||||||
|
|
||||||
|
def test_summary_populates_data_subject_for_popup(self):
|
||||||
|
html = _render_rows([_event("usgs_quake", inner={"magnitude": 1.347,
|
||||||
|
"place": "near Town"})])
|
||||||
|
assert 'data-subject="Magnitude 1.3 — near Town"' in html
|
||||||
|
|
||||||
|
|
||||||
|
class TestTableRendersThroughHTTP:
|
||||||
|
"""End-to-end HTTP render: Time and Adapter cells must be populated in the
|
||||||
|
real response, guarding the route->template binding (not the helper alone)."""
|
||||||
|
|
||||||
|
def _mock_pool(self):
|
||||||
|
now = datetime(2026, 5, 21, 6, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
def _fetchrow(query, *args):
|
||||||
|
q = " ".join(str(query).split())
|
||||||
|
if "config.sessions" in q:
|
||||||
|
return {"id": 1, "username": "admin", "created_at": now,
|
||||||
|
"password_changed_at": now, "csrf_token": "csrf"}
|
||||||
|
if "map_tile_url" in q:
|
||||||
|
return {"map_tile_url": "https://t/{z}/{x}/{y}.png",
|
||||||
|
"map_attribution": "OSM"}
|
||||||
|
return None
|
||||||
|
|
||||||
|
rows = [
|
||||||
|
{"id": "evt-q", "time": now, "received": now, "adapter": "usgs_quake",
|
||||||
|
"category": "quake", "subject": None, "geometry": None,
|
||||||
|
"data": {"data": {"data": {"magnitude": 1.3, "place": "near Town"}}},
|
||||||
|
"regions": []},
|
||||||
|
{"id": "evt-w", "time": now, "received": now, "adapter": "nwis",
|
||||||
|
"category": "hydro", "subject": None, "geometry": None,
|
||||||
|
"data": {"data": {"data": {"value": 5.0, "unit_of_measure": "ft"}}},
|
||||||
|
"regions": []},
|
||||||
|
]
|
||||||
|
mock_conn = MagicMock()
|
||||||
|
mock_conn.fetchrow = AsyncMock(side_effect=_fetchrow)
|
||||||
|
mock_conn.fetch = AsyncMock(return_value=rows)
|
||||||
|
mock_conn.__aenter__ = AsyncMock(return_value=mock_conn)
|
||||||
|
mock_conn.__aexit__ = AsyncMock()
|
||||||
|
mock_pool = MagicMock()
|
||||||
|
mock_pool.acquire = MagicMock(return_value=mock_conn)
|
||||||
|
return mock_pool
|
||||||
|
|
||||||
|
def _client(self):
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
from central.gui.middleware import SessionMiddleware
|
||||||
|
from central.gui.routes import router
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(router)
|
||||||
|
app.add_middleware(SessionMiddleware)
|
||||||
|
return TestClient(app, cookies={"central_session": "valid"})
|
||||||
|
|
||||||
|
def _expected_adapter_display(self):
|
||||||
|
from central.adapter_discovery import discover_adapters
|
||||||
|
return discover_adapters()["usgs_quake"].display_name
|
||||||
|
|
||||||
|
def test_events_page_time_and_adapter_cells_populated(self):
|
||||||
|
mock_pool = self._mock_pool()
|
||||||
|
with patch("central.gui.middleware.get_pool", return_value=mock_pool), \
|
||||||
|
patch("central.gui.routes.get_pool", return_value=mock_pool):
|
||||||
|
resp = self._client().get("/events")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
cells = _first_row_cells(resp.text)
|
||||||
|
assert cells[1].endswith("UTC") and "2026" in cells[1] # Time non-empty
|
||||||
|
assert cells[4] == self._expected_adapter_display() # Adapter display_name
|
||||||
|
|
||||||
|
def test_events_rows_fragment_time_and_adapter_cells_populated(self):
|
||||||
|
mock_pool = self._mock_pool()
|
||||||
|
with patch("central.gui.middleware.get_pool", return_value=mock_pool), \
|
||||||
|
patch("central.gui.routes.get_pool", return_value=mock_pool):
|
||||||
|
resp = self._client().get("/events/rows")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
cells = _first_row_cells(resp.text)
|
||||||
|
assert cells[1].endswith("UTC") and "2026" in cells[1] # Time non-empty
|
||||||
|
assert cells[4] == self._expected_adapter_display() # Adapter display_name
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue