feat(awards): a reference's number can be corrected in the editor

The one field the editor would not let you touch, and the one that was wrong on
WAJA. Every other property of a reference — its name, pattern, entity list,
validity window — was editable; the code was rendered readOnly, so correcting a
number meant deleting all 47 references and importing a new list, throwing away
anything the operator had adjusted in it.

A rename in the store, not a delete plus an insert: everything the reference
carries travels with it, which is the whole point of correcting a number rather
than replacing an entry. A number already in use is refused — REPLACE INTO would
have let one reference silently swallow another, discovered much later as a
prefecture quietly missing from the list.

The typed code is held apart from the selection. The list and every field patch
key off the selected code, so editing it in place made the editor lose the
reference mid-edit.

SaveAwardReference now recomputes the log like Delete and Replace already did. A
reference's name is what the award column SHOWS for awards displaying by name,
and its pattern is part of what matches at all — so editing one changes rows,
and the grid was left showing the old label until something else happened to
trigger a pass.

Changelog: the three TCI-sharing lines are merged into one. The server and the
two fixes made to it while building are one unreleased feature, and an operator
only ever meets the finished thing. The TCI-client PTT line stays separate — it
is OpsLog driving a SunSDR, the other direction entirely.
This commit is contained in:
2026-08-17 10:26:13 +02:00
parent 2941121f4b
commit 21a0d560de
8 changed files with 236 additions and 15 deletions
+42
View File
@@ -266,6 +266,48 @@ func (r *Repo) Upsert(ctx context.Context, awardCode string, ref Ref) error {
return err
}
// Rename changes a reference's CODE, keeping everything else about it.
//
// Wanted because a shipped list can simply be wrong: WAJA went out numbered by
// the Japanese state instead of by the JARL, and the only way to correct it was
// to delete all 47 references and import a new list — losing anything the
// operator had adjusted. The number is the one field an editor could not touch.
//
// A rename, not a delete plus an insert: everything the reference carries — its
// pattern, its DXCC list, its validity window — travels with it, which is the
// whole point of correcting a number rather than replacing an entry.
func (r *Repo) Rename(ctx context.Context, awardCode, oldCode, newCode string) error {
ac := strings.ToUpper(strings.TrimSpace(awardCode))
from := strings.ToUpper(strings.TrimSpace(oldCode))
to := strings.ToUpper(strings.TrimSpace(newCode))
if ac == "" || from == "" || to == "" {
return fmt.Errorf("empty award or reference code")
}
if from == to {
return nil
}
// A collision would REPLACE the other reference and take its name, pattern
// and dates with it — one silently swallowing another, discovered much later
// as a reference that has quietly gone missing.
var n int
if err := r.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM award_references WHERE award_code = ? AND ref_code = ?`, ac, to).Scan(&n); err != nil {
return err
}
if n > 0 {
return fmt.Errorf("%s already has a reference %s", ac, to)
}
res, err := r.db.ExecContext(ctx,
`UPDATE award_references SET ref_code = ? WHERE award_code = ? AND ref_code = ?`, to, ac, from)
if err != nil {
return err
}
if rows, _ := res.RowsAffected(); rows == 0 {
return fmt.Errorf("%s has no reference %s", ac, from)
}
return nil
}
// Delete removes one reference from an award.
func (r *Repo) Delete(ctx context.Context, awardCode, refCode string) error {
_, err := r.db.ExecContext(ctx,
+112
View File
@@ -0,0 +1,112 @@
package awardref
import (
"context"
"path/filepath"
"testing"
"hamlog/internal/db"
)
func renameRepo(t *testing.T) *Repo {
t.Helper()
conn, err := db.Open(filepath.Join(t.TempDir(), "a.db"))
if err != nil {
t.Fatalf("open: %v", err)
}
t.Cleanup(func() { conn.Close() })
return NewRepo(conn)
}
// Correcting a reference's number must keep the reference.
//
// WAJA shipped numbered by the Japanese state instead of by the JARL, and until
// now the only way to fix that was to delete all 47 references and import a new
// list — losing anything the operator had adjusted. A rename keeps the pattern,
// the entity list and the validity window, because a wrong NUMBER is all that
// was wrong.
func TestRenameKeepsEverythingButTheCode(t *testing.T) {
r := renameRepo(t)
ctx := context.Background()
if err := r.Upsert(ctx, "WAJA", Ref{
Code: "13", Name: "Tokyo", Pattern: `\bTok[iy]o\b`, Valid: true,
DXCCList: []int{339}, ValidFrom: "1970-01-01",
}); err != nil {
t.Fatalf("seed: %v", err)
}
if err := r.Rename(ctx, "WAJA", "13", "10"); err != nil {
t.Fatalf("rename: %v", err)
}
refs, err := r.List(ctx, "WAJA")
if err != nil {
t.Fatalf("list: %v", err)
}
if len(refs) != 1 {
t.Fatalf("WAJA holds %d references after a rename, want 1 — it was copied, not renamed", len(refs))
}
got := refs[0]
if got.Code != "10" {
t.Errorf("code = %q, want 10", got.Code)
}
if got.Name != "Tokyo" || got.Pattern != `\bTok[iy]o\b` {
t.Errorf("the reference lost what it carried: name=%q pattern=%q", got.Name, got.Pattern)
}
if len(got.DXCCList) != 1 || got.DXCCList[0] != 339 || got.ValidFrom != "1970-01-01" {
t.Errorf("the reference lost its entity list or dates: %+v", got)
}
}
// A number already in use must be refused. Left to REPLACE, the rename would
// take the other reference's name, pattern and dates with it — one entry
// silently swallowing another, found much later as a prefecture that has
// quietly gone missing from the list.
func TestRenameRefusesANumberAlreadyTaken(t *testing.T) {
r := renameRepo(t)
ctx := context.Background()
if err := r.Upsert(ctx, "WAJA", Ref{Code: "10", Name: "Gunma", Valid: true}); err != nil {
t.Fatalf("seed: %v", err)
}
if err := r.Upsert(ctx, "WAJA", Ref{Code: "13", Name: "Tokyo", Valid: true}); err != nil {
t.Fatalf("seed: %v", err)
}
if err := r.Rename(ctx, "WAJA", "13", "10"); err == nil {
t.Fatal("renaming onto an existing number was accepted — one reference would have eaten the other")
}
refs, _ := r.List(ctx, "WAJA")
if len(refs) != 2 {
t.Fatalf("WAJA holds %d references, want both still there", len(refs))
}
}
// Renaming something that is not there is an error, not a silent no-op: it
// means the editor and the store disagree about what the award holds.
func TestRenameAnUnknownReferenceFails(t *testing.T) {
r := renameRepo(t)
if err := r.Rename(context.Background(), "WAJA", "99", "10"); err == nil {
t.Error("renaming a reference the award does not have was accepted")
}
}
// Codes are stored upper-cased, so a rename must compare the same way — else
// "eu-048" onto "EU-048" looks like a move and is really the same reference,
// which the collision check has to catch.
func TestRenameIsCaseInsensitive(t *testing.T) {
r := renameRepo(t)
ctx := context.Background()
if err := r.Upsert(ctx, "IOTA", Ref{Code: "EU-048", Name: "Belle-Ile", Valid: true}); err != nil {
t.Fatalf("seed: %v", err)
}
if err := r.Rename(ctx, "iota", "eu-048", "eu-048"); err != nil {
t.Errorf("renaming a reference to itself in another case failed: %v", err)
}
if err := r.Rename(ctx, "IOTA", "eu-048", "eu-049"); err != nil {
t.Fatalf("rename: %v", err)
}
refs, _ := r.List(ctx, "IOTA")
if len(refs) != 1 || refs[0].Code != "EU-049" {
t.Errorf("references = %+v, want the one renamed to EU-049 and upper-cased", refs)
}
}