Skip to content

Commit 776c06b

Browse files
committed
feat(webconfig): fold antenna location into the MLAT section
Latitude/longitude/altitude now live inside the MLAT control — revealed on enable, saved together in one apply. Adds verify-on-map and elevation links, and accepts feet for altitude (stored as canonical metres, shown in the viewer's unit).
1 parent 0e82735 commit 776c06b

4 files changed

Lines changed: 566 additions & 361 deletions

File tree

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
package clientvalidators
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"os/exec"
8+
"path/filepath"
9+
"strings"
10+
"testing"
11+
"time"
12+
)
13+
14+
// displayRunnerRel is the node harness that exercises the client-only
15+
// altitude display + locale helpers in app.js (see run_js_display.js).
16+
const displayRunnerRel = "testdata/run_js_display.js"
17+
18+
type localeCase struct {
19+
Languages []string
20+
Imperial bool
21+
}
22+
23+
type altDisplayCase struct {
24+
Metres string
25+
Imperial bool
26+
Output string
27+
}
28+
29+
// runJSDisplay invokes the display harness with the given cases and returns
30+
// the JS results keyed for lookup. Mirrors runJSValidators' node-subprocess
31+
// shape; kept separate so the validator-parity flow stays untouched.
32+
func runJSDisplay(t *testing.T, locales []localeCase, alts []altDisplayCase) (map[string]bool, map[string]string) {
33+
t.Helper()
34+
35+
type reqLocale struct {
36+
Languages []string `json:"languages"`
37+
}
38+
type reqAlt struct {
39+
Metres string `json:"metres"`
40+
Imperial bool `json:"imperial"`
41+
}
42+
req := struct {
43+
LocaleCases []reqLocale `json:"localeCases"`
44+
AltDisplayCases []reqAlt `json:"altDisplayCases"`
45+
}{}
46+
for _, c := range locales {
47+
req.LocaleCases = append(req.LocaleCases, reqLocale{Languages: c.Languages})
48+
}
49+
for _, c := range alts {
50+
req.AltDisplayCases = append(req.AltDisplayCases, reqAlt{Metres: c.Metres, Imperial: c.Imperial})
51+
}
52+
body, err := json.Marshal(req)
53+
if err != nil {
54+
t.Fatalf("marshal display request: %v", err)
55+
}
56+
57+
appJSPath, err := filepath.Abs(appJSRel)
58+
if err != nil {
59+
t.Fatalf("abs app.js: %v", err)
60+
}
61+
runnerPath, err := filepath.Abs(displayRunnerRel)
62+
if err != nil {
63+
t.Fatalf("abs runner: %v", err)
64+
}
65+
66+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
67+
defer cancel()
68+
cmd := exec.CommandContext(ctx, "node", runnerPath, appJSPath)
69+
cmd.Stdin = strings.NewReader(string(body))
70+
var stdout, stderr strings.Builder
71+
cmd.Stdout = &stdout
72+
cmd.Stderr = &stderr
73+
if err := cmd.Run(); err != nil {
74+
t.Fatalf("node display runner failed: %v; stderr: %s", err, stderr.String())
75+
}
76+
77+
var resp struct {
78+
LocaleResults []struct {
79+
Languages []string `json:"languages"`
80+
Imperial bool `json:"imperial"`
81+
} `json:"localeResults"`
82+
AltDisplayResults []struct {
83+
Metres string `json:"metres"`
84+
Imperial bool `json:"imperial"`
85+
Output string `json:"output"`
86+
} `json:"altDisplayResults"`
87+
Errors []string `json:"errors"`
88+
}
89+
if err := json.Unmarshal([]byte(stdout.String()), &resp); err != nil {
90+
t.Fatalf("decode node output: %v; raw: %q", err, stdout.String())
91+
}
92+
if len(resp.Errors) > 0 {
93+
t.Fatalf("display runner reported errors: %v", resp.Errors)
94+
}
95+
96+
localeOut := make(map[string]bool, len(resp.LocaleResults))
97+
for _, r := range resp.LocaleResults {
98+
localeOut[strings.Join(r.Languages, ",")] = r.Imperial
99+
}
100+
altOut := make(map[string]string, len(resp.AltDisplayResults))
101+
for _, r := range resp.AltDisplayResults {
102+
altOut[altKey(r.Metres, r.Imperial)] = r.Output
103+
}
104+
return localeOut, altOut
105+
}
106+
107+
func altKey(metres string, imperial bool) string {
108+
return fmt.Sprintf("%s|%t", metres, imperial)
109+
}
110+
111+
// TestImperialLengthFromLanguages pins the *-us region rule the altitude
112+
// field uses to pick its display unit, mirroring the server-side
113+
// tempUnitFromAcceptLanguage contract.
114+
func TestImperialLengthFromLanguages(t *testing.T) {
115+
cases := []localeCase{
116+
{Languages: []string{"en-US", "en"}, Imperial: true},
117+
{Languages: []string{"es-US"}, Imperial: true},
118+
{Languages: []string{"en-GB", "en"}, Imperial: false},
119+
{Languages: []string{"de-DE"}, Imperial: false},
120+
{Languages: []string{"en", "en-US"}, Imperial: true}, // bare tag skipped, then -us wins
121+
{Languages: []string{"en", "de-DE"}, Imperial: false}, // bare skipped, first regional is metric
122+
{Languages: []string{"en"}, Imperial: false}, // bare only → default metric
123+
{Languages: []string{"*"}, Imperial: false}, // wildcard ignored → default metric
124+
{Languages: []string{}, Imperial: false}, // empty → default metric
125+
{Languages: []string{"fr-FR", "en-US"}, Imperial: false}, // first regional (metric) wins
126+
}
127+
gotLocale, _ := runJSDisplay(t, cases, nil)
128+
for _, c := range cases {
129+
key := strings.Join(c.Languages, ",")
130+
if got := gotLocale[key]; got != c.Imperial {
131+
t.Errorf("imperialLengthFromLanguages(%v) = %t, want %t", c.Languages, got, c.Imperial)
132+
}
133+
}
134+
}
135+
136+
// TestAltitudeDisplayValue pins how a canonical bare-metres value renders in
137+
// the altitude field: metric always "<m>m"; imperial shows "<ft>ft" only
138+
// when it round-trips exactly back to the stored metres, otherwise "<m>m".
139+
func TestAltitudeDisplayValue(t *testing.T) {
140+
cases := []altDisplayCase{
141+
// Metric viewer: always metres, unit suffixed.
142+
{Metres: "3.6576", Imperial: false, Output: "3.6576m"},
143+
{Metres: "20", Imperial: false, Output: "20m"},
144+
{Metres: "", Imperial: false, Output: ""},
145+
// Imperial viewer, exact whole-feet conversions → feet.
146+
{Metres: "3.6576", Imperial: true, Output: "12ft"}, // 12 ft
147+
{Metres: "19.812", Imperial: true, Output: "65ft"}, // 65 ft
148+
{Metres: "121.92", Imperial: true, Output: "400ft"}, // 400 ft
149+
{Metres: "0", Imperial: true, Output: "0ft"},
150+
// Imperial viewer, value not a whole number of feet → stays metres.
151+
{Metres: "20", Imperial: true, Output: "20m"}, // 65.6168 ft, not exact
152+
{Metres: "42.5", Imperial: true, Output: "42.5m"},
153+
// Empty passthrough regardless of locale.
154+
{Metres: "", Imperial: true, Output: ""},
155+
}
156+
_, gotAlt := runJSDisplay(t, nil, cases)
157+
for _, c := range cases {
158+
if got := gotAlt[altKey(c.Metres, c.Imperial)]; got != c.Output {
159+
t.Errorf("altitudeDisplayValue(%q, %t) = %q, want %q", c.Metres, c.Imperial, got, c.Output)
160+
}
161+
}
162+
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
// run_js_display.js — pins the client-only altitude display + locale
2+
// helpers shipped in web/assets/app.js. Separate from run_js_validators.js
3+
// (which covers the JS↔bash validator parity): these two functions have no
4+
// bash twin, they just need their behaviour locked from drift on the Go
5+
// side. Extracts the same /* @validator-parity start … end */ block (where
6+
// altitudeToBareMetres, which altitudeDisplayValue builds on, lives) and
7+
// exercises imperialLengthFromLanguages + altitudeDisplayValue.
8+
//
9+
// Protocol (stdin → stdout, both JSON):
10+
// in: { "localeCases": [{"languages": [<str>...]}],
11+
// "altDisplayCases": [{"metres": <str>, "imperial": <bool>}] }
12+
// out: { "localeResults": [{"languages": [...], "imperial": <bool>}],
13+
// "altDisplayResults": [{"metres": <str>, "imperial": <bool>, "output": <str>}],
14+
// "errors": [<str>...] }
15+
"use strict";
16+
17+
const fs = require("fs");
18+
const os = require("os");
19+
const path = require("path");
20+
21+
function die(message) {
22+
process.stderr.write(message + "\n");
23+
process.exit(2);
24+
}
25+
26+
if (process.argv.length < 3) {
27+
die("usage: node run_js_display.js <path-to-app.js>");
28+
}
29+
30+
let text;
31+
try {
32+
text = fs.readFileSync(process.argv[2], "utf8");
33+
} catch (e) {
34+
die("read app.js: " + (e && e.message ? e.message : String(e)));
35+
}
36+
37+
const START = "/* @validator-parity start */";
38+
const END = "/* @validator-parity end */";
39+
const startIdx = text.indexOf(START);
40+
const endIdx = text.indexOf(END);
41+
if (startIdx === -1 || endIdx === -1 || endIdx < startIdx) {
42+
die("parity markers missing or out of order in " + process.argv[2]);
43+
}
44+
const block = text.slice(startIdx + START.length, endIdx);
45+
46+
const exportedNames = ["imperialLengthFromLanguages", "altitudeDisplayValue"];
47+
const moduleSource = '"use strict";\n' + block + "\n" +
48+
"module.exports = { " + exportedNames.join(", ") + " };\n";
49+
50+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "display-parity-"));
51+
const tmpModulePath = path.join(tmpDir, "block.js");
52+
let mod;
53+
try {
54+
fs.writeFileSync(tmpModulePath, moduleSource);
55+
mod = require(tmpModulePath);
56+
} catch (e) {
57+
die("load display block: " + (e && e.message ? e.message : String(e)));
58+
} finally {
59+
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) { /* best effort */ }
60+
}
61+
62+
let request;
63+
try {
64+
request = JSON.parse(fs.readFileSync(0, "utf8"));
65+
} catch (e) {
66+
die("parse stdin JSON: " + (e && e.message ? e.message : String(e)));
67+
}
68+
69+
const out = { localeResults: [], altDisplayResults: [], errors: [] };
70+
71+
for (const c of request.localeCases || []) {
72+
try {
73+
out.localeResults.push({
74+
languages: c.languages,
75+
imperial: Boolean(mod.imperialLengthFromLanguages(c.languages)),
76+
});
77+
} catch (e) {
78+
out.errors.push("imperialLengthFromLanguages(" + JSON.stringify(c.languages) + "): " + (e && e.message ? e.message : String(e)));
79+
out.localeResults.push({ languages: c.languages, imperial: false });
80+
}
81+
}
82+
83+
for (const c of request.altDisplayCases || []) {
84+
try {
85+
out.altDisplayResults.push({
86+
metres: c.metres,
87+
imperial: Boolean(c.imperial),
88+
output: String(mod.altitudeDisplayValue(c.metres, c.imperial)),
89+
});
90+
} catch (e) {
91+
out.errors.push("altitudeDisplayValue(" + JSON.stringify(c.metres) + "," + JSON.stringify(c.imperial) + "): " + (e && e.message ? e.message : String(e)));
92+
out.altDisplayResults.push({ metres: c.metres, imperial: Boolean(c.imperial), output: "" });
93+
}
94+
}
95+
96+
process.stdout.write(JSON.stringify(out));

0 commit comments

Comments
 (0)