Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a0d76fa0c | ||
|
|
bd5f9c0746 | ||
|
|
29591c5f0c | ||
|
|
01bcf256e2 |
@@ -6370,9 +6370,75 @@ func (a *App) lookupCallsign(callsign string, force bool) (lookup.Result, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
a.enrichFromULS(&r, callsign)
|
||||
return r, err
|
||||
}
|
||||
|
||||
// enrichFromULS fills a US station's state, county and grid from the offline FCC
|
||||
// database while the operator is still typing.
|
||||
//
|
||||
// The ULS data was only ever applied at SAVE time (applyULSCounty), so an
|
||||
// operator with the database downloaded and no QRZ.com/HamQTH account saw
|
||||
// nothing but cty.dat during entry — country, zones, and a 4-character grid that
|
||||
// is the ENTITY centroid, thousands of km off. The county and a 6-character grid
|
||||
// were stamped silently after logging, too late to steer an antenna by.
|
||||
//
|
||||
// It runs LAST and only fills blanks, so an online provider always wins: ULS
|
||||
// holds no name and no address, so it can complete a QRZ record but never
|
||||
// replace one. The grid goes through refineGrid, which accepts the ULS square
|
||||
// only when it EXTENDS what is already there (JN → JN05JG) and never when it
|
||||
// would contradict it.
|
||||
func (a *App) enrichFromULS(r *lookup.Result, callsign string) {
|
||||
if a.uls == nil {
|
||||
return
|
||||
}
|
||||
switch r.DXCC {
|
||||
case 291, 110, 6: // United States, Hawaii, Alaska
|
||||
default:
|
||||
return
|
||||
}
|
||||
call := strings.ToUpper(strings.TrimSpace(callsign))
|
||||
if r.Callsign != "" {
|
||||
call = strings.ToUpper(strings.TrimSpace(r.Callsign))
|
||||
}
|
||||
if call == "" || strings.Contains(call, "/") {
|
||||
// A portable call is not what the FCC licensed — W1AW/4 is not a row.
|
||||
return
|
||||
}
|
||||
loc, ok := a.uls.Resolve(call)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(r.State) == "" {
|
||||
r.State = loc.State
|
||||
}
|
||||
if strings.TrimSpace(r.County) == "" {
|
||||
r.County = loc.CNTY()
|
||||
}
|
||||
// Grid. refineGrid keeps the ULS square when it extends what is there. It
|
||||
// does NOT cover the case this feature exists for: cty.dat's 4-character
|
||||
// square is the ENTITY centroid, so for a US call it is usually a different
|
||||
// square altogether — refineGrid would keep it, and the operator would go on
|
||||
// pointing an antenna at the middle of the country. A per-callsign ULS square
|
||||
// beats any 4-character one, whatever its letters; a 6-character grid already
|
||||
// present (QRZ) is left alone.
|
||||
newGrid := ""
|
||||
if g := refineGrid(r.Grid, loc.Grid); g != "" && g != r.Grid {
|
||||
newGrid = g
|
||||
} else if len(strings.TrimSpace(r.Grid)) <= 4 && len(strings.TrimSpace(loc.Grid)) >= 6 {
|
||||
newGrid = loc.Grid
|
||||
}
|
||||
if newGrid != "" {
|
||||
r.Grid = newGrid
|
||||
// Lat/lon follow, or distance and azimuth would still be computed from the
|
||||
// centroid the grid no longer says. Only when WE changed the grid: a
|
||||
// provider's own coordinates are more precise than a square's centre.
|
||||
if lat, lon, ok := gridToLatLon(newGrid); ok {
|
||||
r.Lat, r.Lon = lat, lon
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// OpenExternalURL opens a URL in the user's default browser. Wails ships
|
||||
// runtime.BrowserOpenURL for exactly this — used by the QRZ.com icon
|
||||
// next to the callsign field, the future Clublog/HamQTH shortcuts, etc.
|
||||
|
||||
@@ -1,4 +1,20 @@
|
||||
[
|
||||
{
|
||||
"version": "0.21.9",
|
||||
"date": "2026-07-28",
|
||||
"en": [
|
||||
"The Icom model list gains the IC-7300MKII (CI-V B6), plus the IC-7100, IC-7410, IC-7600 and IC-7851. The IC-7700 and IC-7800 were listed at the IC-7100's and IC-7410's addresses, so picking them set an address the rig never answers on.",
|
||||
"Icom over the LAN: the frequency no longer stays frozen after another program (WSJT-X through OmniRig, the Remote Utility) takes the CI-V session. The rig kept answering pings while sending nothing, so OpsLog showed the last known frequency until it was restarted; it now reconnects on its own.",
|
||||
"The offline FCC (ULS) database now answers while you type a US callsign, filling state, county and a 6-character grid. It was only applied when the QSO was saved, so without a QRZ.com or HamQTH account you saw nothing but the country and a coarse grid. Online lookups still win — ULS only fills what is blank.",
|
||||
"CQ and ITU zones you correct by hand in your profile stay corrected. They are derived from cty.dat, which gives the zones of the whole entity — in a country spanning several, the automatic value is wrong and it came back at every restart. They now only fill in when empty, and recompute when the callsign or grid changes."
|
||||
],
|
||||
"fr": [
|
||||
"La liste des modèles Icom gagne l'IC-7300MKII (CI-V B6), ainsi que les IC-7100, IC-7410, IC-7600 et IC-7851. Les IC-7700 et IC-7800 y figuraient avec les adresses des IC-7100 et IC-7410 : les choisir réglait une adresse sur laquelle la radio ne répond jamais.",
|
||||
"Icom via le réseau : la fréquence ne reste plus figée après qu'un autre programme (WSJT-X via OmniRig, la Remote Utility) a pris la session CI-V. La radio continuait de répondre aux pings sans rien émettre, si bien qu'OpsLog affichait la dernière fréquence connue jusqu'à son redémarrage ; il se reconnecte désormais tout seul.",
|
||||
"La base FCC (ULS) hors ligne répond désormais pendant la saisie d'un indicatif américain, en remplissant l'état, le comté et un locator 6 caractères. Elle n'était appliquée qu'à l'enregistrement du QSO : sans compte QRZ.com ou HamQTH, vous ne voyiez que le pays et un locator approximatif. Les recherches en ligne restent prioritaires — l'ULS ne comble que ce qui est vide.",
|
||||
"Les zones CQ et ITU corrigées à la main dans votre profil restent corrigées. Elles proviennent de cty.dat, qui donne les zones de l'entité entière — dans un pays qui en couvre plusieurs, la valeur automatique est fausse et revenait à chaque redémarrage. Elles ne se remplissent désormais que si le champ est vide, et se recalculent quand l'indicatif ou le locator change."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.21.8",
|
||||
"date": "2026-07-28",
|
||||
|
||||
@@ -1006,12 +1006,20 @@ function FlexBandAntennasPanel({ bands }: { bands: string[] }) {
|
||||
// model sets icom_addr so the backend identifies it (civ.ModelName) and the UI
|
||||
// adapts (e.g. the attenuator steps differ by model). Keep in lockstep with
|
||||
// civ.ModelName in internal/cat/civ/civ.go. `addr` is decimal.
|
||||
// The IC-7700 and IC-7800 were listed at 0x88 and 0x80 — which are the IC-7100's
|
||||
// and the IC-7410's. Picking one of them set an address the rig never answers on,
|
||||
// and the backend then named it as the other model.
|
||||
const ICOM_MODELS: { name: string; addr: number }[] = [
|
||||
{ name: 'IC-705', addr: 0xA4 },
|
||||
{ name: 'IC-7100', addr: 0x88 },
|
||||
{ name: 'IC-7300', addr: 0x94 },
|
||||
{ name: 'IC-7300MKII', addr: 0xB6 },
|
||||
{ name: 'IC-7410', addr: 0x80 },
|
||||
{ name: 'IC-7600', addr: 0x7A },
|
||||
{ name: 'IC-7610', addr: 0x98 },
|
||||
{ name: 'IC-7700', addr: 0x88 },
|
||||
{ name: 'IC-7800', addr: 0x80 },
|
||||
{ name: 'IC-7700', addr: 0x74 },
|
||||
{ name: 'IC-7800', addr: 0x6A },
|
||||
{ name: 'IC-7851', addr: 0x8E },
|
||||
{ name: 'IC-9100', addr: 0x7C },
|
||||
{ name: 'IC-9700', addr: 0xA2 },
|
||||
];
|
||||
@@ -1483,27 +1491,49 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
return () => { off(); };
|
||||
}, []);
|
||||
|
||||
// Which (profile, callsign, grid) the auto-fill below last derived from.
|
||||
// Without it, simply OPENING the settings counted as "the source changed" and
|
||||
// overwrote the operator's own values.
|
||||
const derivedFrom = useRef<string>('');
|
||||
|
||||
// Auto-fill the active profile's MY_* DXCC metadata from the station
|
||||
// callsign (country, DXCC#, CQ/ITU zones) and the grid (lat/lon). These
|
||||
// are derived values, so they always recompute when the callsign or grid
|
||||
// changes — the user can still edit a field, it just re-populates when the
|
||||
// source changes. Debounced so we don't hammer cty.dat while typing.
|
||||
// callsign (country, DXCC#, CQ/ITU zones) and the grid (lat/lon).
|
||||
//
|
||||
// These are derived values, so they recompute when the callsign or grid
|
||||
// changes. What they must NOT do is recompute on a plain load: cty.dat gives
|
||||
// the zones of the ENTITY, and a large country spans several — an operator in
|
||||
// CQ 14 / ITU 27 was handed 15 and 28 and corrected them by hand, and every
|
||||
// restart put the wrong pair back. cty.dat is a starting point, not an
|
||||
// authority, so on a load we only fill fields that are EMPTY; a value the
|
||||
// operator typed stays until the callsign or grid itself changes.
|
||||
// Debounced so we don't hammer cty.dat while typing.
|
||||
useEffect(() => {
|
||||
const call = (activeProfile?.callsign ?? '').trim();
|
||||
if (!call) return;
|
||||
const grid = (activeProfile?.my_grid ?? '').trim();
|
||||
const source = `${activeProfile?.id ?? 0}|${call.toUpperCase()}|${grid.toUpperCase()}`;
|
||||
// First sight of this profile — a load, not an edit.
|
||||
const isLoad = derivedFrom.current === '' || derivedFrom.current.split('|')[0] !== String(activeProfile?.id ?? 0);
|
||||
const t = window.setTimeout(async () => {
|
||||
try {
|
||||
const i: any = await ComputeStationInfo(call, grid);
|
||||
derivedFrom.current = source;
|
||||
setActiveProfile((p) => {
|
||||
if (!p) return p;
|
||||
const patch: any = {};
|
||||
if (i.country) patch.my_country = i.country;
|
||||
if (i.dxcc) patch.my_dxcc = i.dxcc;
|
||||
if (i.cqz) patch.my_cqz = i.cqz;
|
||||
if (i.ituz) patch.my_ituz = i.ituz;
|
||||
if (i.lat) patch.my_lat = i.lat;
|
||||
if (i.lon) patch.my_lon = i.lon;
|
||||
// On a load, keep in patch only what the profile does not already have.
|
||||
const put = (k: string, v: any) => {
|
||||
if (!v) return;
|
||||
const cur = (p as any)[k];
|
||||
if (isLoad && cur !== undefined && cur !== null && cur !== '' && cur !== 0) return;
|
||||
patch[k] = v;
|
||||
};
|
||||
put('my_country', i.country);
|
||||
put('my_dxcc', i.dxcc);
|
||||
put('my_cqz', i.cqz);
|
||||
put('my_ituz', i.ituz);
|
||||
put('my_lat', i.lat);
|
||||
put('my_lon', i.lon);
|
||||
// Only re-render when a value actually changed (prevents loops).
|
||||
const changed = Object.keys(patch).some((k) => (p as any)[k] !== patch[k]);
|
||||
return changed ? { ...p, ...patch } : p;
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"hamlog/internal/cat/civ"
|
||||
)
|
||||
|
||||
// The Icom model picker in the settings and civ.ModelName are two hand-kept
|
||||
// copies of the same table, and they had drifted: the UI offered the IC-7700 at
|
||||
// 0x88 and the IC-7800 at 0x80, which are the IC-7100's and the IC-7410's
|
||||
// factory addresses. Picking either set an address the rig never answers on —
|
||||
// the symptom is a rig that simply never replies — and the backend then named it
|
||||
// as the other model.
|
||||
//
|
||||
// The list is read out of the .tsx itself, so a model added on one side alone
|
||||
// fails here rather than on someone's radio.
|
||||
func TestIcomModelAddressesMatch(t *testing.T) {
|
||||
src, err := os.ReadFile("frontend/src/components/SettingsModal.tsx")
|
||||
if err != nil {
|
||||
t.Skipf("frontend source not available: %v", err)
|
||||
}
|
||||
re := regexp.MustCompile(`\{ name: '(IC-[A-Za-z0-9-]+)', addr: 0x([0-9A-Fa-f]{2}) \}`)
|
||||
ms := re.FindAllStringSubmatch(string(src), -1)
|
||||
if len(ms) < 5 {
|
||||
t.Fatalf("only %d models found — the parse is wrong, not the data", len(ms))
|
||||
}
|
||||
for _, m := range ms {
|
||||
name := m[1]
|
||||
addr, err := strconv.ParseUint(m[2], 16, 8)
|
||||
if err != nil {
|
||||
t.Fatalf("bad address %q for %s", m[2], name)
|
||||
}
|
||||
if got := civ.ModelName(byte(addr)); got != name {
|
||||
t.Errorf("settings offer %s at 0x%02X, but civ.ModelName(0x%02X) = %q",
|
||||
name, addr, addr, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -344,6 +344,8 @@ func ModelName(addr byte) string {
|
||||
return "IC-9700"
|
||||
case 0xA4:
|
||||
return "IC-705"
|
||||
case 0xB6:
|
||||
return "IC-7300MKII"
|
||||
}
|
||||
return fmt.Sprintf("Icom (0x%02X)", addr)
|
||||
}
|
||||
|
||||
@@ -152,6 +152,7 @@ func TestModelNameAddresses(t *testing.T) {
|
||||
0x98: "IC-7610",
|
||||
0xA2: "IC-9700",
|
||||
0xA4: "IC-705",
|
||||
0xB6: "IC-7300MKII",
|
||||
} {
|
||||
if got := ModelName(addr); got != want {
|
||||
t.Errorf("ModelName(0x%02X) = %q, want %q", addr, got, want)
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package cat
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The rule that decides whether a silent-but-alive Icom network session is
|
||||
// tolerated or torn down. It was "always tolerate", and that is what froze the
|
||||
// frequency display for ever when another program took the CI-V session.
|
||||
//
|
||||
// The decision is pinned here rather than left implicit, because the two cases
|
||||
// it separates pull in opposite directions: a rig in standby must NOT be
|
||||
// reconnected every few seconds (the panel and its ON button would blink away),
|
||||
// while a hijacked session must recover without restarting OpsLog.
|
||||
func TestIcomSilenceTolerance(t *testing.T) {
|
||||
// tolerate mirrors the condition in ReadState.
|
||||
tolerate := func(alive, readerGone bool, lastGood time.Time, silentFor, grace time.Duration) bool {
|
||||
return alive && !readerGone && (lastGood.IsZero() || silentFor < grace)
|
||||
}
|
||||
never := time.Time{}
|
||||
some := time.Now()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
alive bool
|
||||
readerGone bool
|
||||
lastGood time.Time
|
||||
silentFor time.Duration
|
||||
want bool
|
||||
}{
|
||||
{"never answered since connect — rig is off, keep the panel", true, false, never, time.Hour, true},
|
||||
{"brief silence during a band change", true, false, some, 2 * time.Second, true},
|
||||
{"silence past the grace — reconnect", true, false, some, icomSilentGrace + time.Second, false},
|
||||
{"reader goroutine gone — dead however alive the link looks", true, true, some, time.Second, false},
|
||||
{"control link itself dead", false, false, some, time.Second, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := tolerate(c.alive, c.readerGone, c.lastGood, c.silentFor, icomSilentGrace)
|
||||
if got != c.want {
|
||||
t.Errorf("%s: tolerate = %v, want %v", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The backoff must actually converge on the ceiling: a rig left switched off is
|
||||
// silent for hours, and a window that stayed at 30 s would re-tear its session
|
||||
// about a hundred times an hour.
|
||||
func TestIcomSilenceBackoff(t *testing.T) {
|
||||
g := icomSilentGrace
|
||||
for i := 0; i < 20; i++ {
|
||||
if g < icomSilentGraceMax {
|
||||
g *= 2
|
||||
}
|
||||
}
|
||||
if g < icomSilentGraceMax {
|
||||
t.Fatalf("backoff never reached the ceiling: %s < %s", g, icomSilentGraceMax)
|
||||
}
|
||||
if g > 2*icomSilentGraceMax {
|
||||
t.Errorf("backoff overshot the ceiling: %s", g)
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,19 @@ type aliveTransport interface {
|
||||
Alive() bool
|
||||
}
|
||||
|
||||
const (
|
||||
// How long the network backend accepts "the control link answers but the rig
|
||||
// sends no CI-V" before declaring the session lost. Long enough to cover a
|
||||
// band change or a rig booting from standby; short enough that an operator
|
||||
// whose CI-V session was stolen (WSJT-X via OmniRig, the Remote Utility) gets
|
||||
// a live frequency back on his own rather than restarting OpsLog.
|
||||
icomSilentGrace = 30 * time.Second
|
||||
// Ceiling for the backoff applied when the silence persists — a rig switched
|
||||
// OFF is silent for hours, and re-tearing its session every 30 s would blink
|
||||
// the panel (and its ON button) away continuously.
|
||||
icomSilentGraceMax = 4 * time.Minute
|
||||
)
|
||||
|
||||
// scopeTransport is an OPTIONAL transport capability: deliver spectrum-scope
|
||||
// (0x27) frames on a SEPARATE channel from control replies. The network transport
|
||||
// implements it so the continuous panadapter stream can't crowd control replies
|
||||
@@ -95,6 +108,10 @@ type IcomSerial struct {
|
||||
splitOn bool // last read split state (refreshed every few cycles)
|
||||
splitTXFreq int64 // last read unselected/TX VFO freq while in split
|
||||
readFails int // consecutive ReadState freq-read failures (transient tolerance)
|
||||
lastGoodAt time.Time // last SUCCESSFUL frequency read — bounds the network
|
||||
// "alive but silent" tolerance below, which used to be
|
||||
// unbounded and left the display frozen for ever
|
||||
silentGrace time.Duration // current width of that tolerance (backs off, see ReadState)
|
||||
dspLoaded bool // readDSP has run since the rig became responsive (loads all
|
||||
// the panel's set-once controls once the rig actually answers)
|
||||
lastSetFreq int64 // last frequency commanded (spot click: freq then mode)
|
||||
@@ -267,7 +284,34 @@ func (b *IcomSerial) ReadState() (RigState, error) {
|
||||
// rig is switched on) rather than tearing the whole UDP session down and
|
||||
// flapping every few seconds. The panel stays up so the ON button works.
|
||||
if at, ok := b.port.(aliveTransport); ok {
|
||||
if at.Alive() {
|
||||
// The reader goroutine is what feeds every CI-V reply. If it has
|
||||
// exited, no read can ever succeed again, however healthy the control
|
||||
// link looks — that is not a silent rig, it is a dead connection.
|
||||
readerGone := false
|
||||
if b.readerDone != nil {
|
||||
select {
|
||||
case <-b.readerDone:
|
||||
readerGone = true
|
||||
default:
|
||||
}
|
||||
}
|
||||
// "Alive but silent" is the rig in standby, or mid band-change. It was
|
||||
// tolerated for ever, and that is the freeze: when ANOTHER program takes
|
||||
// the CI-V session (WSJT-X through OmniRig, say) the rig keeps answering
|
||||
// pings on the control stream while sending us no CI-V at all. Alive()
|
||||
// stayed true, the cached frequency was re-published with a fresh
|
||||
// timestamp on every poll, and the operator saw a confident, frozen
|
||||
// number until OpsLog was restarted. Bounded now: past the grace period
|
||||
// the error is reported so the Manager tears the session down and
|
||||
// reconnects, which re-takes the CI-V stream.
|
||||
silentFor := time.Duration(0)
|
||||
if !b.lastGoodAt.IsZero() {
|
||||
silentFor = time.Since(b.lastGoodAt)
|
||||
}
|
||||
if b.silentGrace <= 0 {
|
||||
b.silentGrace = icomSilentGrace
|
||||
}
|
||||
if at.Alive() && !readerGone && (b.lastGoodAt.IsZero() || silentFor < b.silentGrace) {
|
||||
b.readFails = 0
|
||||
s.FreqHz = b.curFreq // 0 until the rig is powered on and first read
|
||||
if b.curModeByte != 0 {
|
||||
@@ -286,8 +330,25 @@ func (b *IcomSerial) ReadState() (RigState, error) {
|
||||
b.dspMu.Unlock()
|
||||
return s, nil
|
||||
}
|
||||
switch {
|
||||
case readerGone:
|
||||
debugLog.Printf("icom net: the CI-V reader has exited — the connection is dead however alive the control link looks → reconnecting")
|
||||
case at.Alive():
|
||||
debugLog.Printf("icom net: control link answers but no CI-V reply for %s → reconnecting. Another program (WSJT-X/OmniRig, the Remote Utility) has most likely taken the CI-V session.", silentFor.Round(time.Second))
|
||||
default:
|
||||
debugLog.Printf("icom net: control link went quiet (no rig packets for >6 s) → reconnecting. If this recurs every ~2-3 min, the rig is invalidating the session (token renewal rejected).")
|
||||
return RigState{}, err // control link dead → let the Manager reconnect
|
||||
}
|
||||
// Restart the clock, and widen the window each time it fires without a
|
||||
// good read in between. A hijacked session recovers on the first shot;
|
||||
// a rig simply switched OFF never will, and re-tearing its session every
|
||||
// 30 s would make the panel — and its ON button — blink away
|
||||
// continuously. Backing off to minutes keeps the standby case quiet
|
||||
// while still recovering on its own.
|
||||
b.lastGoodAt = time.Now()
|
||||
if b.silentGrace < icomSilentGraceMax {
|
||||
b.silentGrace *= 2
|
||||
}
|
||||
return RigState{}, err // let the Manager reconnect
|
||||
}
|
||||
// USB (no liveness signal): the rig briefly stops answering CI-V while it
|
||||
// switches band/VFO. Tolerate a few consecutive misses as transient — keep
|
||||
@@ -308,6 +369,8 @@ func (b *IcomSerial) ReadState() (RigState, error) {
|
||||
return RigState{}, err
|
||||
}
|
||||
b.readFails = 0
|
||||
b.lastGoodAt = time.Now()
|
||||
b.silentGrace = icomSilentGrace // the rig answers: back to the short window
|
||||
s.FreqHz = hz
|
||||
b.curFreq = hz
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"hamlog/internal/uls"
|
||||
)
|
||||
|
||||
// The ULS grid rules, which are NOT the same as refineGrid's.
|
||||
//
|
||||
// cty.dat answers a US call with the ENTITY centroid — a 4-character square in
|
||||
// the middle of the country. refineGrid keeps it (the ULS square is not an
|
||||
// extension of it, it is a different square), which is right for a QRZ grid and
|
||||
// wrong here: a per-callsign FCC square beats any entity centroid. But a
|
||||
// 6-character grid already present came from a provider and must survive.
|
||||
func TestULSGridPreference(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
have string // grid already on the result
|
||||
uls string // grid from the FCC database
|
||||
want string
|
||||
}{
|
||||
{"nothing known → take ULS", "", "EM12AB", "EM12AB"},
|
||||
{"cty.dat entity centroid → ULS wins", "EN90", "EM12AB", "EM12AB"},
|
||||
{"4-char square, same square → ULS extends it", "EM12", "EM12AB", "EM12AB"},
|
||||
{"provider gave 6 chars → untouched", "FN31PR", "EM12AB", "FN31PR"},
|
||||
{"ULS has only 4 chars, provider has 6 → untouched", "FN31PR", "EM12", "FN31PR"},
|
||||
{"ULS empty → untouched", "EN90", "", "EN90"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := c.have
|
||||
if g := refineGrid(got, c.uls); g != "" && g != got {
|
||||
got = g
|
||||
} else if len(got) <= 4 && len(c.uls) >= 6 {
|
||||
got = c.uls
|
||||
}
|
||||
if got != c.want {
|
||||
t.Errorf("%s: have=%q uls=%q → %q, want %q", c.name, c.have, c.uls, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CNTY is what gets written to the QSO's county field, so its shape is part of
|
||||
// the contract: ADIF wants "State,County" and nothing when either half is
|
||||
// missing — a lone county name would be an invalid CNTY on export.
|
||||
func TestULSCountyFormat(t *testing.T) {
|
||||
cases := []struct {
|
||||
loc uls.Location
|
||||
want string
|
||||
}{
|
||||
{uls.Location{State: "MA", County: "Middlesex"}, "MA,Middlesex"},
|
||||
{uls.Location{State: "", County: "Middlesex"}, ""},
|
||||
{uls.Location{State: "MA", County: ""}, ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := c.loc.CNTY(); got != c.want {
|
||||
t.Errorf("Location%+v.CNTY() = %q, want %q", c.loc, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user