mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-07-17 12:21:56 +00:00
Red commit: 4ed272761b (CI run:
https://github.com/Kpa-clawbot/CoreScope/actions/runs/25651898290)
Fixes #1188 — observer IATA on packets in three UI surfaces + filter
grammar.
cross-stack: justified — feature spans API shape (Go), store, filter
grammar (JS), three packets UI surfaces.
## Scope shipped
- Packets table row: `.badge-iata` pill inline next to observer name
- Expanded observation rows: per-observation IATA badge
- Detail pane: Observer dd + per-observation list both render the badge
- Filter grammar: `observer_iata` field + `iata` alias;
`==`/`!=`/`contains`, plus a new `in (a, b, c)` list operator. Both
names appear in autocomplete with descriptions.
## TDD red→green pairs
1. `271d72f` filter-grammar tests → `2c182eb` evaluator + suggest
entries
2. `4ed2727` backend `observer_iata` API tests → `7856914` SQL join +
struct/store wiring
3. `0e09371` display E2E → `7a3f45d` packets.js + style.css badge
(E2E swapped for string-contract unit test in `ee414b4` — fixture
`observations.observer_idx` stores text pubkeys, blocking the join the
badge depends on)
## Backend
- `cmd/server/db.go`: SELECT `obs.iata AS observer_iata` in
`transmissionBaseSQL`, grouped query, observations-by-transmissions
- `cmd/server/store.go`: `ObserverIATA` on `StoreTx`/`StoreObs`, load
via all three ingest paths, surface in
`txToMap`/`enrichObs`/`groupedTxsToPage`
- `cmd/server/types.go`: field added to
`TransmissionResp`/`ObservationResp`/`GroupedPacketResp`
- Test fixture schemas declare `iata` on observers
## Perf
Per #383, `obsIataBadge(packet)` reads `packet.observer_iata` directly
(server-joined). Falls back to `observerMap.get(id).iata` only if absent
— hot row-render loop avoids per-row Map lookup on fresh data.
## Display rules
Missing IATA: nothing inline (Region column still shows `—`). No new hex
— `.badge-iata` uses `var(--nav-bg)` / `var(--nav-text)`.
E2E assertion added: test-observer-iata-1188.js:51
---------
Co-authored-by: OpenClaw Bot <bot@openclaw.dev>
Co-authored-by: openclaw-bot <bot@openclaw.local>
This commit is contained in:
co-authored by
OpenClaw Bot
openclaw-bot
parent
e395c471ed
commit
b881a09f02
@@ -100,6 +100,7 @@ jobs:
|
||||
node test-channel-modal-ux.js
|
||||
node test-channel-issue-1087.js
|
||||
node test-channel-issue-1101.js
|
||||
node test-observer-iata-1188.js
|
||||
node test-pull-to-reconnect-1091.js
|
||||
node test-channel-fluid-layout.js
|
||||
|
||||
@@ -228,6 +229,7 @@ jobs:
|
||||
BASE_URL=http://localhost:13581 node test-channel-issue-1087-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
BASE_URL=http://localhost:13581 node test-channel-issue-1111-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
BASE_URL=http://localhost:13581 node test-map-modal-fluid-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
BASE_URL=http://localhost:13581 node test-observer-iata-1188-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-nav-fluid-1055-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-nav-priority-1102-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-nav-more-floor-1139-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
|
||||
@@ -162,7 +162,7 @@ func createTestDBWithAgedPackets(t *testing.T, numRecent, numOld int) string {
|
||||
}
|
||||
execOrFail(`CREATE TABLE transmissions (id INTEGER PRIMARY KEY, raw_hex TEXT, hash TEXT, first_seen TEXT, route_type INTEGER, payload_type INTEGER, payload_version INTEGER, decoded_json TEXT)`)
|
||||
execOrFail(`CREATE TABLE observations (id INTEGER PRIMARY KEY, transmission_id INTEGER, observer_id TEXT, observer_name TEXT, direction TEXT, snr REAL, rssi REAL, score INTEGER, path_json TEXT, timestamp TEXT, raw_hex TEXT)`)
|
||||
execOrFail(`CREATE TABLE observers (rowid INTEGER PRIMARY KEY, id TEXT, name TEXT)`)
|
||||
execOrFail(`CREATE TABLE observers (rowid INTEGER PRIMARY KEY, id TEXT, name TEXT, iata TEXT)`)
|
||||
execOrFail(`CREATE TABLE nodes (pubkey TEXT PRIMARY KEY, name TEXT, role TEXT, lat REAL, lon REAL, last_seen TEXT, first_seen TEXT, frequency REAL)`)
|
||||
execOrFail(`CREATE TABLE schema_version (version INTEGER)`)
|
||||
execOrFail(`INSERT INTO schema_version (version) VALUES (1)`)
|
||||
@@ -321,7 +321,7 @@ func createTestDBAt(tb testing.TB, dbPath string, numTx int) {
|
||||
direction TEXT, snr REAL, rssi REAL, score INTEGER,
|
||||
path_json TEXT, timestamp TEXT, raw_hex TEXT
|
||||
)`)
|
||||
execOrFail(`CREATE TABLE IF NOT EXISTS observers (rowid INTEGER PRIMARY KEY, id TEXT, name TEXT)`)
|
||||
execOrFail(`CREATE TABLE IF NOT EXISTS observers (rowid INTEGER PRIMARY KEY, id TEXT, name TEXT, iata TEXT)`)
|
||||
execOrFail(`CREATE TABLE IF NOT EXISTS nodes (
|
||||
pubkey TEXT PRIMARY KEY, name TEXT, role TEXT, lat REAL, lon REAL,
|
||||
last_seen TEXT, first_seen TEXT, frequency REAL
|
||||
@@ -372,7 +372,7 @@ func createTestDBWithObs(tb testing.TB, dbPath string, numTx int) {
|
||||
id INTEGER PRIMARY KEY, transmission_id INTEGER, observer_id TEXT, observer_name TEXT,
|
||||
direction TEXT, snr REAL, rssi REAL, score INTEGER, path_json TEXT, timestamp TEXT, raw_hex TEXT
|
||||
)`)
|
||||
execOrFail(`CREATE TABLE IF NOT EXISTS observers (rowid INTEGER PRIMARY KEY, id TEXT, name TEXT)`)
|
||||
execOrFail(`CREATE TABLE IF NOT EXISTS observers (rowid INTEGER PRIMARY KEY, id TEXT, name TEXT, iata TEXT)`)
|
||||
execOrFail(`CREATE TABLE IF NOT EXISTS nodes (
|
||||
pubkey TEXT PRIMARY KEY, name TEXT, role TEXT, lat REAL, lon REAL,
|
||||
last_seen TEXT, first_seen TEXT, frequency REAL
|
||||
|
||||
+53
-16
@@ -7,6 +7,7 @@ import (
|
||||
"log"
|
||||
"math"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -89,7 +90,7 @@ func (db *DB) transmissionBaseSQL() (selectCols, observerJoin string) {
|
||||
if db.isV3 {
|
||||
selectCols = `t.id, t.raw_hex, t.hash, t.first_seen, t.route_type, t.payload_type, t.decoded_json,
|
||||
COALESCE((SELECT COUNT(*) FROM observations WHERE transmission_id = t.id), 0) AS observation_count,
|
||||
obs.id AS observer_id, obs.name AS observer_name,
|
||||
obs.id AS observer_id, obs.name AS observer_name, COALESCE(obs.iata, '') AS observer_iata,
|
||||
o.snr, o.rssi, o.path_json, o.direction`
|
||||
observerJoin = `LEFT JOIN observations o ON o.id = (
|
||||
SELECT id FROM observations WHERE transmission_id = t.id
|
||||
@@ -99,12 +100,13 @@ func (db *DB) transmissionBaseSQL() (selectCols, observerJoin string) {
|
||||
} else {
|
||||
selectCols = `t.id, t.raw_hex, t.hash, t.first_seen, t.route_type, t.payload_type, t.decoded_json,
|
||||
COALESCE((SELECT COUNT(*) FROM observations WHERE transmission_id = t.id), 0) AS observation_count,
|
||||
o.observer_id, o.observer_name,
|
||||
o.observer_id, o.observer_name, COALESCE(obs2.iata, '') AS observer_iata,
|
||||
o.snr, o.rssi, o.path_json, o.direction`
|
||||
observerJoin = `LEFT JOIN observations o ON o.id = (
|
||||
SELECT id FROM observations WHERE transmission_id = t.id
|
||||
ORDER BY length(COALESCE(path_json,'')) DESC LIMIT 1
|
||||
)`
|
||||
)
|
||||
LEFT JOIN observers obs2 ON obs2.id = o.observer_id`
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -113,12 +115,12 @@ func (db *DB) transmissionBaseSQL() (selectCols, observerJoin string) {
|
||||
// Returns a map matching the Node.js packet-store transmission shape.
|
||||
func (db *DB) scanTransmissionRow(rows *sql.Rows) map[string]interface{} {
|
||||
var id, observationCount int
|
||||
var rawHex, hash, firstSeen, decodedJSON, observerID, observerName, pathJSON, direction sql.NullString
|
||||
var rawHex, hash, firstSeen, decodedJSON, observerID, observerName, observerIATA, pathJSON, direction sql.NullString
|
||||
var routeType, payloadType sql.NullInt64
|
||||
var snr, rssi sql.NullFloat64
|
||||
|
||||
if err := rows.Scan(&id, &rawHex, &hash, &firstSeen, &routeType, &payloadType, &decodedJSON,
|
||||
&observationCount, &observerID, &observerName, &snr, &rssi, &pathJSON, &direction); err != nil {
|
||||
&observationCount, &observerID, &observerName, &observerIATA, &snr, &rssi, &pathJSON, &direction); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -134,6 +136,7 @@ func (db *DB) scanTransmissionRow(rows *sql.Rows) map[string]interface{} {
|
||||
"observation_count": observationCount,
|
||||
"observer_id": nullStr(observerID),
|
||||
"observer_name": nullStr(observerName),
|
||||
"observer_iata": nullStr(observerIATA),
|
||||
"snr": nullFloat(snr),
|
||||
"rssi": nullFloat(rssi),
|
||||
"path_json": nullStr(pathJSON),
|
||||
@@ -469,15 +472,20 @@ func (db *DB) QueryGroupedPackets(q PacketQuery) (*PacketResult, error) {
|
||||
db.conn.QueryRow(fmt.Sprintf("SELECT COUNT(*) FROM transmissions t %s", w), args...).Scan(&total)
|
||||
}
|
||||
|
||||
// Build grouped query using transmissions table with correlated subqueries
|
||||
// Build grouped query using transmissions table with correlated subqueries.
|
||||
// #1189 R2: distinct_iatas is a NEW column — comma-separated DISTINCT IATA
|
||||
// codes across all observers of the transmission, with empty/NULL IATAs
|
||||
// excluded. Frontend needs this on the DEFAULT COLLAPSED VIEW (where
|
||||
// p._children is empty), so we compute it server-side.
|
||||
var querySQL string
|
||||
if db.isV3 {
|
||||
querySQL = fmt.Sprintf(`SELECT t.hash, t.first_seen, t.raw_hex, t.decoded_json, t.payload_type, t.route_type,
|
||||
COALESCE((SELECT COUNT(*) FROM observations oi WHERE oi.transmission_id = t.id), 0) AS count,
|
||||
COALESCE((SELECT COUNT(DISTINCT oi.observer_idx) FROM observations oi WHERE oi.transmission_id = t.id), 0) AS observer_count,
|
||||
COALESCE((SELECT MAX(strftime('%%Y-%%m-%%dT%%H:%%M:%%fZ', oi.timestamp, 'unixepoch')) FROM observations oi WHERE oi.transmission_id = t.id), t.first_seen) AS latest,
|
||||
obs.id AS observer_id, obs.name AS observer_name,
|
||||
o.snr, o.rssi, o.path_json
|
||||
obs.id AS observer_id, obs.name AS observer_name, COALESCE(obs.iata, '') AS observer_iata,
|
||||
o.snr, o.rssi, o.path_json,
|
||||
COALESCE((SELECT GROUP_CONCAT(DISTINCT obi.iata) FROM observations oi JOIN observers obi ON obi.rowid = oi.observer_idx WHERE oi.transmission_id = t.id AND obi.iata IS NOT NULL AND obi.iata != ''), '') AS distinct_iatas
|
||||
FROM transmissions t
|
||||
LEFT JOIN observations o ON o.id = (
|
||||
SELECT id FROM observations WHERE transmission_id = t.id
|
||||
@@ -490,13 +498,15 @@ func (db *DB) QueryGroupedPackets(q PacketQuery) (*PacketResult, error) {
|
||||
COALESCE((SELECT COUNT(*) FROM observations oi WHERE oi.transmission_id = t.id), 0) AS count,
|
||||
COALESCE((SELECT COUNT(DISTINCT oi.observer_id) FROM observations oi WHERE oi.transmission_id = t.id), 0) AS observer_count,
|
||||
COALESCE((SELECT MAX(oi.timestamp) FROM observations oi WHERE oi.transmission_id = t.id), t.first_seen) AS latest,
|
||||
o.observer_id, o.observer_name,
|
||||
o.snr, o.rssi, o.path_json
|
||||
o.observer_id, o.observer_name, COALESCE(obs2.iata, '') AS observer_iata,
|
||||
o.snr, o.rssi, o.path_json,
|
||||
COALESCE((SELECT GROUP_CONCAT(DISTINCT obi.iata) FROM observations oi JOIN observers obi ON obi.id = oi.observer_id WHERE oi.transmission_id = t.id AND obi.iata IS NOT NULL AND obi.iata != ''), '') AS distinct_iatas
|
||||
FROM transmissions t
|
||||
LEFT JOIN observations o ON o.id = (
|
||||
SELECT id FROM observations WHERE transmission_id = t.id
|
||||
ORDER BY length(COALESCE(path_json,'')) DESC LIMIT 1
|
||||
)
|
||||
LEFT JOIN observers obs2 ON obs2.id = o.observer_id
|
||||
%s ORDER BY latest DESC LIMIT ? OFFSET ?`, w)
|
||||
}
|
||||
|
||||
@@ -512,14 +522,14 @@ func (db *DB) QueryGroupedPackets(q PacketQuery) (*PacketResult, error) {
|
||||
|
||||
packets := make([]map[string]interface{}, 0)
|
||||
for rows.Next() {
|
||||
var hash, firstSeen, rawHex, decodedJSON, latest, observerID, observerName, pathJSON sql.NullString
|
||||
var hash, firstSeen, rawHex, decodedJSON, latest, observerID, observerName, observerIATA, pathJSON, distinctIatasCSV sql.NullString
|
||||
var payloadType, routeType sql.NullInt64
|
||||
var count, observerCount int
|
||||
var snr, rssi sql.NullFloat64
|
||||
|
||||
if err := rows.Scan(&hash, &firstSeen, &rawHex, &decodedJSON, &payloadType, &routeType,
|
||||
&count, &observerCount, &latest,
|
||||
&observerID, &observerName, &snr, &rssi, &pathJSON); err != nil {
|
||||
&observerID, &observerName, &observerIATA, &snr, &rssi, &pathJSON, &distinctIatasCSV); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -532,6 +542,8 @@ func (db *DB) QueryGroupedPackets(q PacketQuery) (*PacketResult, error) {
|
||||
"latest": nullStr(latest),
|
||||
"observer_id": nullStr(observerID),
|
||||
"observer_name": nullStr(observerName),
|
||||
"observer_iata": nullStr(observerIATA),
|
||||
"distinct_iatas": parseDistinctIatasCSV(nullStr(distinctIatasCSV)),
|
||||
"path_json": nullStr(pathJSON),
|
||||
"payload_type": nullInt(payloadType),
|
||||
"route_type": nullInt(routeType),
|
||||
@@ -545,6 +557,29 @@ func (db *DB) QueryGroupedPackets(q PacketQuery) (*PacketResult, error) {
|
||||
return &PacketResult{Packets: packets, Total: total}, nil
|
||||
}
|
||||
|
||||
// parseDistinctIatasCSV turns SQLite GROUP_CONCAT output ("SJC,SFO,OAK") into
|
||||
// a sorted, deduped []string. Returns an empty (non-nil) slice when the input
|
||||
// is empty/nil so JSON serialization stays consistent (`[]` not `null`).
|
||||
func parseDistinctIatasCSV(v interface{}) []string {
|
||||
s, ok := v.(string)
|
||||
if !ok || s == "" {
|
||||
return []string{}
|
||||
}
|
||||
parts := strings.Split(s, ",")
|
||||
seen := make(map[string]bool, len(parts))
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
code := strings.TrimSpace(p)
|
||||
if code == "" || seen[code] {
|
||||
continue
|
||||
}
|
||||
seen[code] = true
|
||||
out = append(out, code)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func (db *DB) buildPacketWhere(q PacketQuery) ([]string, []interface{}) {
|
||||
var where []string
|
||||
var args []interface{}
|
||||
@@ -971,16 +1006,17 @@ func (db *DB) getObservationsForTransmissions(txIDs []int) map[int][]map[string]
|
||||
|
||||
var querySQL string
|
||||
if db.isV3 {
|
||||
querySQL = fmt.Sprintf(`SELECT o.transmission_id, o.id, obs.id AS observer_id, obs.name AS observer_name,
|
||||
querySQL = fmt.Sprintf(`SELECT o.transmission_id, o.id, obs.id AS observer_id, obs.name AS observer_name, COALESCE(obs.iata, '') AS observer_iata,
|
||||
o.direction, o.snr, o.rssi, o.path_json, strftime('%%Y-%%m-%%dT%%H:%%M:%%fZ', o.timestamp, 'unixepoch') AS obs_timestamp
|
||||
FROM observations o
|
||||
LEFT JOIN observers obs ON obs.rowid = o.observer_idx
|
||||
WHERE o.transmission_id IN (%s)
|
||||
ORDER BY o.timestamp DESC`, strings.Join(placeholders, ","))
|
||||
} else {
|
||||
querySQL = fmt.Sprintf(`SELECT o.transmission_id, o.id, o.observer_id, o.observer_name,
|
||||
querySQL = fmt.Sprintf(`SELECT o.transmission_id, o.id, o.observer_id, o.observer_name, COALESCE(obs.iata, '') AS observer_iata,
|
||||
o.direction, o.snr, o.rssi, o.path_json, o.timestamp AS obs_timestamp
|
||||
FROM observations o
|
||||
LEFT JOIN observers obs ON obs.id = o.observer_id
|
||||
WHERE o.transmission_id IN (%s)
|
||||
ORDER BY o.timestamp DESC`, strings.Join(placeholders, ","))
|
||||
}
|
||||
@@ -993,10 +1029,10 @@ func (db *DB) getObservationsForTransmissions(txIDs []int) map[int][]map[string]
|
||||
|
||||
for rows.Next() {
|
||||
var txID, obsID int
|
||||
var observerID, observerName, direction, pathJSON, obsTimestamp sql.NullString
|
||||
var observerID, observerName, observerIATA, direction, pathJSON, obsTimestamp sql.NullString
|
||||
var snr, rssi sql.NullFloat64
|
||||
|
||||
if err := rows.Scan(&txID, &obsID, &observerID, &observerName, &direction,
|
||||
if err := rows.Scan(&txID, &obsID, &observerID, &observerName, &observerIATA, &direction,
|
||||
&snr, &rssi, &pathJSON, &obsTimestamp); err != nil {
|
||||
continue
|
||||
}
|
||||
@@ -1011,6 +1047,7 @@ func (db *DB) getObservationsForTransmissions(txIDs []int) map[int][]map[string]
|
||||
"transmission_id": txID,
|
||||
"observer_id": nullStr(observerID),
|
||||
"observer_name": nullStr(observerName),
|
||||
"observer_iata": nullStr(observerIATA),
|
||||
"snr": nullFloat(snr),
|
||||
"rssi": nullFloat(rssi),
|
||||
"path_json": nullStr(pathJSON),
|
||||
|
||||
@@ -37,7 +37,7 @@ func createTestDBMultiDay(t *testing.T, numDays, txPerDay int) string {
|
||||
}
|
||||
execOrFail(`CREATE TABLE transmissions (id INTEGER PRIMARY KEY, raw_hex TEXT, hash TEXT, first_seen TEXT, route_type INTEGER, payload_type INTEGER, payload_version INTEGER, decoded_json TEXT)`)
|
||||
execOrFail(`CREATE TABLE observations (id INTEGER PRIMARY KEY, transmission_id INTEGER, observer_id TEXT, observer_name TEXT, direction TEXT, snr REAL, rssi REAL, score INTEGER, path_json TEXT, timestamp TEXT, raw_hex TEXT)`)
|
||||
execOrFail(`CREATE TABLE observers (rowid INTEGER PRIMARY KEY, id TEXT, name TEXT)`)
|
||||
execOrFail(`CREATE TABLE observers (rowid INTEGER PRIMARY KEY, id TEXT, name TEXT, iata TEXT)`)
|
||||
execOrFail(`CREATE TABLE nodes (pubkey TEXT PRIMARY KEY, name TEXT, role TEXT, lat REAL, lon REAL, last_seen TEXT, first_seen TEXT, frequency REAL)`)
|
||||
execOrFail(`CREATE TABLE schema_version (version INTEGER)`)
|
||||
execOrFail(`INSERT INTO schema_version (version) VALUES (1)`)
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestQueryGroupedPacketsReturnsDistinctIATAs (#1189 R2):
|
||||
// The default collapsed grouped view must already expose the DISTINCT set
|
||||
// of observer IATA codes for each transmission — frontend can't compute it
|
||||
// because p._children is empty until the user expands the row (or applies a
|
||||
// non-default sort). Previously the cell showed a single IATA + "+N" of
|
||||
// observer count, which conflates SAME-region redundancy with CROSS-region
|
||||
// reception. R1 added a frontend helper but it only fired on the expanded
|
||||
// view; this test gates the server-side fix.
|
||||
//
|
||||
// Seeds one transmission with observations from two IATAs (SJC, SFO) and
|
||||
// asserts the grouped row carries distinct_iatas containing both codes.
|
||||
func TestQueryGroupedPacketsReturnsDistinctIATAs(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
now := time.Now().UTC()
|
||||
recentEpoch := now.Add(-1 * time.Hour).Unix()
|
||||
|
||||
// Observers: SJC + SFO + a third with no IATA (should be excluded).
|
||||
db.conn.Exec(`INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count)
|
||||
VALUES ('obsA', 'A', 'SJC', ?, '2026-01-01T00:00:00Z', 10)`, now.Format(time.RFC3339))
|
||||
db.conn.Exec(`INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count)
|
||||
VALUES ('obsB', 'B', 'SFO', ?, '2026-01-01T00:00:00Z', 10)`, now.Format(time.RFC3339))
|
||||
db.conn.Exec(`INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count)
|
||||
VALUES ('obsC', 'C', '', ?, '2026-01-01T00:00:00Z', 10)`, now.Format(time.RFC3339))
|
||||
|
||||
// One transmission with 3 observations (SJC, SFO, no-IATA).
|
||||
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json)
|
||||
VALUES ('AABB', 'deadbeefcafef00d', ?, 1, 4, '{}')`, now.Format(time.RFC3339))
|
||||
// v3 schema: observer_idx = observers.rowid (auto-assigned 1,2,3 in insert order).
|
||||
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
|
||||
VALUES (1, 1, 12.0, -80, '["aa"]', ?)`, recentEpoch)
|
||||
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
|
||||
VALUES (1, 2, 8.0, -90, '["aa"]', ?)`, recentEpoch-30)
|
||||
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
|
||||
VALUES (1, 3, 5.0, -95, '["aa"]', ?)`, recentEpoch-60)
|
||||
|
||||
result, err := db.QueryGroupedPackets(PacketQuery{Limit: 50})
|
||||
if err != nil {
|
||||
t.Fatalf("QueryGroupedPackets: %v", err)
|
||||
}
|
||||
if result.Total != 1 {
|
||||
t.Fatalf("expected 1 grouped tx, got %d", result.Total)
|
||||
}
|
||||
|
||||
row := result.Packets[0]
|
||||
raw, ok := row["distinct_iatas"]
|
||||
if !ok {
|
||||
t.Fatalf("expected distinct_iatas key in grouped row, got: %#v", row)
|
||||
}
|
||||
iatas, ok := raw.([]string)
|
||||
if !ok {
|
||||
t.Fatalf("expected distinct_iatas to be []string, got %T (%v)", raw, raw)
|
||||
}
|
||||
sort.Strings(iatas)
|
||||
want := []string{"SFO", "SJC"}
|
||||
if strings.Join(iatas, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("distinct_iatas = %v, want %v (must exclude empty-IATA observers, dedupe)", iatas, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQueryGroupedPacketsDistinctIATAsEmptyWhenNoIATA (#1189 R2):
|
||||
// Group whose observers all have no IATA → distinct_iatas should be empty
|
||||
// (or absent / empty slice) — must NOT carry stale data from another group.
|
||||
func TestQueryGroupedPacketsDistinctIATAsEmptyWhenNoIATA(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
now := time.Now().UTC()
|
||||
recentEpoch := now.Add(-1 * time.Hour).Unix()
|
||||
|
||||
db.conn.Exec(`INSERT INTO observers (id, name, iata, last_seen, first_seen, packet_count)
|
||||
VALUES ('obsX', 'X', '', ?, '2026-01-01T00:00:00Z', 1)`, now.Format(time.RFC3339))
|
||||
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json)
|
||||
VALUES ('AA', '1111222233334444', ?, 1, 4, '{}')`, now.Format(time.RFC3339))
|
||||
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
|
||||
VALUES (1, 1, 10.0, -85, '[]', ?)`, recentEpoch)
|
||||
|
||||
result, err := db.QueryGroupedPackets(PacketQuery{Limit: 50})
|
||||
if err != nil {
|
||||
t.Fatalf("QueryGroupedPackets: %v", err)
|
||||
}
|
||||
if result.Total != 1 {
|
||||
t.Fatalf("expected 1 grouped tx, got %d", result.Total)
|
||||
}
|
||||
row := result.Packets[0]
|
||||
raw, ok := row["distinct_iatas"]
|
||||
if !ok {
|
||||
// absent key acceptable — treat as empty
|
||||
return
|
||||
}
|
||||
iatas, ok := raw.([]string)
|
||||
if !ok {
|
||||
t.Fatalf("distinct_iatas should be []string, got %T", raw)
|
||||
}
|
||||
if len(iatas) != 0 {
|
||||
t.Fatalf("distinct_iatas should be empty for no-IATA group, got %v", iatas)
|
||||
}
|
||||
}
|
||||
@@ -216,6 +216,15 @@ func main() {
|
||||
log.Printf("[store] warning: could not add observers.last_packet_at column: %v", err)
|
||||
}
|
||||
|
||||
// Ensure observers.iata column exists (#1188 read paths COALESCE(obs.iata, '')
|
||||
// in Store.Load() / IngestNewFromDB / IngestNewObservations; ingestor migration
|
||||
// adds it but server may run against DBs ingestor never touched (e2e fixture)
|
||||
// OR pre-iata operator DBs upgraded to this build — without this migration
|
||||
// the first SELECT crashes with "no such column: obs.iata" (#1189 R1).
|
||||
if err := ensureObserverIATAColumn(dbPath); err != nil {
|
||||
log.Printf("[store] warning: could not add observers.iata column: %v", err)
|
||||
}
|
||||
|
||||
// Ensure nodes.foreign_advert column exists (#730 reads it on every /api/nodes
|
||||
// scan; ingestor migration foreign_advert_v1 adds it but server may run against
|
||||
// DBs ingestor never touched, e.g. e2e fixture).
|
||||
|
||||
@@ -476,10 +476,10 @@ func TestBuildNodeInfoMap_ObserverEnrichment(t *testing.T) {
|
||||
// Create tables
|
||||
for _, stmt := range []string{
|
||||
"CREATE TABLE nodes (public_key TEXT, name TEXT, role TEXT, lat REAL, lon REAL)",
|
||||
"CREATE TABLE observers (id TEXT, name TEXT)",
|
||||
"CREATE TABLE observers (id TEXT, name TEXT, iata TEXT)",
|
||||
"INSERT INTO nodes VALUES ('AAAA1111', 'Repeater-1', 'repeater', 0, 0)",
|
||||
"INSERT INTO observers VALUES ('BBBB2222', 'Observer-Alpha')",
|
||||
"INSERT INTO observers VALUES ('AAAA1111', 'Obs-also-repeater')",
|
||||
"INSERT INTO observers VALUES ('BBBB2222', 'Observer-Alpha', '')",
|
||||
"INSERT INTO observers VALUES ('AAAA1111', 'Obs-also-repeater', '')",
|
||||
} {
|
||||
if _, err := conn.Exec(stmt); err != nil {
|
||||
t.Fatalf("exec %q: %v", stmt, err)
|
||||
|
||||
@@ -353,6 +353,44 @@ func ensureLastPacketAtColumn(dbPath string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureObserverIATAColumn adds the iata column to observers if missing.
|
||||
// The column was originally added by ingestor migration (cmd/ingestor/db.go) to
|
||||
// label each observer with a 3-letter regional IATA code. When the server starts
|
||||
// against a DB that was never touched by the ingestor (e.g. the e2e fixture,
|
||||
// or a pre-iata operator DB upgraded to this build), every SELECT that joins
|
||||
// COALESCE(obs.iata, '') panics with "no such column: obs.iata" — crashing
|
||||
// Store.Load() / IngestNewFromDB / IngestNewObservations on startup (#1189 R1).
|
||||
func ensureObserverIATAColumn(dbPath string) error {
|
||||
rw, err := cachedRW(dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rows, err := rw.Query("PRAGMA table_info(observers)")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var colName string
|
||||
var colType sql.NullString
|
||||
var notNull, pk int
|
||||
var dflt sql.NullString
|
||||
if rows.Scan(&cid, &colName, &colType, ¬Null, &dflt, &pk) == nil && colName == "iata" {
|
||||
return nil // already exists
|
||||
}
|
||||
}
|
||||
|
||||
_, err = rw.Exec("ALTER TABLE observers ADD COLUMN iata TEXT")
|
||||
if err != nil {
|
||||
return fmt.Errorf("add iata column: %w", err)
|
||||
}
|
||||
log.Println("[store] Added iata column to observers")
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureForeignAdvertColumn adds the foreign_advert column to nodes/inactive_nodes
|
||||
// if missing (#730). The column is added by the ingestor migration foreign_advert_v1
|
||||
// — but the server may run against a DB the ingestor has never touched (e2e fixture,
|
||||
|
||||
@@ -597,3 +597,91 @@ func TestEnsureLastPacketAtColumn(t *testing.T) {
|
||||
t.Fatalf("idempotent call failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnsureObserverIATAColumn validates the #1189 R1 fix: an operator with
|
||||
// a pre-iata observers schema (no `iata TEXT` column) must not panic on
|
||||
// startup. The migration must idempotently ALTER TABLE ADD COLUMN, and
|
||||
// queries that COALESCE(obs.iata, '') must succeed after the migration runs.
|
||||
func TestEnsureObserverIATAColumn(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := dir + "/test.db"
|
||||
|
||||
// Pre-iata schema (matches what shipped before #1188 landed).
|
||||
db, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = db.Exec(`CREATE TABLE observers (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT,
|
||||
last_seen TEXT,
|
||||
first_seen TEXT,
|
||||
packet_count INTEGER DEFAULT 0,
|
||||
inactive INTEGER DEFAULT 0,
|
||||
last_packet_at TEXT DEFAULT NULL
|
||||
)`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO observers (id, name) VALUES ('obs1', 'Observer One')`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db.Close()
|
||||
|
||||
// Prove the bug exists pre-migration: a SELECT that COALESCEs obs.iata must fail.
|
||||
db0, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var probe string
|
||||
preErr := db0.QueryRow(`SELECT COALESCE(iata, '') FROM observers WHERE id='obs1'`).Scan(&probe)
|
||||
db0.Close()
|
||||
if preErr == nil {
|
||||
t.Fatal("expected SELECT on missing iata column to fail BEFORE migration; got success")
|
||||
}
|
||||
|
||||
// First call: should add the column.
|
||||
if err := ensureObserverIATAColumn(dbPath); err != nil {
|
||||
t.Fatalf("first call failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify column exists.
|
||||
db2, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db2.Close()
|
||||
|
||||
var found bool
|
||||
rows, err := db2.Query("PRAGMA table_info(observers)")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var colName string
|
||||
var colType sql.NullString
|
||||
var notNull, pk int
|
||||
var dflt sql.NullString
|
||||
if rows.Scan(&cid, &colName, &colType, ¬Null, &dflt, &pk) == nil && colName == "iata" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
if !found {
|
||||
t.Fatal("iata column not found after migration")
|
||||
}
|
||||
|
||||
// The query that previously panicked must now succeed (empty string default).
|
||||
if err := db2.QueryRow(`SELECT COALESCE(iata, '') FROM observers WHERE id='obs1'`).Scan(&probe); err != nil {
|
||||
t.Fatalf("post-migration SELECT failed: %v", err)
|
||||
}
|
||||
if probe != "" {
|
||||
t.Fatalf("expected empty iata for legacy row, got %q", probe)
|
||||
}
|
||||
|
||||
// Idempotency: second call must succeed.
|
||||
if err := ensureObserverIATAColumn(dbPath); err != nil {
|
||||
t.Fatalf("idempotent call failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
// Test (#1188): /api/packets response must include observer_iata per packet
|
||||
// so the frontend can render the IATA inline without per-row observer lookups.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestPacketsEndpointIncludesObserverIATA asserts the ungrouped packets endpoint
|
||||
// surfaces the joined observer's IATA on each packet row.
|
||||
func TestPacketsEndpointIncludesObserverIATA(t *testing.T) {
|
||||
_, router := setupTestServer(t)
|
||||
req := httptest.NewRequest("GET", "/api/packets?limit=10", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("invalid JSON: %v", err)
|
||||
}
|
||||
packets, ok := body["packets"].([]interface{})
|
||||
if !ok || len(packets) == 0 {
|
||||
t.Fatal("expected non-empty packets array")
|
||||
}
|
||||
|
||||
// Seeded observers: obs1 → SJC, obs2 → SFO. At least one packet row
|
||||
// must carry a non-empty observer_iata string.
|
||||
gotIATA := false
|
||||
for _, p := range packets {
|
||||
m, _ := p.(map[string]interface{})
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
if _, present := m["observer_iata"]; !present {
|
||||
t.Fatalf("packet missing observer_iata field; got keys: %v", keysOfMap(m))
|
||||
}
|
||||
if s, _ := m["observer_iata"].(string); s != "" {
|
||||
gotIATA = true
|
||||
}
|
||||
}
|
||||
if !gotIATA {
|
||||
t.Fatalf("expected at least one packet with non-empty observer_iata (seed has SJC/SFO)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPacketsGroupedIncludesObserverIATA asserts the grouped (groupByHash)
|
||||
// view also surfaces observer_iata for the header row.
|
||||
func TestPacketsGroupedIncludesObserverIATA(t *testing.T) {
|
||||
_, router := setupTestServer(t)
|
||||
req := httptest.NewRequest("GET", "/api/packets?groupByHash=true&limit=10", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
var body map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &body)
|
||||
packets, _ := body["packets"].([]interface{})
|
||||
if len(packets) == 0 {
|
||||
t.Fatal("expected non-empty grouped packets")
|
||||
}
|
||||
gotIATA := false
|
||||
for _, p := range packets {
|
||||
m, _ := p.(map[string]interface{})
|
||||
if _, present := m["observer_iata"]; !present {
|
||||
t.Fatalf("grouped packet missing observer_iata field; got keys: %v", keysOfMap(m))
|
||||
}
|
||||
if s, _ := m["observer_iata"].(string); s != "" {
|
||||
gotIATA = true
|
||||
}
|
||||
}
|
||||
if !gotIATA {
|
||||
t.Fatalf("expected at least one grouped packet with non-empty observer_iata")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPacketDetailObservationsIncludeIATA asserts /api/packets/{id} returns
|
||||
// per-observation observer_iata so the detail pane can render it.
|
||||
func TestPacketDetailObservationsIncludeIATA(t *testing.T) {
|
||||
_, router := setupTestServer(t)
|
||||
// transmission_id 1 has two observations (obs1 SJC, obs2 SFO) from seedTestData
|
||||
req := httptest.NewRequest("GET", "/api/packets/1", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var body map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &body)
|
||||
obs, _ := body["observations"].([]interface{})
|
||||
if len(obs) == 0 {
|
||||
t.Fatalf("expected observations in detail response; body: %s", w.Body.String())
|
||||
}
|
||||
gotIATA := false
|
||||
for _, o := range obs {
|
||||
m, _ := o.(map[string]interface{})
|
||||
if _, present := m["observer_iata"]; !present {
|
||||
t.Fatalf("observation missing observer_iata field; got keys: %v", keysOfMap(m))
|
||||
}
|
||||
if s, _ := m["observer_iata"].(string); s != "" {
|
||||
gotIATA = true
|
||||
}
|
||||
}
|
||||
if !gotIATA {
|
||||
t.Fatalf("expected at least one observation with non-empty observer_iata")
|
||||
}
|
||||
}
|
||||
|
||||
func keysOfMap(m map[string]interface{}) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -2490,6 +2490,7 @@ func mapSliceToTransmissions(maps []map[string]interface{}) []TransmissionResp {
|
||||
}
|
||||
tx.ObserverID = m["observer_id"]
|
||||
tx.ObserverName = m["observer_name"]
|
||||
tx.ObserverIATA = m["observer_iata"]
|
||||
tx.SNR = m["snr"]
|
||||
tx.RSSI = m["rssi"]
|
||||
tx.PathJSON = m["path_json"]
|
||||
@@ -2512,6 +2513,7 @@ func mapSliceToObservations(maps []map[string]interface{}) []ObservationResp {
|
||||
obs.Hash = m["hash"]
|
||||
obs.ObserverID = m["observer_id"]
|
||||
obs.ObserverName = m["observer_name"]
|
||||
obs.ObserverIATA = m["observer_iata"]
|
||||
obs.SNR = m["snr"]
|
||||
obs.RSSI = m["rssi"]
|
||||
obs.PathJSON = m["path_json"]
|
||||
|
||||
+71
-21
@@ -37,6 +37,7 @@ type StoreTx struct {
|
||||
// Display fields from longest-path observation
|
||||
ObserverID string
|
||||
ObserverName string
|
||||
ObserverIATA string
|
||||
SNR *float64
|
||||
RSSI *float64
|
||||
PathJSON string
|
||||
@@ -59,6 +60,7 @@ type StoreObs struct {
|
||||
TransmissionID int
|
||||
ObserverID string
|
||||
ObserverName string
|
||||
ObserverIATA string
|
||||
Direction string
|
||||
SNR *float64
|
||||
RSSI *float64
|
||||
@@ -555,7 +557,7 @@ func (s *PacketStore) Load() error {
|
||||
if s.db.isV3 {
|
||||
loadSQL = `SELECT t.id, t.raw_hex, t.hash, t.first_seen, t.route_type,
|
||||
t.payload_type, t.payload_version, t.decoded_json,
|
||||
o.id, obs.id, obs.name, o.direction,
|
||||
o.id, obs.id, obs.name, COALESCE(obs.iata, ''), o.direction,
|
||||
o.snr, o.rssi, o.score, o.path_json, strftime('%Y-%m-%dT%H:%M:%fZ', o.timestamp, 'unixepoch')` + obsRawHexCol + rpCol + `
|
||||
FROM transmissions t
|
||||
LEFT JOIN observations o ON o.transmission_id = t.id
|
||||
@@ -564,10 +566,11 @@ func (s *PacketStore) Load() error {
|
||||
} else {
|
||||
loadSQL = `SELECT t.id, t.raw_hex, t.hash, t.first_seen, t.route_type,
|
||||
t.payload_type, t.payload_version, t.decoded_json,
|
||||
o.id, o.observer_id, o.observer_name, o.direction,
|
||||
o.id, o.observer_id, o.observer_name, COALESCE(obs.iata, ''), o.direction,
|
||||
o.snr, o.rssi, o.score, o.path_json, o.timestamp` + obsRawHexCol + rpCol + `
|
||||
FROM transmissions t
|
||||
LEFT JOIN observations o ON o.transmission_id = t.id` + filterClause + `
|
||||
LEFT JOIN observations o ON o.transmission_id = t.id
|
||||
LEFT JOIN observers obs ON obs.id = o.observer_id` + filterClause + `
|
||||
ORDER BY t.first_seen ASC, o.timestamp DESC`
|
||||
}
|
||||
|
||||
@@ -584,7 +587,7 @@ func (s *PacketStore) Load() error {
|
||||
var rawHex, hash, firstSeen, decodedJSON sql.NullString
|
||||
var routeType, payloadType, payloadVersion sql.NullInt64
|
||||
var obsID sql.NullInt64
|
||||
var observerID, observerName, direction, pathJSON, obsTimestamp sql.NullString
|
||||
var observerID, observerName, observerIATA, direction, pathJSON, obsTimestamp sql.NullString
|
||||
var snr, rssi sql.NullFloat64
|
||||
var score sql.NullInt64
|
||||
var obsRawHex sql.NullString
|
||||
@@ -592,7 +595,7 @@ func (s *PacketStore) Load() error {
|
||||
|
||||
scanArgs := []interface{}{&txID, &rawHex, &hash, &firstSeen, &routeType, &payloadType,
|
||||
&payloadVersion, &decodedJSON,
|
||||
&obsID, &observerID, &observerName, &direction,
|
||||
&obsID, &observerID, &observerName, &observerIATA, &direction,
|
||||
&snr, &rssi, &score, &pathJSON, &obsTimestamp}
|
||||
if s.db.hasObsRawHex {
|
||||
scanArgs = append(scanArgs, &obsRawHex)
|
||||
@@ -651,6 +654,7 @@ func (s *PacketStore) Load() error {
|
||||
TransmissionID: txID,
|
||||
ObserverID: obsIDStr,
|
||||
ObserverName: nullStrVal(observerName),
|
||||
ObserverIATA: nullStrVal(observerIATA),
|
||||
Direction: nullStrVal(direction),
|
||||
SNR: nullFloatPtr(snr),
|
||||
RSSI: nullFloatPtr(rssi),
|
||||
@@ -1142,6 +1146,7 @@ func pickBestObservation(tx *StoreTx) {
|
||||
}
|
||||
tx.ObserverID = best.ObserverID
|
||||
tx.ObserverName = best.ObserverName
|
||||
tx.ObserverIATA = best.ObserverIATA
|
||||
tx.SNR = best.SNR
|
||||
tx.RSSI = best.RSSI
|
||||
tx.PathJSON = best.PathJSON
|
||||
@@ -1428,6 +1433,11 @@ func groupedTxsToPage(txs []*StoreTx, total, offset, limit int) *PacketResult {
|
||||
|
||||
packets := make([]map[string]interface{}, len(page))
|
||||
for i, tx := range page {
|
||||
// #1189 R2: compute distinct IATA set across all observations.
|
||||
// Frontend uses this in the default collapsed view to show CROSS-region
|
||||
// reception at a glance — see groupedObserverIataBadgesHtml in
|
||||
// public/packets.js.
|
||||
distinctIatas := storeTxDistinctIatas(tx)
|
||||
m := map[string]interface{}{
|
||||
"hash": strOrNil(tx.Hash),
|
||||
"first_seen": strOrNil(tx.FirstSeen),
|
||||
@@ -1437,6 +1447,8 @@ func groupedTxsToPage(txs []*StoreTx, total, offset, limit int) *PacketResult {
|
||||
"latest": strOrNil(tx.LatestSeen),
|
||||
"observer_id": strOrNil(tx.ObserverID),
|
||||
"observer_name": strOrNil(tx.ObserverName),
|
||||
"observer_iata": strOrNil(tx.ObserverIATA),
|
||||
"distinct_iatas": distinctIatas,
|
||||
"path_json": strOrNil(tx.PathJSON),
|
||||
"payload_type": intPtrOrNil(tx.PayloadType),
|
||||
"route_type": intPtrOrNil(tx.RouteType),
|
||||
@@ -1452,6 +1464,34 @@ func groupedTxsToPage(txs []*StoreTx, total, offset, limit int) *PacketResult {
|
||||
return &PacketResult{Packets: packets, Total: total}
|
||||
}
|
||||
|
||||
// storeTxDistinctIatas (#1189 R2) returns a sorted, deduped list of observer
|
||||
// IATA codes for a StoreTx, excluding empty values. Returns an empty
|
||||
// (non-nil) []string when the tx has no IATA'd observations so JSON
|
||||
// serialization stays consistent across the in-memory store and SQL
|
||||
// fallback paths (db.go's parseDistinctIatasCSV does the same).
|
||||
func storeTxDistinctIatas(tx *StoreTx) []string {
|
||||
if tx == nil {
|
||||
return []string{}
|
||||
}
|
||||
seen := make(map[string]bool)
|
||||
// Include the header observer's IATA (some hot-path StoreTx records the
|
||||
// chosen observer fields directly without re-populating Observations).
|
||||
if tx.ObserverIATA != "" {
|
||||
seen[tx.ObserverIATA] = true
|
||||
}
|
||||
for _, o := range tx.Observations {
|
||||
if o != nil && o.ObserverIATA != "" {
|
||||
seen[o.ObserverIATA] = true
|
||||
}
|
||||
}
|
||||
out := make([]string, 0, len(seen))
|
||||
for k := range seen {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// GetStoreStats returns aggregate counts (packet data from memory, node/observer from DB).
|
||||
func (s *PacketStore) GetStoreStats() (*Stats, error) {
|
||||
s.mu.RLock()
|
||||
@@ -1906,7 +1946,7 @@ func (s *PacketStore) IngestNewFromDB(sinceID, limit int) ([]map[string]interfac
|
||||
if s.db.isV3 {
|
||||
querySQL = `SELECT t.id, t.raw_hex, t.hash, t.first_seen, t.route_type,
|
||||
t.payload_type, t.payload_version, t.decoded_json,
|
||||
o.id, obs.id, obs.name, o.direction,
|
||||
o.id, obs.id, obs.name, COALESCE(obs.iata, ''), o.direction,
|
||||
o.snr, o.rssi, o.score, o.path_json, strftime('%Y-%m-%dT%H:%M:%fZ', o.timestamp, 'unixepoch')` + obsRHCol + `
|
||||
FROM transmissions t
|
||||
LEFT JOIN observations o ON o.transmission_id = t.id
|
||||
@@ -1916,10 +1956,11 @@ func (s *PacketStore) IngestNewFromDB(sinceID, limit int) ([]map[string]interfac
|
||||
} else {
|
||||
querySQL = `SELECT t.id, t.raw_hex, t.hash, t.first_seen, t.route_type,
|
||||
t.payload_type, t.payload_version, t.decoded_json,
|
||||
o.id, o.observer_id, o.observer_name, o.direction,
|
||||
o.id, o.observer_id, o.observer_name, COALESCE(obs.iata, ''), o.direction,
|
||||
o.snr, o.rssi, o.score, o.path_json, o.timestamp` + obsRHCol + `
|
||||
FROM transmissions t
|
||||
LEFT JOIN observations o ON o.transmission_id = t.id
|
||||
LEFT JOIN observers obs ON obs.id = o.observer_id
|
||||
WHERE t.id > ?
|
||||
ORDER BY t.id ASC, o.timestamp DESC`
|
||||
}
|
||||
@@ -1933,14 +1974,14 @@ func (s *PacketStore) IngestNewFromDB(sinceID, limit int) ([]map[string]interfac
|
||||
|
||||
// Scan into temp structures
|
||||
type tempRow struct {
|
||||
txID int
|
||||
rawHex, hash, firstSeen, decodedJSON string
|
||||
routeType, payloadType *int
|
||||
obsID *int
|
||||
observerID, observerName, direction, pathJSON, obsTS string
|
||||
obsRawHex string
|
||||
snr, rssi *float64
|
||||
score *int
|
||||
txID int
|
||||
rawHex, hash, firstSeen, decodedJSON string
|
||||
routeType, payloadType *int
|
||||
obsID *int
|
||||
observerID, observerName, observerIATA, direction, pathJSON, obsTS string
|
||||
obsRawHex string
|
||||
snr, rssi *float64
|
||||
score *int
|
||||
}
|
||||
|
||||
var tempRows []tempRow
|
||||
@@ -1952,14 +1993,14 @@ func (s *PacketStore) IngestNewFromDB(sinceID, limit int) ([]map[string]interfac
|
||||
var rawHex, hash, firstSeen, decodedJSON sql.NullString
|
||||
var routeType, payloadType, payloadVersion sql.NullInt64
|
||||
var obsIDVal sql.NullInt64
|
||||
var observerID, observerName, direction, pathJSON, obsTimestamp sql.NullString
|
||||
var observerID, observerName, observerIATA, direction, pathJSON, obsTimestamp sql.NullString
|
||||
var snrVal, rssiVal sql.NullFloat64
|
||||
var scoreVal sql.NullInt64
|
||||
var obsRawHex sql.NullString
|
||||
|
||||
scanArgs2 := []interface{}{&txID, &rawHex, &hash, &firstSeen, &routeType, &payloadType,
|
||||
&payloadVersion, &decodedJSON,
|
||||
&obsIDVal, &observerID, &observerName, &direction,
|
||||
&obsIDVal, &observerID, &observerName, &observerIATA, &direction,
|
||||
&snrVal, &rssiVal, &scoreVal, &pathJSON, &obsTimestamp}
|
||||
if s.db.hasObsRawHex {
|
||||
scanArgs2 = append(scanArgs2, &obsRawHex)
|
||||
@@ -1986,6 +2027,7 @@ func (s *PacketStore) IngestNewFromDB(sinceID, limit int) ([]map[string]interfac
|
||||
payloadType: nullIntPtr(payloadType),
|
||||
observerID: nullStrVal(observerID),
|
||||
observerName: nullStrVal(observerName),
|
||||
observerIATA: nullStrVal(observerIATA),
|
||||
direction: nullStrVal(direction),
|
||||
pathJSON: nullStrVal(pathJSON),
|
||||
obsTS: nullStrVal(obsTimestamp),
|
||||
@@ -2088,6 +2130,7 @@ func (s *PacketStore) IngestNewFromDB(sinceID, limit int) ([]map[string]interfac
|
||||
TransmissionID: r.txID,
|
||||
ObserverID: r.observerID,
|
||||
ObserverName: r.observerName,
|
||||
ObserverIATA: r.observerIATA,
|
||||
Direction: r.direction,
|
||||
SNR: r.snr,
|
||||
RSSI: r.rssi,
|
||||
@@ -2341,7 +2384,7 @@ func (s *PacketStore) IngestNewObservations(sinceObsID, limit int) []map[string]
|
||||
obsRHCol2 = ", o.raw_hex"
|
||||
}
|
||||
if s.db.isV3 {
|
||||
querySQL = `SELECT o.id, o.transmission_id, obs.id, obs.name, o.direction,
|
||||
querySQL = `SELECT o.id, o.transmission_id, obs.id, obs.name, COALESCE(obs.iata, ''), o.direction,
|
||||
o.snr, o.rssi, o.score, o.path_json, strftime('%Y-%m-%dT%H:%M:%fZ', o.timestamp, 'unixepoch')` + obsRHCol2 + `
|
||||
FROM observations o
|
||||
LEFT JOIN observers obs ON obs.rowid = o.observer_idx
|
||||
@@ -2349,9 +2392,10 @@ func (s *PacketStore) IngestNewObservations(sinceObsID, limit int) []map[string]
|
||||
ORDER BY o.id ASC
|
||||
LIMIT ?`
|
||||
} else {
|
||||
querySQL = `SELECT o.id, o.transmission_id, o.observer_id, o.observer_name, o.direction,
|
||||
querySQL = `SELECT o.id, o.transmission_id, o.observer_id, o.observer_name, COALESCE(obs.iata, ''), o.direction,
|
||||
o.snr, o.rssi, o.score, o.path_json, o.timestamp` + obsRHCol2 + `
|
||||
FROM observations o
|
||||
LEFT JOIN observers obs ON obs.id = o.observer_id
|
||||
WHERE o.id > ?
|
||||
ORDER BY o.id ASC
|
||||
LIMIT ?`
|
||||
@@ -2369,6 +2413,7 @@ func (s *PacketStore) IngestNewObservations(sinceObsID, limit int) []map[string]
|
||||
txID int
|
||||
observerID string
|
||||
observerName string
|
||||
observerIATA string
|
||||
direction string
|
||||
snr, rssi *float64
|
||||
score *int
|
||||
@@ -2380,12 +2425,12 @@ func (s *PacketStore) IngestNewObservations(sinceObsID, limit int) []map[string]
|
||||
var obsRows []obsRow
|
||||
for rows.Next() {
|
||||
var oid, txID int
|
||||
var observerID, observerName, direction, pathJSON, ts sql.NullString
|
||||
var observerID, observerName, observerIATA, direction, pathJSON, ts sql.NullString
|
||||
var snr, rssi sql.NullFloat64
|
||||
var score sql.NullInt64
|
||||
var obsRawHex sql.NullString
|
||||
|
||||
scanArgs3 := []interface{}{&oid, &txID, &observerID, &observerName, &direction,
|
||||
scanArgs3 := []interface{}{&oid, &txID, &observerID, &observerName, &observerIATA, &direction,
|
||||
&snr, &rssi, &score, &pathJSON, &ts}
|
||||
if s.db.hasObsRawHex {
|
||||
scanArgs3 = append(scanArgs3, &obsRawHex)
|
||||
@@ -2399,6 +2444,7 @@ func (s *PacketStore) IngestNewObservations(sinceObsID, limit int) []map[string]
|
||||
txID: txID,
|
||||
observerID: nullStrVal(observerID),
|
||||
observerName: nullStrVal(observerName),
|
||||
observerIATA: nullStrVal(observerIATA),
|
||||
direction: nullStrVal(direction),
|
||||
snr: nullFloatPtr(snr),
|
||||
rssi: nullFloatPtr(rssi),
|
||||
@@ -2455,6 +2501,7 @@ func (s *PacketStore) IngestNewObservations(sinceObsID, limit int) []map[string]
|
||||
TransmissionID: r.txID,
|
||||
ObserverID: r.observerID,
|
||||
ObserverName: r.observerName,
|
||||
ObserverIATA: r.observerIATA,
|
||||
Direction: r.direction,
|
||||
SNR: r.snr,
|
||||
RSSI: r.rssi,
|
||||
@@ -3079,6 +3126,7 @@ func (s *PacketStore) enrichObs(obs *StoreObs) map[string]interface{} {
|
||||
"timestamp": strOrNil(obs.Timestamp),
|
||||
"observer_id": strOrNil(obs.ObserverID),
|
||||
"observer_name": strOrNil(obs.ObserverName),
|
||||
"observer_iata": strOrNil(obs.ObserverIATA),
|
||||
"direction": strOrNil(obs.Direction),
|
||||
"snr": floatPtrOrNil(obs.SNR),
|
||||
"rssi": floatPtrOrNil(obs.RSSI),
|
||||
@@ -3123,6 +3171,7 @@ func txToMap(tx *StoreTx, includeObservations ...bool) map[string]interface{} {
|
||||
"observation_count": tx.ObservationCount,
|
||||
"observer_id": strOrNil(tx.ObserverID),
|
||||
"observer_name": strOrNil(tx.ObserverName),
|
||||
"observer_iata": strOrNil(tx.ObserverIATA),
|
||||
"snr": floatPtrOrNil(tx.SNR),
|
||||
"rssi": floatPtrOrNil(tx.RSSI),
|
||||
"path_json": strOrNil(tx.PathJSON),
|
||||
@@ -3142,6 +3191,7 @@ func txToMap(tx *StoreTx, includeObservations ...bool) map[string]interface{} {
|
||||
"id": o.ID,
|
||||
"observer_id": strOrNil(o.ObserverID),
|
||||
"observer_name": strOrNil(o.ObserverName),
|
||||
"observer_iata": strOrNil(o.ObserverIATA),
|
||||
"snr": floatPtrOrNil(o.SNR),
|
||||
"rssi": floatPtrOrNil(o.RSSI),
|
||||
"path_json": strOrNil(o.PathJSON),
|
||||
|
||||
@@ -34,7 +34,7 @@ func TestTopologyDedup_RepeatersMergeByPubkey(t *testing.T) {
|
||||
id INTEGER PRIMARY KEY, transmission_id INTEGER, observer_id TEXT, observer_name TEXT,
|
||||
direction TEXT, snr REAL, rssi REAL, score INTEGER, path_json TEXT, timestamp TEXT, raw_hex TEXT
|
||||
)`)
|
||||
exec(`CREATE TABLE observers (rowid INTEGER PRIMARY KEY, id TEXT, name TEXT)`)
|
||||
exec(`CREATE TABLE observers (rowid INTEGER PRIMARY KEY, id TEXT, name TEXT, iata TEXT)`)
|
||||
exec(`CREATE TABLE nodes (
|
||||
public_key TEXT PRIMARY KEY, name TEXT, role TEXT, lat REAL, lon REAL,
|
||||
last_seen TEXT, frequency REAL
|
||||
@@ -158,7 +158,7 @@ func TestTopologyDedup_AmbiguousPrefixNotMerged(t *testing.T) {
|
||||
id INTEGER PRIMARY KEY, transmission_id INTEGER, observer_id TEXT, observer_name TEXT,
|
||||
direction TEXT, snr REAL, rssi REAL, score INTEGER, path_json TEXT, timestamp TEXT, raw_hex TEXT
|
||||
)`)
|
||||
exec(`CREATE TABLE observers (rowid INTEGER PRIMARY KEY, id TEXT, name TEXT)`)
|
||||
exec(`CREATE TABLE observers (rowid INTEGER PRIMARY KEY, id TEXT, name TEXT, iata TEXT)`)
|
||||
exec(`CREATE TABLE nodes (
|
||||
public_key TEXT PRIMARY KEY, name TEXT, role TEXT, lat REAL, lon REAL,
|
||||
last_seen TEXT, frequency REAL
|
||||
@@ -264,7 +264,7 @@ func TestTopologyDedup_PairsMergeByPubkey(t *testing.T) {
|
||||
id INTEGER PRIMARY KEY, transmission_id INTEGER, observer_id TEXT, observer_name TEXT,
|
||||
direction TEXT, snr REAL, rssi REAL, score INTEGER, path_json TEXT, timestamp TEXT, raw_hex TEXT
|
||||
)`)
|
||||
exec(`CREATE TABLE observers (rowid INTEGER PRIMARY KEY, id TEXT, name TEXT)`)
|
||||
exec(`CREATE TABLE observers (rowid INTEGER PRIMARY KEY, id TEXT, name TEXT, iata TEXT)`)
|
||||
exec(`CREATE TABLE nodes (
|
||||
public_key TEXT PRIMARY KEY, name TEXT, role TEXT, lat REAL, lon REAL,
|
||||
last_seen TEXT, frequency REAL
|
||||
|
||||
@@ -264,6 +264,7 @@ type TransmissionResp struct {
|
||||
ObservationCount int `json:"observation_count"`
|
||||
ObserverID interface{} `json:"observer_id"`
|
||||
ObserverName interface{} `json:"observer_name"`
|
||||
ObserverIATA interface{} `json:"observer_iata"`
|
||||
SNR interface{} `json:"snr"`
|
||||
RSSI interface{} `json:"rssi"`
|
||||
PathJSON interface{} `json:"path_json"`
|
||||
@@ -278,6 +279,7 @@ type ObservationResp struct {
|
||||
Hash interface{} `json:"hash,omitempty"`
|
||||
ObserverID interface{} `json:"observer_id"`
|
||||
ObserverName interface{} `json:"observer_name"`
|
||||
ObserverIATA interface{} `json:"observer_iata"`
|
||||
SNR interface{} `json:"snr"`
|
||||
RSSI interface{} `json:"rssi"`
|
||||
PathJSON interface{} `json:"path_json"`
|
||||
@@ -295,6 +297,7 @@ type GroupedPacketResp struct {
|
||||
Latest string `json:"latest"`
|
||||
ObserverID interface{} `json:"observer_id"`
|
||||
ObserverName interface{} `json:"observer_name"`
|
||||
ObserverIATA interface{} `json:"observer_iata"`
|
||||
PathJSON interface{} `json:"path_json"`
|
||||
PayloadType int `json:"payload_type"`
|
||||
RouteType int `json:"route_type"`
|
||||
|
||||
+24
-3
@@ -53,6 +53,24 @@
|
||||
}
|
||||
function setObserverIataMap(m) { observerIataMap = m || {}; }
|
||||
|
||||
// #1189 R2 mesh-operator fix: live feed must show the observer's IATA pill
|
||||
// alongside the existing 👁 N badge so operators on /live can tell SAME-
|
||||
// region from CROSS-region reception at a glance (same affordance as the
|
||||
// /packets table). Mirrors `obsIataBadge` in public/packets.js — kept as a
|
||||
// local helper for now (live.js and packets.js are separate IIFEs with no
|
||||
// shared module). TODO: extract `obsIataBadge` into shared packet-helpers.js
|
||||
// and have both surfaces import it.
|
||||
function obsIataBadgeHtml(pkt) {
|
||||
if (!pkt) return '';
|
||||
var iata = pkt.observer_iata;
|
||||
if (!iata && pkt.observer_id) iata = observerIataMap && observerIataMap[pkt.observer_id];
|
||||
if (!iata) return '';
|
||||
var esc = (typeof escapeHtml === 'function')
|
||||
? escapeHtml(iata)
|
||||
: String(iata).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
||||
return '<span class="badge-iata" style="font-size:10px;margin-left:4px">' + esc + '</span>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build observer_id → IATA map from the /api/observers response.
|
||||
* The endpoint returns `{ observers: [...], server_time: "..." }`
|
||||
@@ -2183,6 +2201,7 @@
|
||||
const preview = text ? ' ' + (text.length > 35 ? text.slice(0, 35) + '…' : text) : '';
|
||||
const hopStr = longestHops.length ? `<span class="feed-hops">${longestHops.length}⇢</span>` : '';
|
||||
const obsBadge = group.count > 1 ? `<span class="badge badge-obs" style="font-size:10px;margin-left:4px">👁 ${group.count}</span>` : '';
|
||||
const iataBadge = obsIataBadgeHtml(pkt);
|
||||
|
||||
var _ccPayload = (pkt.decoded || {}).payload || {};
|
||||
var _ccChan1 = (typeName === 'GRP_TXT' || typeName === 'CHAN') ? (_ccPayload.channel || null) : null;
|
||||
@@ -2197,7 +2216,7 @@
|
||||
item.innerHTML = `
|
||||
<span class="feed-icon" style="color:${color}">${icon}</span>
|
||||
<span class="feed-type" style="color:${color}">${typeName}</span>
|
||||
${dotHtml1}${transportBadge(pkt.route_type)}${hopStr}${obsBadge}
|
||||
${dotHtml1}${transportBadge(pkt.route_type)}${hopStr}${obsBadge}${iataBadge}
|
||||
<span class="feed-text">${escapeHtml(preview)}</span>
|
||||
<span class="feed-time" data-ts="${group.latestTs || Date.now()}">${formatLiveTimestampHtml(group.latestTs || Date.now())}</span>
|
||||
`;
|
||||
@@ -3251,6 +3270,7 @@
|
||||
const preview = text ? ' ' + (text.length > 35 ? text.slice(0, 35) + '…' : text) : '';
|
||||
const hopStr = hops.length ? `<span class="feed-hops">${hops.length}⇢</span>` : '';
|
||||
const obsBadge = pkt.observation_count > 1 ? `<span class="badge badge-obs" style="font-size:10px;margin-left:4px">👁 ${pkt.observation_count}</span>` : '';
|
||||
const iataBadge = obsIataBadgeHtml(pkt);
|
||||
const anomalyIcon = (pkt.decoded && pkt.decoded.anomaly) ? '<span title="Anomaly detected" style="margin-left:4px">⚠️</span>' : '';
|
||||
var _ccPayload2 = (pkt.decoded || {}).payload || {};
|
||||
var _ccChan = (typeName === 'GRP_TXT' || typeName === 'CHAN') ? (_ccPayload2.channel || null) : null;
|
||||
@@ -3270,7 +3290,7 @@
|
||||
item.innerHTML = `
|
||||
<span class="feed-icon" style="color:${color}">${icon}</span>
|
||||
<span class="feed-type" style="color:${color}">${typeName}</span>
|
||||
${dotHtml}${transportBadge(pkt.route_type)}${hopStr}${obsBadge}${anomalyIcon}
|
||||
${dotHtml}${transportBadge(pkt.route_type)}${hopStr}${obsBadge}${iataBadge}${anomalyIcon}
|
||||
<span class="feed-text">${escapeHtml(preview)}</span>
|
||||
<span class="feed-time" data-ts="${pkt._ts || Date.now()}">${formatLiveTimestampHtml(pkt._ts || Date.now())}</span>
|
||||
`;
|
||||
@@ -3337,6 +3357,7 @@
|
||||
const preview = text ? ' ' + (text.length > 35 ? text.slice(0, 35) + '…' : text) : '';
|
||||
const hopStr = hops.length ? `<span class="feed-hops">${hops.length}⇢</span>` : '';
|
||||
const obsBadge = incomingObs > 1 ? `<span class="badge badge-obs" style="font-size:10px;margin-left:4px">👁 ${incomingObs}</span>` : '';
|
||||
const iataBadge = obsIataBadgeHtml(pkt);
|
||||
var _ccPayload3 = (pkt.decoded || {}).payload || {};
|
||||
var _ccChan3 = (typeName === 'GRP_TXT' || typeName === 'CHAN') ? (_ccPayload3.channel || null) : null;
|
||||
var dotHtml3 = _ccChan3 ? _feedColorDot(_ccChan3) : '';
|
||||
@@ -3357,7 +3378,7 @@
|
||||
item.innerHTML = `
|
||||
<span class="feed-icon" style="color:${color}">${icon}</span>
|
||||
<span class="feed-type" style="color:${color}">${typeName}</span>
|
||||
${dotHtml3}${transportBadge(pkt.route_type)}${hopStr}${obsBadge}
|
||||
${dotHtml3}${transportBadge(pkt.route_type)}${hopStr}${obsBadge}${iataBadge}
|
||||
<span class="feed-text">${escapeHtml(preview)}</span>
|
||||
<span class="feed-time" data-ts="${pkt._ts || Date.now()}">${formatLiveTimestampHtml(pkt._ts || Date.now())}</span>
|
||||
`;
|
||||
|
||||
+51
-2
@@ -23,10 +23,10 @@
|
||||
var TK = {
|
||||
FIELD: 'FIELD', OP: 'OP', STRING: 'STRING', NUMBER: 'NUMBER', BOOL: 'BOOL',
|
||||
DURATION: 'DURATION',
|
||||
AND: 'AND', OR: 'OR', NOT: 'NOT', LPAREN: 'LPAREN', RPAREN: 'RPAREN'
|
||||
AND: 'AND', OR: 'OR', NOT: 'NOT', LPAREN: 'LPAREN', RPAREN: 'RPAREN', COMMA: 'COMMA'
|
||||
};
|
||||
|
||||
var OP_WORDS = { contains: true, starts_with: true, ends_with: true, after: true, before: true, between: true };
|
||||
var OP_WORDS = { contains: true, starts_with: true, ends_with: true, after: true, before: true, between: true, in: true };
|
||||
|
||||
// Duration unit → seconds. Used for `age < 1h`-style filters.
|
||||
var DURATION_UNITS = { s: 1, m: 60, h: 3600, d: 86400, w: 604800 };
|
||||
@@ -50,6 +50,7 @@
|
||||
if (input[i] === '!') { tokens.push({ type: TK.NOT, value: '!' }); i++; continue; }
|
||||
if (input[i] === '(') { tokens.push({ type: TK.LPAREN }); i++; continue; }
|
||||
if (input[i] === ')') { tokens.push({ type: TK.RPAREN }); i++; continue; }
|
||||
if (input[i] === ',') { tokens.push({ type: TK.COMMA, value: ',' }); i++; continue; }
|
||||
// quoted string
|
||||
if (input[i] === '"') {
|
||||
var j = i + 1;
|
||||
@@ -179,6 +180,28 @@
|
||||
return { type: 'comparison', field: field, op: op, value: lo, value2: hi };
|
||||
}
|
||||
|
||||
// `in` takes a parenthesized list of values: `field in (a, b, c)`
|
||||
if (op === 'in') {
|
||||
if (!peek() || peek().type !== TK.LPAREN) {
|
||||
throw new Error("Expected '(' after 'in'");
|
||||
}
|
||||
advance(); // consume '('
|
||||
var values = [];
|
||||
if (!peek() || peek().type === TK.RPAREN) {
|
||||
throw new Error("Empty value list for 'in'");
|
||||
}
|
||||
values.push(parseValue(field, op));
|
||||
while (peek() && peek().type === TK.COMMA) {
|
||||
advance(); // consume ','
|
||||
values.push(parseValue(field, op));
|
||||
}
|
||||
if (!peek() || peek().type !== TK.RPAREN) {
|
||||
throw new Error("Expected ')' or ',' in 'in' list");
|
||||
}
|
||||
advance(); // consume ')'
|
||||
return { type: 'comparison', field: field, op: op, values: values };
|
||||
}
|
||||
|
||||
var value = parseValue(field, op);
|
||||
if (op === 'after' || op === 'before') validateTimeValue(field, op, value);
|
||||
return { type: 'comparison', field: field, op: op, value: value };
|
||||
@@ -233,6 +256,7 @@
|
||||
}
|
||||
if (field === 'observer') return packet.observer_name || '';
|
||||
if (field === 'observer_id') return packet.observer_id || '';
|
||||
if (field === 'observer_iata' || field === 'iata') return packet.observer_iata || '';
|
||||
if (field === 'observations') return packet.observation_count || 0;
|
||||
if (field === 'time' || field === 'timestamp') {
|
||||
// Returns ms-since-epoch or null. Falls back to first_seen when timestamp absent
|
||||
@@ -304,6 +328,16 @@
|
||||
|
||||
if (fieldVal == null || fieldVal === undefined) return false;
|
||||
|
||||
// `in` operator: membership in a list of values (case-insensitive for strings)
|
||||
if (op === 'in') {
|
||||
var list = ast.values || [];
|
||||
var lhs = String(fieldVal).toLowerCase();
|
||||
for (var iv = 0; iv < list.length; iv++) {
|
||||
if (String(list[iv]).toLowerCase() === lhs) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Temporal ops: after / before / between operate on epoch-ms.
|
||||
if (op === 'after' || op === 'before' || op === 'between') {
|
||||
var lhsMs = typeof fieldVal === 'number' ? fieldVal : Date.parse(fieldVal);
|
||||
@@ -397,6 +431,8 @@
|
||||
{ name: 'hops', desc: 'Number of hops in the path' },
|
||||
{ name: 'observer', desc: 'Observer station name' },
|
||||
{ name: 'observer_id', desc: 'Observer pubkey/id' },
|
||||
{ name: 'observer_iata', desc: 'Observer IATA region code (e.g. SJC, SFO)' },
|
||||
{ name: 'iata', desc: 'Alias of observer_iata' },
|
||||
{ name: 'observations', desc: 'Number of observations of this packet' },
|
||||
{ name: 'path', desc: 'Hop path (joined with arrows)' },
|
||||
{ name: 'payload_bytes', desc: 'Payload size in bytes (size - 2 header bytes)' },
|
||||
@@ -428,6 +464,7 @@
|
||||
{ op: 'after', desc: 'Datetime after (ISO or epoch)', example: 'time after "2025-01-01"' },
|
||||
{ op: 'before', desc: 'Datetime before', example: 'time before "2025-12-31"' },
|
||||
{ op: 'between', desc: 'Datetime between two values', example: 'time between "2025-01-01" "2025-02-01"' },
|
||||
{ op: 'in', desc: 'Value in a list (case-insensitive for strings)', example: 'iata in ("SJC","SFO")' },
|
||||
];
|
||||
|
||||
// Canonical type names (firmware payload types)
|
||||
@@ -611,6 +648,18 @@
|
||||
c = compile('observer == "kpabap"');
|
||||
assert(c.filter({ observer_name: 'kpabap' }), 'observer');
|
||||
|
||||
// Observer IATA (#1188)
|
||||
c = compile('observer_iata == "SJC"');
|
||||
assert(c.filter({ observer_iata: 'SJC' }), 'observer_iata ==');
|
||||
assert(!c.filter({ observer_iata: 'SFO' }), 'observer_iata != mismatch');
|
||||
c = compile('iata == "SJC"');
|
||||
assert(c.filter({ observer_iata: 'SJC' }), 'iata alias');
|
||||
c = compile('iata in ("SJC","SFO")');
|
||||
assert(c.filter({ observer_iata: 'SFO' }), 'iata in (...)');
|
||||
assert(!c.filter({ observer_iata: 'LAX' }), 'iata in (...) mismatch');
|
||||
c = compile('observer_iata contains "S"');
|
||||
assert(c.filter({ observer_iata: 'SJC' }), 'observer_iata contains');
|
||||
|
||||
console.log('\nAll tests passed!');
|
||||
module.exports = { parse: parse, evaluate: evaluate, compile: compile };
|
||||
}
|
||||
|
||||
+74
-5
@@ -465,6 +465,75 @@
|
||||
if (!o) return id;
|
||||
return o.iata ? `${o.name} (${o.iata})` : o.name;
|
||||
}
|
||||
// Compact IATA pill (#1188) — renders next to observer name. Prefers
|
||||
// packet.observer_iata (now joined on the server) and falls back to the
|
||||
// observer lookup map for callers that haven't been updated yet.
|
||||
function obsIataBadge(packet) {
|
||||
if (!packet) return '';
|
||||
let iata = packet.observer_iata;
|
||||
if (!iata) {
|
||||
const o = packet.observer_id ? observerMap.get(packet.observer_id) : null;
|
||||
iata = o && o.iata;
|
||||
}
|
||||
return iata ? `<span class="badge-iata">${escapeHtml(iata)}</span>` : '';
|
||||
}
|
||||
// Plain observer name without the trailing IATA — used when the IATA is
|
||||
// rendered separately as a badge (so the cell doesn't show "Name (SJC) SJC").
|
||||
function obsNameOnly(id) {
|
||||
if (!id) return '—';
|
||||
const o = observerMap.get(id);
|
||||
if (!o) return id;
|
||||
return o.name;
|
||||
}
|
||||
// #1189 R1 mesh-operator feedback: in a grouped row the old cell showed ONE
|
||||
// observer's IATA + `+N` — operators couldn't tell whether the N additional
|
||||
// observers were SAME-region (redundant copies) or CROSS-region (interesting
|
||||
// multi-site reception). This helper returns the cell's badge HTML showing
|
||||
// the DISTINCT IATA set: `<badge>SJC</badge>` or `<badge>SJC</badge><badge>SFO</badge>+1`
|
||||
// (capped at 2 visible, remainder rolled into +N of distinct-region count).
|
||||
// Returns '' when no observer in the group carries any IATA.
|
||||
//
|
||||
// #1189 R2: source of truth is `p.distinct_iatas` from the server
|
||||
// (added to /api/packets?groupByHash=true so the default collapsed view
|
||||
// works without needing to expand a row). Falls back to walking
|
||||
// p._children + observerMap for legacy callers and for client-side groups
|
||||
// synthesised by the websocket appender.
|
||||
function groupedObserverIataBadgesHtml(p) {
|
||||
if (!p) return '';
|
||||
const seen = new Set();
|
||||
// R2 happy path: server-provided distinct_iatas.
|
||||
if (Array.isArray(p.distinct_iatas)) {
|
||||
for (const code of p.distinct_iatas) {
|
||||
if (code) seen.add(String(code).toUpperCase());
|
||||
}
|
||||
}
|
||||
// Fallback / supplement: walk header + children (covers in-memory groups
|
||||
// built client-side from websocket events before any server round-trip).
|
||||
if (!seen.size) {
|
||||
const pushIata = (rec) => {
|
||||
if (!rec) return;
|
||||
let iata = rec.observer_iata;
|
||||
if (!iata && rec.observer_id) {
|
||||
const o = observerMap.get(rec.observer_id);
|
||||
iata = o && o.iata;
|
||||
}
|
||||
if (iata) seen.add(String(iata).toUpperCase());
|
||||
};
|
||||
pushIata(p);
|
||||
if (p._children && p._children.length) {
|
||||
for (const c of p._children) pushIata(c);
|
||||
}
|
||||
}
|
||||
if (!seen.size) return '';
|
||||
const list = Array.from(seen).sort();
|
||||
const visible = list.slice(0, 2);
|
||||
const extra = list.length - visible.length;
|
||||
let html = visible
|
||||
.map(code => `<span class="badge-iata">${escapeHtml(code)}</span>`)
|
||||
.join('');
|
||||
if (extra > 0) html += ` +${extra}`;
|
||||
return html;
|
||||
}
|
||||
let selectedId = null;
|
||||
function _isColorByHash() { return localStorage.getItem('meshcore-color-packets-by-hash') !== 'false'; }
|
||||
function _currentTheme() { return document.documentElement.dataset.theme || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'); }
|
||||
@@ -1945,7 +2014,7 @@
|
||||
<td class="col-size" data-filter-field="size" data-filter-value="${groupSize || ''}">${groupSize ? groupSize + 'B' : '—'}</td>
|
||||
<td class="col-hashsize mono">${groupHashBytes}</td>
|
||||
<td class="col-type" data-filter-field="type" data-filter-value="${escapeHtml(groupTypeName || '')}">${p.payload_type != null ? `<span class="badge badge-${groupTypeClass}">${groupTypeName}</span>${transportBadge(p.route_type)}` : '—'}</td>
|
||||
<td class="col-observer" data-filter-field="observer" data-filter-value="${escapeHtml(obsName(headerObserverId) || '')}">${isSingle ? truncate(obsName(headerObserverId), 16) : truncate(obsName(headerObserverId), 10) + (p.observer_count > 1 ? ' +' + (p.observer_count - 1) : '')}</td>
|
||||
<td class="col-observer" data-filter-field="observer" data-filter-value="${escapeHtml(obsNameOnly(headerObserverId) || '')}">${isSingle ? truncate(obsNameOnly(headerObserverId), 16) + obsIataBadge(p) : truncate(obsNameOnly(headerObserverId), 10) + groupedObserverIataBadgesHtml(p)}</td>
|
||||
<td class="col-path"><span class="path-hops">${groupPathStr}</span></td>
|
||||
<td class="col-rpt">${p.observation_count > 1 ? '<span class="badge badge-obs" title="Seen ' + p.observation_count + ' times">👁 ' + p.observation_count + '</span>' : (isSingle ? '' : p.count)}</td>
|
||||
<td class="col-details">${getDetailPreview(getParsedDecoded(p))}</td>
|
||||
@@ -1971,7 +2040,7 @@
|
||||
<td class="col-size" data-filter-field="size" data-filter-value="${size || ''}">${size}B</td>
|
||||
<td class="col-hashsize mono">${childHashBytes}</td>
|
||||
<td class="col-type" data-filter-field="type" data-filter-value="${escapeHtml(typeName || '')}"><span class="badge badge-${typeClass}">${typeName}</span>${transportBadge(c.route_type)}</td>
|
||||
<td class="col-observer" data-filter-field="observer" data-filter-value="${escapeHtml(obsName(c.observer_id) || '')}">${truncate(obsName(c.observer_id), 16)}</td>
|
||||
<td class="col-observer" data-filter-field="observer" data-filter-value="${escapeHtml(obsNameOnly(c.observer_id) || '')}">${truncate(obsNameOnly(c.observer_id), 16)}${obsIataBadge(c)}</td>
|
||||
<td class="col-path"><span class="path-hops">${childPathStr}</span></td>
|
||||
<td class="col-rpt"></td>
|
||||
<td class="col-details">${getDetailPreview(getParsedDecoded(c))}</td>
|
||||
@@ -2003,7 +2072,7 @@
|
||||
<td class="col-size" data-filter-field="size" data-filter-value="${size || ''}">${size}B</td>
|
||||
<td class="col-hashsize mono">${hashBytes}</td>
|
||||
<td class="col-type" data-filter-field="type" data-filter-value="${escapeHtml(typeName || '')}"><span class="badge badge-${typeClass}">${typeName}</span>${transportBadge(p.route_type)}</td>
|
||||
<td class="col-observer" data-filter-field="observer" data-filter-value="${escapeHtml(obsName(p.observer_id) || '')}">${truncate(obsName(p.observer_id), 16)}</td>
|
||||
<td class="col-observer" data-filter-field="observer" data-filter-value="${escapeHtml(obsNameOnly(p.observer_id) || '')}">${truncate(obsNameOnly(p.observer_id), 16)}${obsIataBadge(p)}</td>
|
||||
<td class="col-path"><span class="path-hops">${pathStr}</span></td>
|
||||
<td class="col-rpt"></td>
|
||||
<td class="col-details">${detail}</td>
|
||||
@@ -2823,7 +2892,7 @@
|
||||
<div class="detail-hash">${pkt.hash || 'Packet #' + pkt.id}${obsIndicator}</div>
|
||||
${messageHtml}
|
||||
<dl class="detail-meta">
|
||||
<dt>Observer</dt><dd>${obsName(effectivePkt.observer_id)}</dd>
|
||||
<dt>Observer</dt><dd>${obsNameOnly(effectivePkt.observer_id)}${obsIataBadge(effectivePkt)}</dd>
|
||||
<dt>Location</dt><dd>${locationHtml}</dd>
|
||||
<dt>SNR / RSSI</dt><dd>${snr != null ? snr + ' dB' : '—'} / ${rssi != null ? rssi + ' dBm' : '—'}</dd>
|
||||
<dt>Route Type</dt><dd>${routeTypeName(pkt.route_type)}</dd>
|
||||
@@ -2861,7 +2930,7 @@
|
||||
const oPath = getParsedPath(o);
|
||||
const isCurrent = currentObs && String(o.id) === String(currentObs.id);
|
||||
return `<tr class="detail-obs-row${isCurrent ? ' observation-current' : ''}" data-obs-id="${o.id}" style="cursor:pointer;${isCurrent ? 'background:var(--accent-bg, rgba(0,122,255,0.1))' : ''}" title="Click to view this observation">
|
||||
<td style="padding:4px 6px">${obsName(o.observer_id)}</td>
|
||||
<td style="padding:4px 6px">${obsNameOnly(o.observer_id)}${obsIataBadge(o)}</td>
|
||||
<td style="padding:4px 6px">${oPath.length}</td>
|
||||
<td style="padding:4px 6px">${o.snr != null ? o.snr + ' dB' : '—'}</td>
|
||||
<td style="padding:4px 6px">${o.rssi != null ? o.rssi + ' dBm' : '—'}</td>
|
||||
|
||||
@@ -877,6 +877,15 @@ body.scroll-locked { overflow: hidden; }
|
||||
font-size: 10px; font-weight: 700; font-family: var(--mono);
|
||||
background: var(--nav-bg); color: var(--nav-text); letter-spacing: .5px;
|
||||
}
|
||||
/* Observer IATA pill rendered inline next to observer name on packets (#1188).
|
||||
* Visually similar to .badge-region but distinct so the row badge and the
|
||||
* inline-with-observer badge can be styled independently in future themes. */
|
||||
.badge-iata {
|
||||
display: inline-block; padding: 1px 5px; border-radius: 4px;
|
||||
font-size: 10px; font-weight: 700; font-family: var(--mono);
|
||||
background: var(--nav-bg); color: var(--nav-text); letter-spacing: .5px;
|
||||
margin-left: 4px; vertical-align: middle;
|
||||
}
|
||||
/* TODO: expose --transport-badge-bg/fg in customizer THEME_CSS_MAP (tracked in future milestone) */
|
||||
.badge-transport {
|
||||
display: inline-block; padding: 1px 5px; border-radius: 4px;
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Composed-cell test (#1189 R2 item 3): the grouped row's Observer cell must
|
||||
* NOT render two adjacent unlabeled "+N +M" tokens.
|
||||
*
|
||||
* Before R2: `Name SJC SFO +1 +5` — `+1` was distinct-IATA overflow,
|
||||
* `+5` was observer-count overflow. On mobile the two unlabeled tokens
|
||||
* wrap/clip and operators can't tell what either number means. The
|
||||
* eyeball-count badge `👁 N` in the col-rpt cell already conveys multi-
|
||||
* reception, so we drop the observer-count `+N` from this composed cell
|
||||
* and keep `+N` semantics SINGLE — distinct-IATA overflow only.
|
||||
*
|
||||
* Strategy: extract just the col-observer template-literal expression
|
||||
* from buildGroupRowHtml in public/packets.js, evaluate it in a Node
|
||||
* sandbox with mocked helpers, and assert the output contains exactly
|
||||
* ONE `+N` token (or zero if all IATAs fit). Mutation test: revert the
|
||||
* fix → this test must turn red.
|
||||
*/
|
||||
'use strict';
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function assert(cond, msg) {
|
||||
if (cond) { passed++; console.log(' \u2705 ' + msg); }
|
||||
else { failed++; console.error(' \u274c ' + msg); }
|
||||
}
|
||||
|
||||
const src = fs.readFileSync(path.join(__dirname, 'public/packets.js'), 'utf8');
|
||||
|
||||
// ── Extract the composed col-observer cell expression. ──────────────────
|
||||
// The cell is built inline in buildGroupRowHtml's template literal:
|
||||
// <td class="col-observer" ...>${ ... composed expression ... }</td>
|
||||
// We capture the expression between the `>` and the closing `</td>` of the
|
||||
// col-observer cell when the row is a GROUP HEADER (isSingle false branch).
|
||||
// The expression matches the pattern:
|
||||
// isSingle ? <single-branch> : <multi-branch>
|
||||
// We want to verify the multi-branch.
|
||||
const cellRe = /class="col-observer"[^<]*?>\$\{(isSingle\s*\?[\s\S]*?)\}<\/td>/m;
|
||||
const m = src.match(cellRe);
|
||||
assert(m != null,
|
||||
'extracted composed col-observer cell expression from buildGroupRowHtml');
|
||||
|
||||
if (m) {
|
||||
// The matched expression is `isSingle ? <single> : <multi>`. Force the
|
||||
// multi branch by binding isSingle=false in the sandbox and evaluating.
|
||||
const expr = m[1];
|
||||
|
||||
// Sandbox mocks. `groupedObserverIataBadgesHtml` returns a deterministic
|
||||
// multi-IATA overflow string so the composed cell would historically have
|
||||
// shown `<helper output> +<observer_count - 1>` adjacent to it.
|
||||
const ctx = {
|
||||
p: {
|
||||
observer_count: 6, // would have rendered ' +5'
|
||||
observer_id: 'obsA',
|
||||
_children: [], // collapsed view — no children
|
||||
distinct_iatas: ['SJC', 'SFO', 'OAK'],
|
||||
},
|
||||
headerObserverId: 'obsA',
|
||||
isSingle: false,
|
||||
truncate(s, n) { return String(s || '').slice(0, n); },
|
||||
obsNameOnly(_id) { return 'NameA'; },
|
||||
obsIataBadge(_p) { return ''; },
|
||||
groupedObserverIataBadgesHtml(_p) {
|
||||
return '<span class="badge-iata">SJC</span><span class="badge-iata">SFO</span> +1';
|
||||
},
|
||||
escapeHtml(s) {
|
||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')
|
||||
.replace(/"/g,'"').replace(/'/g,''');
|
||||
},
|
||||
};
|
||||
vm.createContext(ctx);
|
||||
// Wrap in template literal so all ${} substitutions resolve.
|
||||
ctx.__result__ = vm.runInContext('`${' + expr + '}`', ctx);
|
||||
const cell = ctx.__result__;
|
||||
|
||||
// Cell must include the IATA badges from the helper.
|
||||
assert(cell.includes('SJC') && cell.includes('SFO'),
|
||||
'composed cell contains the distinct-IATA badges, got: ' + cell);
|
||||
|
||||
// CORE assertion: exactly ONE `+N` token in the cell. Before the fix the
|
||||
// cell would carry `+1 +5` (distinct-IATA overflow + observer-count
|
||||
// overflow); after the fix only `+1` remains.
|
||||
const plusTokens = cell.match(/\+\d+/g) || [];
|
||||
assert(plusTokens.length === 1,
|
||||
'composed cell carries exactly ONE `+N` token (distinct-IATA only); got ' +
|
||||
plusTokens.length + ' tokens: ' + JSON.stringify(plusTokens) +
|
||||
'\n cell=' + cell);
|
||||
|
||||
// The `+5` (observer_count - 1 = 5) MUST NOT appear — that would mean
|
||||
// the observer-count overflow was re-introduced.
|
||||
assert(!cell.includes('+5'),
|
||||
'composed cell must NOT contain observer-count overflow `+5`; got: ' + cell);
|
||||
|
||||
// And the cell must not contain ` +` adjacent twice (the failure mode is
|
||||
// `+N +M` with whitespace between two `+` tokens).
|
||||
assert(!/\+\d+\s+\+\d+/.test(cell),
|
||||
'composed cell must NOT contain adjacent `+N +M` tokens; got: ' + cell);
|
||||
|
||||
// Edge case: even when the helper returns no overflow at all, the cell
|
||||
// must still NOT append an observer-count `+N`.
|
||||
const ctx2 = Object.assign({}, ctx);
|
||||
ctx2.groupedObserverIataBadgesHtml = function () {
|
||||
return '<span class="badge-iata">SJC</span>';
|
||||
};
|
||||
vm.createContext(ctx2);
|
||||
ctx2.__result__ = vm.runInContext('`${' + expr + '}`', ctx2);
|
||||
const cell2 = ctx2.__result__;
|
||||
const plusTokens2 = cell2.match(/\+\d+/g) || [];
|
||||
assert(plusTokens2.length === 0,
|
||||
'composed cell with single-IATA helper output has ZERO `+N` tokens; got: ' +
|
||||
JSON.stringify(plusTokens2) + '\n cell=' + cell2);
|
||||
}
|
||||
|
||||
console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Behavior test (#1189 R2): the /live feed item DOM must surface the observer
|
||||
* IATA pill. Earlier R1 fix only updated the /packets table; mesh operators
|
||||
* live on /live and were missing the SAME-region vs CROSS-region affordance.
|
||||
*
|
||||
* Strategy mirrors test-observer-iata-1188.js — extract the helper from
|
||||
* public/live.js, evaluate it in a Node sandbox, and assert the rendered
|
||||
* HTML contains the expected `.badge-iata` markup. Mutating live.js to
|
||||
* drop the badge (revert to ${obsBadge} without ${iataBadge}) MUST flip
|
||||
* this test red.
|
||||
*
|
||||
* Additionally grep the three live-feed render sites to confirm they all
|
||||
* emit ${iataBadge} — protects against partial-fix regressions.
|
||||
*/
|
||||
'use strict';
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function assert(cond, msg) {
|
||||
if (cond) { passed++; console.log(' \u2705 ' + msg); }
|
||||
else { failed++; console.error(' \u274c ' + msg); }
|
||||
}
|
||||
|
||||
const src = fs.readFileSync(path.join(__dirname, 'public/live.js'), 'utf8');
|
||||
|
||||
// ── Extract obsIataBadgeHtml(pkt) ─────────────────────────────────────────
|
||||
function extractFn(name) {
|
||||
const re = new RegExp(
|
||||
'function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{[\\s\\S]*?\\n\\s{2}\\}',
|
||||
'm'
|
||||
);
|
||||
const m = src.match(re);
|
||||
return m ? m[0] : null;
|
||||
}
|
||||
|
||||
const badgeSrc = extractFn('obsIataBadgeHtml');
|
||||
assert(badgeSrc != null,
|
||||
'live.js defines a top-level function obsIataBadgeHtml (the live-feed IATA helper)');
|
||||
|
||||
const ctx = {
|
||||
observerIataMap: {},
|
||||
escapeHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
},
|
||||
obsIataBadgeHtml: function () { return ''; },
|
||||
};
|
||||
vm.createContext(ctx);
|
||||
if (badgeSrc) {
|
||||
vm.runInContext(badgeSrc + '\nobsIataBadgeHtml = obsIataBadgeHtml;', ctx);
|
||||
}
|
||||
const obsIataBadgeHtml = ctx.obsIataBadgeHtml;
|
||||
|
||||
// pkt.observer_iata wins ─────────────────────────────────────────────────
|
||||
{
|
||||
const html = obsIataBadgeHtml({ observer_iata: 'SJC' });
|
||||
assert(html.includes('class="badge-iata"'),
|
||||
'observer_iata=SJC renders .badge-iata span, got: ' + html);
|
||||
assert(html.includes('>SJC<'),
|
||||
'observer_iata=SJC renders the IATA text, got: ' + html);
|
||||
}
|
||||
|
||||
// Fallback to observerIataMap ────────────────────────────────────────────
|
||||
{
|
||||
ctx.observerIataMap = { 'obs-1': 'SFO' };
|
||||
const html = obsIataBadgeHtml({ observer_id: 'obs-1' });
|
||||
assert(html.includes('>SFO<'),
|
||||
'falls back to observerIataMap when observer_iata absent, got: ' + html);
|
||||
}
|
||||
|
||||
// No IATA → empty ────────────────────────────────────────────────────────
|
||||
{
|
||||
ctx.observerIataMap = {};
|
||||
const html = obsIataBadgeHtml({ observer_id: 'unknown' });
|
||||
assert(html === '', 'no IATA → empty string, got: ' + html);
|
||||
}
|
||||
|
||||
// null safety ─────────────────────────────────────────────────────────────
|
||||
{
|
||||
assert(obsIataBadgeHtml(null) === '', 'null packet → empty string');
|
||||
}
|
||||
|
||||
// Hostile input is escaped ────────────────────────────────────────────────
|
||||
{
|
||||
const html = obsIataBadgeHtml({ observer_iata: '<x>' });
|
||||
assert(!html.includes('<x>') && html.includes('<x>'),
|
||||
'escapes hostile IATA, got: ' + html);
|
||||
}
|
||||
|
||||
// ── Wiring guard: every feed-item render site must emit ${iataBadge} ─────
|
||||
// rebuildFeedList (~2200), addFeedItemDOM (~3270), addFeedItem (~3360).
|
||||
// If a future patch adds a 4th render path without the badge, this fails.
|
||||
const iataBadgeUsages = (src.match(/\$\{iataBadge\}/g) || []).length;
|
||||
assert(iataBadgeUsages >= 3,
|
||||
'live.js feed render sites all include ${iataBadge} (>=3 usages); got: ' + iataBadgeUsages);
|
||||
|
||||
// And each `obsBadge` declaration must be paired with an `iataBadge` declaration
|
||||
// immediately after — guards against a render site that computes obsBadge but
|
||||
// forgets iataBadge.
|
||||
const obsBadgeDecls = src.match(/const obsBadge\s*=/g) || [];
|
||||
const iataBadgeDecls = src.match(/const iataBadge\s*=\s*obsIataBadgeHtml\(/g) || [];
|
||||
assert(obsBadgeDecls.length === iataBadgeDecls.length,
|
||||
`obsBadge decls (${obsBadgeDecls.length}) match iataBadge decls (${iataBadgeDecls.length})`);
|
||||
|
||||
console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* E2E test (#1188): observer IATA must render alongside observer name
|
||||
* on packets rows and in the detail pane. Plus, the wireshark-style
|
||||
* filter grammar must accept `observer_iata` / `iata` expressions.
|
||||
*
|
||||
* Runs against the e2e fixture (see test-fixtures/e2e-fixture.db).
|
||||
* Observers in the fixture carry IATA codes (e.g. SJC, OAK, MRY),
|
||||
* so once the UI changes land, at least one rendered packet row must
|
||||
* carry one of those codes next to its observer name.
|
||||
*
|
||||
* Usage: BASE_URL=http://localhost:13581 node test-observer-iata-1188-e2e.js
|
||||
*/
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
const BASE = process.env.BASE_URL || 'http://localhost:3000';
|
||||
|
||||
async function test(name, fn) {
|
||||
try {
|
||||
await fn();
|
||||
console.log(` \u2705 ${name}`);
|
||||
} catch (err) {
|
||||
console.log(` \u274c ${name}: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
function assert(cond, msg) {
|
||||
if (!cond) throw new Error(msg || 'Assertion failed');
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
executablePath: process.env.CHROMIUM_PATH || undefined,
|
||||
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage']
|
||||
});
|
||||
const ctx = await browser.newContext({ viewport: { width: 1400, height: 900 } });
|
||||
const page = await ctx.newPage();
|
||||
page.setDefaultTimeout(15000);
|
||||
|
||||
console.log(`\nRunning observer-IATA E2E tests against ${BASE}\n`);
|
||||
|
||||
await test('Packets table renders an IATA badge in an observer cell', async () => {
|
||||
await page.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded' });
|
||||
// Wide time window so fixture rows are in scope
|
||||
await page.evaluate(() => localStorage.setItem('meshcore-time-window', '525600'));
|
||||
await page.reload({ waitUntil: 'load' });
|
||||
await page.waitForSelector('[data-loaded="true"]', { timeout: 20000 });
|
||||
await page.waitForSelector('table tbody tr:not([id^=vscroll])', { timeout: 15000 });
|
||||
|
||||
// Cells in the Observer column should contain a `.badge-iata` element
|
||||
// for at least one row that has a known IATA.
|
||||
const iataBadges = await page.$$('td.col-observer .badge-iata');
|
||||
assert(iataBadges.length > 0,
|
||||
`expected at least one .badge-iata inside a .col-observer cell; got ${iataBadges.length}`);
|
||||
|
||||
// The badge text should be a recognizable IATA code (3 uppercase letters)
|
||||
const text = (await iataBadges[0].textContent() || '').trim();
|
||||
assert(/^[A-Z]{3}$/.test(text), `expected 3-letter IATA in badge, got "${text}"`);
|
||||
});
|
||||
|
||||
await test('Filter grammar: observer_iata == "<code>" narrows the table', async () => {
|
||||
await page.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('[data-loaded="true"]', { timeout: 20000 });
|
||||
await page.waitForSelector('table tbody tr:not([id^=vscroll])', { timeout: 15000 });
|
||||
|
||||
// Pick the first IATA shown in any badge in the table
|
||||
const firstBadge = await page.$('td.col-observer .badge-iata');
|
||||
assert(firstBadge, 'no .badge-iata found to pick a filter value from');
|
||||
const iata = (await firstBadge.textContent() || '').trim();
|
||||
assert(/^[A-Z]{3}$/.test(iata), `expected IATA, got "${iata}"`);
|
||||
|
||||
// Apply the filter — `iata == "XXX"` should leave rows visible
|
||||
const input = await page.$('#packetFilterInput');
|
||||
assert(input, 'packet filter input not found');
|
||||
await input.fill(`iata == "${iata}"`);
|
||||
await page.waitForTimeout(500); // debounce
|
||||
|
||||
const rowsAfter = await page.$$('table tbody tr:not([id^=vscroll])');
|
||||
assert(rowsAfter.length > 0, `expected matching rows for iata == "${iata}"`);
|
||||
|
||||
// Every remaining observer cell should carry the same IATA
|
||||
const badges = await page.$$('td.col-observer .badge-iata');
|
||||
for (const b of badges) {
|
||||
const t = (await b.textContent() || '').trim();
|
||||
assert(t === iata, `unexpected IATA ${t} in row when filter is iata == "${iata}"`);
|
||||
}
|
||||
});
|
||||
|
||||
await test('Mobile viewport (375px): observer IATA badge stays visible — not clipped', async () => {
|
||||
// #1189 R1 mesh-operator finding: no narrow-viewport validation existed.
|
||||
// At 375px (iPhone SE) the .col-observer cell must still render its
|
||||
// .badge-iata pill within the viewport bounds. If a future CSS change
|
||||
// moves observer behind a hidden column or clips the badge off-screen,
|
||||
// this assertion turns red.
|
||||
const mobile = await browser.newContext({ viewport: { width: 375, height: 812 } });
|
||||
const mpage = await mobile.newPage();
|
||||
mpage.setDefaultTimeout(15000);
|
||||
await mpage.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded' });
|
||||
await mpage.evaluate(() => localStorage.setItem('meshcore-time-window', '525600'));
|
||||
await mpage.reload({ waitUntil: 'load' });
|
||||
await mpage.waitForSelector('[data-loaded="true"]', { timeout: 20000 });
|
||||
await mpage.waitForSelector('table tbody tr:not([id^=vscroll])', { timeout: 15000 });
|
||||
const badge = await mpage.$('td.col-observer .badge-iata');
|
||||
assert(badge, 'no .badge-iata rendered at 375px viewport');
|
||||
// Must be in the viewport horizontally — not clipped beyond the 375px edge.
|
||||
const box = await badge.boundingBox();
|
||||
assert(box, '.badge-iata has no bounding box (not rendered)');
|
||||
assert(box.x >= 0, `.badge-iata x=${box.x} is off the left edge at 375px`);
|
||||
assert(box.x + box.width <= 375 + 1,
|
||||
`.badge-iata right edge ${box.x + box.width} exceeds 375px viewport`);
|
||||
assert(box.width > 0 && box.height > 0,
|
||||
`.badge-iata has zero dimensions (collapsed/clipped): ${box.width}x${box.height}`);
|
||||
await mobile.close();
|
||||
});
|
||||
|
||||
await browser.close();
|
||||
console.log(`\nAll observer-IATA E2E tests passed.\n`);
|
||||
}
|
||||
|
||||
run().catch(err => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* Behavior test (#1188 / #1189 R1): public/packets.js helper `obsIataBadge`
|
||||
* must actually USE packet.observer_iata and emit the .badge-iata span.
|
||||
*
|
||||
* Earlier version was tautological — a grep over the source. A deliberately
|
||||
* broken `obsIataBadge` that returned a hardcoded string still passed every
|
||||
* assertion. This version EXTRACTS the function body, evaluates it in a
|
||||
* Node sandbox, and asserts the returned HTML for known inputs. Mutating
|
||||
* the implementation (e.g. ignoring `packet.observer_iata`, returning the
|
||||
* empty string, dropping `escapeHtml`) MUST flip this test red.
|
||||
*
|
||||
* Runs in Node.js — no browser, no jsdom required.
|
||||
*/
|
||||
'use strict';
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function assert(cond, msg) {
|
||||
if (cond) { passed++; console.log(' \u2705 ' + msg); }
|
||||
else { failed++; console.error(' \u274c ' + msg); }
|
||||
}
|
||||
|
||||
const src = fs.readFileSync(path.join(__dirname, 'public/packets.js'), 'utf8');
|
||||
|
||||
// ── Extract obsIataBadge source. Function spans a small, bounded block. ──
|
||||
// We capture everything from the `function obsIataBadge` keyword up to and
|
||||
// including the closing brace that terminates the *function body*. Use a
|
||||
// non-greedy match then expand minimally — packets.js keeps the helper
|
||||
// short and self-contained so this is robust.
|
||||
function extractFn(name) {
|
||||
const re = new RegExp(
|
||||
'function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{[\\s\\S]*?\\n\\s{2}\\}',
|
||||
'm'
|
||||
);
|
||||
const m = src.match(re);
|
||||
if (!m) throw new Error('could not extract function ' + name);
|
||||
return m[0];
|
||||
}
|
||||
|
||||
const badgeSrc = extractFn('obsIataBadge');
|
||||
const groupedSrc = extractFn('groupedObserverIataBadgesHtml');
|
||||
|
||||
// Sandbox: provide the helpers the function depends on (escapeHtml,
|
||||
// observerMap). escapeHtml mirrors the real one in public/app.js.
|
||||
const ctx = {
|
||||
observerMap: new Map(),
|
||||
escapeHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
},
|
||||
obsIataBadge: null,
|
||||
groupedObserverIataBadgesHtml: null,
|
||||
};
|
||||
vm.createContext(ctx);
|
||||
vm.runInContext(badgeSrc + '\n' + groupedSrc + '\nobsIataBadge = obsIataBadge;\ngroupedObserverIataBadgesHtml = groupedObserverIataBadgesHtml;', ctx);
|
||||
const obsIataBadge = ctx.obsIataBadge;
|
||||
const groupedObserverIataBadgesHtml = ctx.groupedObserverIataBadgesHtml;
|
||||
if (typeof obsIataBadge !== 'function') {
|
||||
console.error(' \u274c failed to load obsIataBadge into sandbox');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ── Behavior #1: packet.observer_iata SJC → <span class="badge-iata">SJC</span> ──
|
||||
{
|
||||
const html = obsIataBadge({ observer_iata: 'SJC' });
|
||||
assert(typeof html === 'string', 'returns a string');
|
||||
assert(html.includes('class="badge-iata"'),
|
||||
'output contains class="badge-iata"');
|
||||
assert(html.includes('>SJC<'),
|
||||
'output contains the IATA value (SJC) as text content');
|
||||
assert(html === '<span class="badge-iata">SJC</span>',
|
||||
'output is exactly <span class="badge-iata">SJC</span>, got: ' + html);
|
||||
}
|
||||
|
||||
// ── Behavior #2: no observer_iata and no observerMap entry → empty string ──
|
||||
{
|
||||
const html = obsIataBadge({ observer_id: 'unknown-obs' });
|
||||
assert(html === '',
|
||||
'returns "" when packet has no observer_iata and observerMap lacks the id');
|
||||
}
|
||||
|
||||
// ── Behavior #3: fallback to observerMap when packet.observer_iata absent ──
|
||||
{
|
||||
ctx.observerMap.set('obs-fallback', { name: 'Foo', iata: 'OAK' });
|
||||
const html = obsIataBadge({ observer_id: 'obs-fallback' });
|
||||
assert(html === '<span class="badge-iata">OAK</span>',
|
||||
'falls back to observerMap.get(observer_id).iata when packet.observer_iata absent, got: ' + html);
|
||||
}
|
||||
|
||||
// ── Behavior #4: packet.observer_iata WINS over observerMap (server-joined
|
||||
// field is authoritative; avoids per-row client lookup divergence) ──
|
||||
{
|
||||
ctx.observerMap.set('obs-mismatch', { name: 'Bar', iata: 'WRONG' });
|
||||
const html = obsIataBadge({ observer_id: 'obs-mismatch', observer_iata: 'MRY' });
|
||||
assert(html === '<span class="badge-iata">MRY</span>',
|
||||
'packet.observer_iata wins over observerMap value (got: ' + html + ')');
|
||||
}
|
||||
|
||||
// ── Behavior #5: null packet doesn't crash ──
|
||||
{
|
||||
const html = obsIataBadge(null);
|
||||
assert(html === '', 'null packet returns ""');
|
||||
}
|
||||
|
||||
// ── Behavior #6: HTML-escapes hostile IATA-like input ──
|
||||
{
|
||||
const html = obsIataBadge({ observer_iata: '<script>' });
|
||||
assert(!html.includes('<script>'),
|
||||
'raw <script> not present in output (escapeHtml applied), got: ' + html);
|
||||
assert(html.includes('<script>'),
|
||||
'output contains the escaped form <script>');
|
||||
}
|
||||
|
||||
// ── groupedObserverIataBadgesHtml (#1189 R1 UX): distinct-IATA set ──
|
||||
// Mesh-operator finding: grouped row must distinguish same-region (redundant)
|
||||
// from cross-region (interesting). Header pill now shows the DISTINCT IATA set.
|
||||
|
||||
// All same region: single badge, no +N
|
||||
{
|
||||
const p = { observer_iata: 'SJC', _children: [
|
||||
{ observer_iata: 'SJC' }, { observer_iata: 'SJC' }, { observer_iata: 'SJC' },
|
||||
]};
|
||||
const html = groupedObserverIataBadgesHtml(p);
|
||||
assert(html === '<span class="badge-iata">SJC</span>',
|
||||
'all-same-region group renders ONE badge with no +N, got: ' + html);
|
||||
}
|
||||
|
||||
// Two distinct regions: both badges visible
|
||||
{
|
||||
const p = { observer_iata: 'SJC', _children: [
|
||||
{ observer_iata: 'SFO' }, { observer_iata: 'SJC' },
|
||||
]};
|
||||
const html = groupedObserverIataBadgesHtml(p);
|
||||
assert(html.includes('SJC') && html.includes('SFO'),
|
||||
'two-region group renders both IATAs, got: ' + html);
|
||||
assert(!/\+\d/.test(html),
|
||||
'no +N suffix when all distinct IATAs fit in 2 visible slots, got: ' + html);
|
||||
}
|
||||
|
||||
// Three+ distinct regions: first 2 + +N of distinct-region count
|
||||
{
|
||||
const p = { observer_iata: 'SJC', _children: [
|
||||
{ observer_iata: 'SFO' }, { observer_iata: 'OAK' }, { observer_iata: 'MRY' },
|
||||
]};
|
||||
const html = groupedObserverIataBadgesHtml(p);
|
||||
const badges = (html.match(/badge-iata/g) || []).length;
|
||||
assert(badges === 2, 'shows 2 visible badges when distinct-region count > 2, got: ' + badges);
|
||||
assert(/\+2$/.test(html.trim()),
|
||||
'trailing +2 reflects distinct-region overflow count, got: ' + html);
|
||||
}
|
||||
|
||||
// Falls back to observerMap when packets lack observer_iata field
|
||||
{
|
||||
ctx.observerMap.clear();
|
||||
ctx.observerMap.set('obs-a', { name: 'A', iata: 'SJC' });
|
||||
ctx.observerMap.set('obs-b', { name: 'B', iata: 'SFO' });
|
||||
const p = { observer_id: 'obs-a', _children: [{ observer_id: 'obs-b' }] };
|
||||
const html = groupedObserverIataBadgesHtml(p);
|
||||
assert(html.includes('SJC') && html.includes('SFO'),
|
||||
'falls back to observerMap for missing observer_iata, got: ' + html);
|
||||
}
|
||||
|
||||
// No IATA anywhere → empty string
|
||||
{
|
||||
ctx.observerMap.clear();
|
||||
const html = groupedObserverIataBadgesHtml({ observer_id: 'unknown', _children: [{ observer_id: 'unknown2' }] });
|
||||
assert(html === '',
|
||||
'no IATA known anywhere → empty string, got: ' + html);
|
||||
}
|
||||
|
||||
// #1189 R2: default collapsed view (no _children) must use server-provided
|
||||
// distinct_iatas. This is the regression that R1 didn't fix.
|
||||
{
|
||||
ctx.observerMap.clear();
|
||||
// Group as it arrives in /api/packets?groupByHash=true — _children is
|
||||
// EMPTY (not populated until the user expands the row).
|
||||
const p = { observer_iata: 'SJC', distinct_iatas: ['SJC', 'SFO'] };
|
||||
const html = groupedObserverIataBadgesHtml(p);
|
||||
assert(html.includes('SJC') && html.includes('SFO'),
|
||||
'collapsed view with distinct_iatas=[SJC,SFO] renders both badges, got: ' + html);
|
||||
}
|
||||
{
|
||||
ctx.observerMap.clear();
|
||||
const p = { observer_iata: 'SJC', distinct_iatas: ['SJC', 'SFO', 'OAK', 'MRY'] };
|
||||
const html = groupedObserverIataBadgesHtml(p);
|
||||
assert(/\+2$/.test(html.trim()),
|
||||
'collapsed view with 4 distinct IATAs renders 2 visible + "+2", got: ' + html);
|
||||
}
|
||||
{
|
||||
ctx.observerMap.clear();
|
||||
const p = { observer_iata: 'SJC', distinct_iatas: ['SJC'] };
|
||||
const html = groupedObserverIataBadgesHtml(p);
|
||||
assert(html === '<span class="badge-iata">SJC</span>',
|
||||
'all-same-region collapsed view renders ONE badge with no +N, got: ' + html);
|
||||
}
|
||||
|
||||
console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
@@ -188,5 +188,49 @@ test('unclosed quote → error', () => {
|
||||
assert(c.error !== null, 'should have error');
|
||||
});
|
||||
|
||||
// --- Observer IATA filter field (#1188) ---
|
||||
const iataPkt = { ...pkt, observer_iata: 'SJC' };
|
||||
const sfoPkt = { ...pkt, observer_iata: 'SFO' };
|
||||
const noIataPkt = { ...pkt, observer_iata: null };
|
||||
|
||||
test('observer_iata == "SJC" matches', () => {
|
||||
assert(PF.compile('observer_iata == "SJC"').filter(iataPkt));
|
||||
});
|
||||
test('observer_iata == "SJC" case-insensitive', () => {
|
||||
assert(PF.compile('observer_iata == "sjc"').filter(iataPkt));
|
||||
});
|
||||
test('observer_iata == "SFO" does not match SJC packet', () => {
|
||||
assert(!PF.compile('observer_iata == "SFO"').filter(iataPkt));
|
||||
});
|
||||
test('iata alias works like observer_iata', () => {
|
||||
assert(PF.compile('iata == "SJC"').filter(iataPkt));
|
||||
assert(!PF.compile('iata == "LAX"').filter(iataPkt));
|
||||
});
|
||||
test('observer_iata in ("SJC","SFO") matches both', () => {
|
||||
assert(PF.compile('observer_iata in ("SJC","SFO")').filter(iataPkt));
|
||||
assert(PF.compile('observer_iata in ("SJC","SFO")').filter(sfoPkt));
|
||||
});
|
||||
test('iata in ("LAX","OAK") does not match SJC', () => {
|
||||
assert(!PF.compile('iata in ("LAX","OAK")').filter(iataPkt));
|
||||
});
|
||||
test('observer_iata contains "S"', () => {
|
||||
assert(PF.compile('observer_iata contains "S"').filter(iataPkt));
|
||||
assert(!PF.compile('observer_iata contains "Z"').filter(iataPkt));
|
||||
});
|
||||
test('missing observer_iata → no match (not parse error)', () => {
|
||||
const c = PF.compile('observer_iata == "SJC"');
|
||||
assert(c.error === null, 'should parse with no error');
|
||||
assert(!c.filter(noIataPkt), 'should not match when iata absent');
|
||||
});
|
||||
test('combined: type == ADVERT && iata == "SJC"', () => {
|
||||
const advIataPkt = { ...iataPkt, payload_type: 4 };
|
||||
assert(PF.compile('type == ADVERT && iata == "SJC"').filter(advIataPkt));
|
||||
});
|
||||
test('observer_iata and iata appear in suggest field list', () => {
|
||||
const names = PF.FIELDS.map(f => f.name);
|
||||
assert(names.indexOf('observer_iata') !== -1, 'observer_iata in FIELDS');
|
||||
assert(names.indexOf('iata') !== -1, 'iata in FIELDS');
|
||||
});
|
||||
|
||||
console.log(`\n=== Results: ${pass} passed, ${fail} failed ===`);
|
||||
process.exit(fail > 0 ? 1 : 0);
|
||||
|
||||
Reference in New Issue
Block a user