diff --git a/internal/award/award_test.go b/internal/award/award_test.go index 3f1754b..7a96a5a 100644 --- a/internal/award/award_test.go +++ b/internal/award/award_test.go @@ -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 // 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 diff --git a/internal/cat/flex.go b/internal/cat/flex.go index 85ba838..5bb6352 100644 --- a/internal/cat/flex.go +++ b/internal/cat/flex.go @@ -919,9 +919,11 @@ func (f *Flex) txSliceLocked() *flexSlice { // 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 // 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 -// slice often steals focus right after "slice create", which must NOT read as -// "no split"). A slice on another band is an independent receiver, ignored. +// freq) AND the SAME split class (both PHONE, or both CW) — detected from the +// pair itself, NOT from which slice is active (the TX slice often steals focus +// 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. func (f *Flex) operatingLocked() (main, rx, tx *flexSlice) { _, main = f.mainSliceLocked() @@ -933,21 +935,31 @@ func (f *Flex) operatingLocked() (main, rx, tx *flexSlice) { if bt == "" { return main, nil, nil } - // RX = the active slice when it's a distinct same-band slice, else the first - // other in-use same-band slice. - if main != nil && main != txS && main.freqHz != txS.freqHz && BandFromHz(main.freqHz) == bt { + // Split only applies to PHONE/CW; a data-mode TX slice never splits. + ct := flexSplitClass(txS.mode) + 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 } else { for _, idx := range f.sortedSliceIdxLocked() { - s := f.slices[idx] - if s.inUse && s != txS && s.freqHz != txS.freqHz && BandFromHz(s.freqHz) == bt { + if s := f.slices[idx]; sameSplit(s) { rx = s break } } } 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 } @@ -1921,6 +1933,23 @@ func parseFloatDefault(s string, def float64) float64 { } // 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 { switch strings.ToUpper(strings.TrimSpace(m)) { case "USB", "LSB": diff --git a/internal/cat/flex_split_test.go b/internal/cat/flex_split_test.go new file mode 100644 index 0000000..d4add7f --- /dev/null +++ b/internal/cat/flex_split_test.go @@ -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) + } + } +}