The three layers underneath were already there and unreachable: the change-log format (7d664bd), the identity column (724e68b) and the no-backfill decision (c48294b). This is the wiring that gives the operator a switch. Settings, all profile-scoped, because each profile can point at its own logbook: the folder, this PC's name, the machine id minted once from it, the per-peer read offsets and the tie-break counter. Scoping the machine id is what keeps two profiles sharing one folder from writing two logbooks into one file. Three hooks. Add and update publish asynchronously, down with the rest of the after-the-fact work — a folder on a network share can block for seconds and a contact belongs on screen long before another machine hears about it. Deletion publishes SYNCHRONOUSLY and BEFORE the row goes, for the same reason deleteRemoteCopies does: once it is gone its identity is gone with it and the tombstone names nothing. The apply path uses the repository directly and never AddQSO/UpdateQSO/DeleteQSO — those publish, and a change applied here would be written straight back out, two machines echoing each other for ever. Saving writes a probe file to the chosen folder rather than asking whether it exists. A read-only cloud folder, or a share whose credentials expired, exists perfectly well and would swallow every contact in silence; if the probe fails the switch goes back off instead of sitting on while nothing is written. The panel is mostly status, and deliberately: every part of this runs on another machine and on a sync client OpsLog cannot see, so "it is not working" has to be answerable from the settings page — which PCs are in the folder, when each last logged, what is waiting unread. Four tests on the apply path, the middle two being the ones that matter: an edit made on the other PC lands on the copy already here, matched on the contact itself, instead of becoming a second row — that is what makes the no-backfill decision safe — and a contact with the same station on another band stays a separate contact.
177 lines
6.3 KiB
Go
177 lines
6.3 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"hamlog/internal/db"
|
|
"hamlog/internal/qso"
|
|
"hamlog/internal/syncfolder"
|
|
)
|
|
|
|
// syncTestApp is an App with nothing but a logbook: applySyncRecord touches the
|
|
// repository and the log file, and no more. Settings are nil, which is exactly
|
|
// the state it must survive anyway — the loop runs before the active profile is
|
|
// known.
|
|
func syncTestApp(t *testing.T) *App {
|
|
t.Helper()
|
|
conn, err := db.Open(filepath.Join(t.TempDir(), "log.db"))
|
|
if err != nil {
|
|
t.Fatalf("open: %v", err)
|
|
}
|
|
t.Cleanup(func() { conn.Close() })
|
|
return &App{ctx: context.Background(), qso: qso.NewRepo(conn)}
|
|
}
|
|
|
|
func syncRecord(t *testing.T, op syncfolder.Op, uid string, q qso.QSO) syncfolder.Record {
|
|
t.Helper()
|
|
rec := syncfolder.Record{V: syncfolder.FormatVersion, Op: op, UID: uid, At: time.Now().UTC(), By: "other"}
|
|
if op != syncfolder.OpDelete {
|
|
b, err := json.Marshal(q)
|
|
if err != nil {
|
|
t.Fatalf("marshal: %v", err)
|
|
}
|
|
rec.Data = b
|
|
}
|
|
return rec
|
|
}
|
|
|
|
func countQSOs(t *testing.T, a *App) int64 {
|
|
t.Helper()
|
|
n, err := a.qso.Count(a.ctx)
|
|
if err != nil {
|
|
t.Fatalf("count: %v", err)
|
|
}
|
|
return n
|
|
}
|
|
|
|
// The ordinary life of a contact made on the other PC: it arrives, it is
|
|
// corrected, it is deleted. One row throughout — a sync that inserted a second
|
|
// copy on the edit would be worse than no sync at all.
|
|
func TestSyncRecordAddThenUpdateThenDelete(t *testing.T) {
|
|
a := syncTestApp(t)
|
|
when := time.Date(2026, 8, 16, 14, 32, 0, 0, time.UTC)
|
|
uid := syncfolder.NewUID()
|
|
|
|
if !a.applySyncRecord(syncRecord(t, syncfolder.OpAdd, uid, qso.QSO{
|
|
Callsign: "M0ABC", QSODate: when, Band: "20m", Mode: "CW", Name: "Ann",
|
|
})) {
|
|
t.Fatal("the added contact was not applied")
|
|
}
|
|
if n := countQSOs(t, a); n != 1 {
|
|
t.Fatalf("log holds %d QSOs after an add, want 1", n)
|
|
}
|
|
// Stamped with the identity from the record — without this every later
|
|
// change naming it would look like a contact never seen before.
|
|
id, found, err := a.qso.IDBySyncUID(a.ctx, uid)
|
|
if err != nil || !found {
|
|
t.Fatalf("IDBySyncUID = (%d,%v,%v), want the new row", id, found, err)
|
|
}
|
|
|
|
if !a.applySyncRecord(syncRecord(t, syncfolder.OpUpdate, uid, qso.QSO{
|
|
Callsign: "M0ABC", QSODate: when, Band: "20m", Mode: "CW", Name: "Annette",
|
|
})) {
|
|
t.Fatal("the correction was not applied")
|
|
}
|
|
if n := countQSOs(t, a); n != 1 {
|
|
t.Fatalf("log holds %d QSOs after an edit, want 1 — the edit was logged as a second contact", n)
|
|
}
|
|
got, err := a.qso.GetByID(a.ctx, id)
|
|
if err != nil {
|
|
t.Fatalf("get: %v", err)
|
|
}
|
|
if got.Name != "Annette" {
|
|
t.Errorf("name = %q after the correction, want %q", got.Name, "Annette")
|
|
}
|
|
|
|
if !a.applySyncRecord(syncRecord(t, syncfolder.OpDelete, uid, qso.QSO{})) {
|
|
t.Fatal("the tombstone was not applied")
|
|
}
|
|
if n := countQSOs(t, a); n != 0 {
|
|
t.Fatalf("log holds %d QSOs after the deletion, want 0", n)
|
|
}
|
|
// A tombstone that arrives twice — both peers relayed it, or the file was
|
|
// re-read after a restore — must be quiet, not an error and not a change.
|
|
if a.applySyncRecord(syncRecord(t, syncfolder.OpDelete, uid, qso.QSO{})) {
|
|
t.Error("a repeated tombstone reported a change; the grid would refresh for nothing, for ever")
|
|
}
|
|
}
|
|
|
|
// The case the whole no-backfill decision rests on.
|
|
//
|
|
// Both PCs already hold the operator's 123 000 contacts — seeded from one
|
|
// database or one ADIF — and neither row carries an identity, because nothing
|
|
// has touched them since. The day the shack PC corrects a 2019 QSO it stamps an
|
|
// identity and sends an update naming it; the laptop has never seen that
|
|
// identity. Inserting would give the operator two copies of a contact they
|
|
// merely corrected, and would do it for every edit for ever.
|
|
func TestSyncRecordAdoptsTheContactAlreadyInTheLog(t *testing.T) {
|
|
a := syncTestApp(t)
|
|
when := time.Date(2019, 3, 2, 9, 15, 0, 0, time.UTC)
|
|
|
|
// The copy that was already here, with no identity.
|
|
localID, err := a.qso.Add(a.ctx, qso.QSO{
|
|
Callsign: "M0ABC", QSODate: when, Band: "40m", Mode: "SSB", Name: "Ann",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
|
|
uid := syncfolder.NewUID() // minted on the OTHER machine
|
|
if !a.applySyncRecord(syncRecord(t, syncfolder.OpUpdate, uid, qso.QSO{
|
|
Callsign: "M0ABC", QSODate: when, Band: "40m", Mode: "SSB", Name: "Annette", QTH: "Bristol",
|
|
})) {
|
|
t.Fatal("the correction was not applied")
|
|
}
|
|
if n := countQSOs(t, a); n != 1 {
|
|
t.Fatalf("log holds %d QSOs, want 1 — the contact was duplicated instead of recognised", n)
|
|
}
|
|
got, err := a.qso.GetByID(a.ctx, localID)
|
|
if err != nil {
|
|
t.Fatalf("get: %v", err)
|
|
}
|
|
if got.Name != "Annette" || got.QTH != "Bristol" {
|
|
t.Errorf("the row already here was not corrected: name=%q qth=%q", got.Name, got.QTH)
|
|
}
|
|
// And it now carries the identity, so the NEXT change goes straight to it
|
|
// without needing the contact-matching fallback again.
|
|
if id, found, _ := a.qso.IDBySyncUID(a.ctx, uid); !found || id != localID {
|
|
t.Errorf("IDBySyncUID = (%d,%v), want the row already here (%d)", id, found, localID)
|
|
}
|
|
}
|
|
|
|
// A different contact must NOT be adopted. The matching is deliberately narrow
|
|
// — same callsign, same minute, same band, same mode — and this pins that a
|
|
// second contact with the same station on another band stays a second contact.
|
|
func TestSyncRecordDoesNotAdoptADifferentContact(t *testing.T) {
|
|
a := syncTestApp(t)
|
|
when := time.Date(2026, 8, 16, 14, 32, 0, 0, time.UTC)
|
|
if _, err := a.qso.Add(a.ctx, qso.QSO{Callsign: "M0ABC", QSODate: when, Band: "40m", Mode: "CW"}); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
if !a.applySyncRecord(syncRecord(t, syncfolder.OpAdd, syncfolder.NewUID(), qso.QSO{
|
|
Callsign: "M0ABC", QSODate: when, Band: "20m", Mode: "CW",
|
|
})) {
|
|
t.Fatal("the contact was not applied")
|
|
}
|
|
if n := countQSOs(t, a); n != 2 {
|
|
t.Fatalf("log holds %d QSOs, want 2 — a contact on another band was swallowed as a duplicate", n)
|
|
}
|
|
}
|
|
|
|
// A record with no callsign is not a contact. It reaches here from a file
|
|
// truncated by a sync client mid-upload, or from a future format read
|
|
// optimistically, and inserting it would put a blank row in the log.
|
|
func TestSyncRecordIgnoresAContactWithNoCallsign(t *testing.T) {
|
|
a := syncTestApp(t)
|
|
if a.applySyncRecord(syncRecord(t, syncfolder.OpAdd, syncfolder.NewUID(), qso.QSO{Band: "20m", Mode: "CW"})) {
|
|
t.Error("a record with no callsign was applied")
|
|
}
|
|
if n := countQSOs(t, a); n != 0 {
|
|
t.Fatalf("log holds %d QSOs, want 0", n)
|
|
}
|
|
}
|