chore: release v0.25.9
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
package qso
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LoTW never hands back the submode it was given: every digital contact comes
|
||||
// back as the mode GROUP, "DATA". Matched on the exact mode string that is
|
||||
// simply never equal, so an operator downloading their confirmations was told
|
||||
// their own QSOs were not in their log — reported for FT2 contacts confirmed as
|
||||
// DATA, and true of FT4 and FT8 alike.
|
||||
func TestClassKeyMatchesAConfirmationCarryingTheModeGroup(t *testing.T) {
|
||||
r := openRepo(t)
|
||||
ctx := context.Background()
|
||||
when := time.Date(2026, 8, 16, 16, 29, 0, 0, time.UTC)
|
||||
id, err := r.Add(ctx, QSO{Callsign: "F1NQP", QSODate: when, Band: "20m", Mode: "FT2"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
minute := when.Format("2006-01-02T15:04")
|
||||
|
||||
exact, err := r.DedupeKeyIDs(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, found := exact[DedupeKey("F1NQP", minute, "20m", "DATA")]; found {
|
||||
t.Fatal("the exact index matched DATA against FT2 — the test proves nothing")
|
||||
}
|
||||
|
||||
byClass, err := r.DedupeClassKeyIDs(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, found := byClass[DedupeClassKey("F1NQP", minute, "20m", "DATA")]
|
||||
if !found || got != id {
|
||||
t.Errorf("class match = (%d, %v), want (%d, true) — the confirmation would be reported as having no local QSO", got, found, id)
|
||||
}
|
||||
}
|
||||
|
||||
// Two digital contacts with the same station, same band, same minute is barely
|
||||
// physical — but if it happens, stamping the confirmation on whichever row the
|
||||
// map happened to keep is a silent error in an award credit. Ambiguity maps to
|
||||
// 0 so the caller reports it unmatched instead of guessing.
|
||||
func TestAnAmbiguousClassKeyIsRefusedRatherThanGuessed(t *testing.T) {
|
||||
r := openRepo(t)
|
||||
ctx := context.Background()
|
||||
when := time.Date(2026, 8, 16, 16, 29, 0, 0, time.UTC)
|
||||
for _, m := range []string{"FT8", "RTTY"} {
|
||||
if _, err := r.Add(ctx, QSO{Callsign: "F1NQP", QSODate: when, Band: "20m", Mode: m}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
byClass, err := r.DedupeClassKeyIDs(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := byClass[DedupeClassKey("F1NQP", when.Format("2006-01-02T15:04"), "20m", "DATA")]; got != 0 {
|
||||
t.Errorf("ambiguous class key resolved to %d — one of two QSOs was picked at random", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Phone and CW keep their own classes: a CW confirmation must never land on an
|
||||
// SSB contact just because the rest of the key agrees.
|
||||
func TestClassMatchDoesNotCrossPhoneAndCW(t *testing.T) {
|
||||
r := openRepo(t)
|
||||
ctx := context.Background()
|
||||
when := time.Date(2026, 8, 16, 16, 29, 0, 0, time.UTC)
|
||||
if _, err := r.Add(ctx, QSO{Callsign: "F1NQP", QSODate: when, Band: "20m", Mode: "SSB"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
byClass, err := r.DedupeClassKeyIDs(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
minute := when.Format("2006-01-02T15:04")
|
||||
if _, found := byClass[DedupeClassKey("F1NQP", minute, "20m", "CW")]; found {
|
||||
t.Error("a CW confirmation matched an SSB contact")
|
||||
}
|
||||
if _, found := byClass[DedupeClassKey("F1NQP", minute, "20m", "USB")]; !found {
|
||||
t.Error("USB did not match the SSB contact — the phone sidebands are one class")
|
||||
}
|
||||
}
|
||||
@@ -2691,6 +2691,51 @@ func (r *Repo) DedupeKeyIDs(ctx context.Context) (map[string]int64, error) {
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// DedupeClassKey is DedupeKey with the mode collapsed to its CLASS (Phone / CW /
|
||||
// Digital).
|
||||
//
|
||||
// For matching a downloaded confirmation whose mode is not the one you logged.
|
||||
// LoTW does not hand back the submode it was given: a contact uploaded as FT4,
|
||||
// FT8 or anything else digital comes back as the mode GROUP, "DATA". Matched on
|
||||
// the exact string that is simply never equal, and the operator is told their
|
||||
// own QSO is not in their log — which is how a confirmed contact goes unrecorded.
|
||||
func DedupeClassKey(callsign, qsoDateMinute, band, mode string) string {
|
||||
return strings.ToUpper(callsign) + "|" + qsoDateMinute + "|" + strings.ToLower(band) + "|" + modeClass(mode)
|
||||
}
|
||||
|
||||
// DedupeClassKeyIDs is DedupeKeyIDs at mode-CLASS granularity, for the second
|
||||
// pass when the exact key misses.
|
||||
//
|
||||
// A key shared by more than one QSO maps to 0 — AMBIGUOUS, not "pick one". Two
|
||||
// contacts with the same station, on the same band, in the same minute, in two
|
||||
// digital modes is barely physical; but if it ever happens, stamping the
|
||||
// confirmation on whichever row the map happened to keep would be a silent
|
||||
// error in someone's award credit. Better to report it unmatched.
|
||||
func (r *Repo) DedupeClassKeyIDs(ctx context.Context) (map[string]int64, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, callsign, substr(qso_date, 1, 16), band, mode
|
||||
FROM qso`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make(map[string]int64, 1024)
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var call, when, band, mode string
|
||||
if err := rows.Scan(&id, &call, &when, &band, &mode); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
k := DedupeClassKey(call, when, band, mode)
|
||||
if prev, seen := out[k]; seen && prev != id {
|
||||
out[k] = 0
|
||||
continue
|
||||
}
|
||||
out[k] = id
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// matchRef is one local QSO's time + id, for time-window confirmation matching.
|
||||
type matchRef struct {
|
||||
when time.Time
|
||||
|
||||
Reference in New Issue
Block a user