fix: Bug showing split for flex while in different mode on same band

This commit is contained in:
2026-07-10 15:59:32 +02:00
parent 96390110f8
commit 6ab606fa54
3 changed files with 130 additions and 9 deletions
+43
View File
@@ -165,6 +165,49 @@ func TestComputeOrRuleFallback(t *testing.T) {
} }
} }
// The user's WAPC cascade: (1) province NAME in QTH, else (2) province NAME in
// ADDRESS, else (3) match-by-pattern in QTH, which runs each reference's OWN
// regex (city lists). Confirms the ordered fallback + that a match-by-pattern
// rule with an empty rule-pattern triggers the per-reference regexes.
func TestComputeProvinceCascade(t *testing.T) {
def := Def{Code: "WAPC", Type: TypeQSOFields, Field: "qth", MatchBy: "description",
DXCCFilter: []int{318}, Valid: true,
OrRules: []OrRule{
{Field: "address", MatchBy: "description"},
{Field: "qth", MatchBy: "pattern"}, // empty pattern → per-reference regexes
},
}
refMetas := map[string][]RefMeta{"WAPC": {
{Code: "JS", Name: "Jiangsu", Pattern: `\bJiangyin\b`, Valid: true},
{Code: "ZJ", Name: "Zhejiang", Pattern: `\bHangzhou\b`, Valid: true},
}}
worked := func(q qso.QSO) []string {
r := Compute([]Def{def}, []qso.QSO{q}, refMetas, nil)[0]
var out []string
for _, rf := range r.Refs {
if rf.Worked {
out = append(out, rf.Ref)
}
}
return out
}
cases := []struct {
name, qth, addr string
want string
}{
{"name in qth", "Wuxi, Jiangsu", "", "JS"},
{"name in address", "Wuxi", "Jiangsu Province", "JS"},
{"city regex (Jiangyin) in qth", "Jiangyin", "", "JS"},
{"city regex (Hangzhou) in qth", "Hangzhou", "", "ZJ"},
}
for _, c := range cases {
got := worked(qso.QSO{Callsign: "BA1A", DXCC: ip(318), QTH: c.qth, Address: c.addr})
if len(got) != 1 || got[0] != c.want {
t.Errorf("%s: got %v, want [%s]", c.name, got, c.want)
}
}
}
// A manually-assigned override (ManualRefsKey) must count in Compute — not just // A manually-assigned override (ManualRefsKey) must count in Compute — not just
// MatchQSO — so a custom award like WAPC (matches ADDRESS by description, writes // MatchQSO — so a custom award like WAPC (matches ADDRESS by description, writes
// no QSO field) sticks after the operator picks the province by hand. The engine // no QSO field) sticks after the operator picks the province by hand. The engine
+38 -9
View File
@@ -919,9 +919,11 @@ func (f *Flex) txSliceLocked() *flexSlice {
// operatingLocked resolves the operator's slices: the MAIN (active) slice for the // operatingLocked resolves the operator's slices: the MAIN (active) slice for the
// mode/display, and — ONLY for a GENUINE split — the RX and TX slices. Split is // mode/display, and — ONLY for a GENUINE split — the RX and TX slices. Split is
// the tx-flagged slice PLUS a distinct in-use slice on the SAME band (different // the tx-flagged slice PLUS a distinct in-use slice on the SAME band (different
// freq) — detected from the pair itself, NOT from which slice is active (the TX // freq) AND the SAME split class (both PHONE, or both CW) — detected from the
// slice often steals focus right after "slice create", which must NOT read as // pair itself, NOT from which slice is active (the TX slice often steals focus
// "no split"). A slice on another band is an independent receiver, ignored. // right after "slice create", which must NOT read as "no split"). A slice on
// another band is an independent receiver, ignored; so is a same-band slice in a
// different mode (SSB + FT8) or a data mode (FT8/FT4/RTTY never split).
// Caller holds f.mu. // Caller holds f.mu.
func (f *Flex) operatingLocked() (main, rx, tx *flexSlice) { func (f *Flex) operatingLocked() (main, rx, tx *flexSlice) {
_, main = f.mainSliceLocked() _, main = f.mainSliceLocked()
@@ -933,21 +935,31 @@ func (f *Flex) operatingLocked() (main, rx, tx *flexSlice) {
if bt == "" { if bt == "" {
return main, nil, nil return main, nil, nil
} }
// RX = the active slice when it's a distinct same-band slice, else the first // Split only applies to PHONE/CW; a data-mode TX slice never splits.
// other in-use same-band slice. ct := flexSplitClass(txS.mode)
if main != nil && main != txS && main.freqHz != txS.freqHz && BandFromHz(main.freqHz) == bt { if ct == "" {
return main, nil, nil
}
// A split partner is an in-use, same-band slice at a different freq in the
// SAME split class (so SSB + FT8 on one band is NOT a split).
sameSplit := func(s *flexSlice) bool {
return s != nil && s.inUse && s != txS && s.freqHz != txS.freqHz &&
BandFromHz(s.freqHz) == bt && flexSplitClass(s.mode) == ct
}
// RX = the active slice when it qualifies, else the first other qualifying
// same-band slice.
if sameSplit(main) {
rx = main rx = main
} else { } else {
for _, idx := range f.sortedSliceIdxLocked() { for _, idx := range f.sortedSliceIdxLocked() {
s := f.slices[idx] if s := f.slices[idx]; sameSplit(s) {
if s.inUse && s != txS && s.freqHz != txS.freqHz && BandFromHz(s.freqHz) == bt {
rx = s rx = s
break break
} }
} }
} }
if rx == nil { if rx == nil {
return main, nil, nil // tx slice alone (simplex) → not split return main, nil, nil // tx slice alone (simplex) or no same-mode partner → not split
} }
return main, rx, txS return main, rx, txS
} }
@@ -1921,6 +1933,23 @@ func parseFloatDefault(s string, def float64) float64 {
} }
// flexModeToADIF maps a Flex slice mode to a generic ADIF mode. // flexModeToADIF maps a Flex slice mode to a generic ADIF mode.
// flexSplitClass groups a raw Flex slice mode into a split-capable class. A
// genuine split (one RX freq, one TX freq, SAME mode) only makes sense for PHONE
// and CW pileups. Data modes (FT8/FT4/RTTY all report as DIGU/DIGL/RTTY on a
// Flex) and digital voice never operate split, so they return "" (not
// split-capable). Two same-band slices whose classes differ (e.g. SSB + FT8) are
// independent operations, not a split.
func flexSplitClass(rawMode string) string {
switch flexModeToADIF(rawMode) {
case "SSB", "AM", "FM":
return "PHONE"
case "CW":
return "CW"
default: // DATA, RTTY, DIGITALVOICE, "" → never split
return ""
}
}
func flexModeToADIF(m string) string { func flexModeToADIF(m string) string {
switch strings.ToUpper(strings.TrimSpace(m)) { switch strings.ToUpper(strings.TrimSpace(m)) {
case "USB", "LSB": case "USB", "LSB":
+49
View File
@@ -0,0 +1,49 @@
package cat
import "testing"
// A genuine split is two same-band slices in the SAME split class (both PHONE or
// both CW). Two same-band slices in different modes (SSB + FT8), or in a data
// mode (FT8/FT4/RTTY = DIGU/DIGL), must NOT read as split.
func TestOperatingSplitMode(t *testing.T) {
mk := func(hz int64, mode string, active, tx bool) *flexSlice {
return &flexSlice{freqHz: hz, mode: mode, active: active, tx: tx, inUse: true}
}
cases := []struct {
name string
slices map[int]*flexSlice
wantSplit bool
}{
{"phone split (both USB)", map[int]*flexSlice{
0: mk(14_100_000, "USB", true, false),
1: mk(14_200_000, "USB", false, true),
}, true},
{"CW split (both CW)", map[int]*flexSlice{
0: mk(7_010_000, "CW", true, false),
1: mk(7_025_000, "CW", false, true),
}, true},
{"SSB + FT8 same band → not split", map[int]*flexSlice{
0: mk(14_100_000, "USB", true, false),
1: mk(14_074_000, "DIGU", false, true),
}, false},
{"both FT8 (DIGU) same band → not split", map[int]*flexSlice{
0: mk(14_074_000, "DIGU", true, false),
1: mk(14_080_000, "DIGU", false, true),
}, false},
{"different bands → not split", map[int]*flexSlice{
0: mk(14_100_000, "USB", true, false),
1: mk(7_100_000, "LSB", false, true),
}, false},
{"simplex (single slice) → not split", map[int]*flexSlice{
0: mk(14_100_000, "USB", true, true),
}, false},
}
for _, c := range cases {
f := &Flex{slices: c.slices}
_, rx, tx := f.operatingLocked()
gotSplit := rx != nil && tx != nil
if gotSplit != c.wantSplit {
t.Errorf("%s: split=%v, want %v", c.name, gotSplit, c.wantSplit)
}
}
}