Initial codebase: Go + Wails amateur radio logbook
Backend (Go 1.25 / Wails v2): - QSO storage on SQLite (modernc) with embedded migrations (0001..0005) - Streaming ADIF import (batch insert) + WorkedBefore per callsign and DXCC - Callsign lookup with QRZ.com + HamQTH providers (primary/failsafe routing) and SQLite-backed TTL cache - DXCC resolver from cty.dat (auto-download, longest-prefix-match) - Multi-profile operator identities (home/portable/SOTA/contest) — every QSO stamps MY_* from the active profile - CAT control via OmniRig COM on a single OS-locked goroutine, with bidirectional sync (freq/mode/band/split/VFOs) and Rig1/Rig2 hot-swap - Settings store (key/value), CAT debug log at %APPDATA%/HamLog/cat.log Frontend (React 18 + TypeScript + Tailwind v4 + shadcn-style): - Single-row entry strip with CAT-aware band/mode/freq, RST, Start/End UTC, per-field locks (band/mode/freq/start/end) for backdated QSOs - Topbar: live freq (MHz.kHz.Hz dotted), live UTC, band/mode/SPLIT badges, CAT pill with rig selector and clickable Azimuth pill (rotor TODO) - Settings tree: Profiles (Log4OM-style manager), Station Information (edits the active profile), unified Callsign Lookup with Test buttons, Bands/Modes lists, CAT - Worked-before matrix (band × mode × class) with new-DXCC highlighting - ADIF import from menu + Maintenance > Refresh cty.dat Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,364 @@
|
||||
package adif
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hamlog/internal/qso"
|
||||
)
|
||||
|
||||
// ImportResult summarises an ADIF import for the UI.
|
||||
type ImportResult struct {
|
||||
Total int `json:"total"` // records found in the file
|
||||
Imported int `json:"imported"` // successfully inserted
|
||||
Skipped int `json:"skipped"` // dropped (missing required fields, etc.)
|
||||
Errors []string `json:"errors"` // up to maxErrors error messages
|
||||
}
|
||||
|
||||
const maxErrors = 50
|
||||
|
||||
// Importer streams an ADI file into a QSO repository.
|
||||
type Importer struct {
|
||||
Repo *qso.Repo
|
||||
BatchSize int // 0 → 500
|
||||
}
|
||||
|
||||
// ImportFile opens the file at path and imports it into the repo.
|
||||
func (im *Importer) ImportFile(ctx context.Context, path string) (ImportResult, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return ImportResult{}, fmt.Errorf("open %s: %w", path, err)
|
||||
}
|
||||
defer f.Close()
|
||||
return im.Import(ctx, f)
|
||||
}
|
||||
|
||||
// Import streams the ADI content from r into the repo.
|
||||
func (im *Importer) Import(ctx context.Context, r interface {
|
||||
Read(p []byte) (int, error)
|
||||
}) (ImportResult, error) {
|
||||
if im.BatchSize <= 0 {
|
||||
im.BatchSize = 500
|
||||
}
|
||||
res := ImportResult{}
|
||||
batch := make([]qso.QSO, 0, im.BatchSize)
|
||||
|
||||
flush := func() error {
|
||||
if len(batch) == 0 {
|
||||
return nil
|
||||
}
|
||||
n, err := im.Repo.AddBatch(ctx, batch)
|
||||
res.Imported += int(n)
|
||||
batch = batch[:0]
|
||||
return err
|
||||
}
|
||||
|
||||
err := Parse(r, func(rec Record) error {
|
||||
res.Total++
|
||||
q, ok := recordToQSO(rec)
|
||||
if !ok {
|
||||
res.Skipped++
|
||||
if len(res.Errors) < maxErrors {
|
||||
res.Errors = append(res.Errors,
|
||||
fmt.Sprintf("record %d: missing required fields (call/band/mode/date)", res.Total))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
batch = append(batch, q)
|
||||
if len(batch) >= im.BatchSize {
|
||||
return flush()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
_ = flush()
|
||||
return res, err
|
||||
}
|
||||
if err := flush(); err != nil {
|
||||
return res, err
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// adifPromoted lists every lowercase ADIF tag that maps to a promoted column.
|
||||
// Anything not in this set ends up in Extras.
|
||||
var adifPromoted = stringSet(
|
||||
// Core
|
||||
"call", "qso_date", "time_on", "qso_date_off", "time_off",
|
||||
"band", "band_rx", "mode", "submode", "freq", "freq_rx",
|
||||
"rst_sent", "rst_rcvd",
|
||||
// Contacted
|
||||
"name", "qth", "address", "email", "web",
|
||||
"gridsquare", "gridsquare_ext", "vucc_grids",
|
||||
"country", "state", "cnty",
|
||||
"dxcc", "cont", "cqz", "ituz",
|
||||
"iota", "sota_ref", "pota_ref",
|
||||
"age", "lat", "lon", "rig", "ant",
|
||||
// QSL
|
||||
"qsl_sent", "qsl_rcvd",
|
||||
"qslsdate", "qslrdate", "qsl_via", "qslmsg", "qslmsg_rcvd",
|
||||
"lotw_qsl_sent", "lotw_qsl_rcvd", "lotw_qslsdate", "lotw_qslrdate",
|
||||
"eqsl_qsl_sent", "eqsl_qsl_rcvd", "eqsl_qslsdate", "eqsl_qslrdate",
|
||||
"clublog_qso_upload_date", "clublog_qso_upload_status",
|
||||
"hrdlog_qso_upload_date", "hrdlog_qso_upload_status",
|
||||
// Contest
|
||||
"contest_id", "srx", "stx", "srx_string", "stx_string",
|
||||
"check", "precedence", "arrl_sect",
|
||||
// Sat / propagation
|
||||
"prop_mode", "sat_name", "sat_mode", "ant_az", "ant_el", "ant_path",
|
||||
// My station
|
||||
"station_callsign", "operator",
|
||||
"my_gridsquare", "my_gridsquare_ext", "my_country", "my_state", "my_cnty", "my_iota",
|
||||
"my_sota_ref", "my_pota_ref",
|
||||
"my_dxcc", "my_cq_zone", "my_itu_zone", "my_lat", "my_lon",
|
||||
"my_street", "my_city", "my_postal_code", "my_rig", "my_antenna",
|
||||
// Misc
|
||||
"tx_pwr", "comment", "notes",
|
||||
)
|
||||
|
||||
func stringSet(items ...string) map[string]struct{} {
|
||||
m := make(map[string]struct{}, len(items))
|
||||
for _, s := range items {
|
||||
m[s] = struct{}{}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// recordToQSO maps an ADIF record onto a QSO. Returns false if required
|
||||
// fields are missing. Any ADIF tag we don't promote is stored in Extras.
|
||||
func recordToQSO(rec Record) (qso.QSO, bool) {
|
||||
call := strings.ToUpper(strings.TrimSpace(rec["call"]))
|
||||
if call == "" {
|
||||
return qso.QSO{}, false
|
||||
}
|
||||
band := strings.ToLower(strings.TrimSpace(rec["band"]))
|
||||
mode := strings.ToUpper(strings.TrimSpace(rec["mode"]))
|
||||
date := parseDateTime(rec["qso_date"], rec["time_on"])
|
||||
if date.IsZero() || band == "" || mode == "" {
|
||||
return qso.QSO{}, false
|
||||
}
|
||||
|
||||
q := qso.QSO{
|
||||
Callsign: call,
|
||||
QSODate: date,
|
||||
QSODateOff: parseDateTime(rec["qso_date_off"], rec["time_off"]),
|
||||
Band: band,
|
||||
BandRX: strings.ToLower(rec["band_rx"]),
|
||||
Mode: mode,
|
||||
Submode: strings.ToUpper(rec["submode"]),
|
||||
}
|
||||
if hz, ok := parseFreqHz(rec["freq"]); ok {
|
||||
q.FreqHz = &hz
|
||||
}
|
||||
if hz, ok := parseFreqHz(rec["freq_rx"]); ok {
|
||||
q.FreqRXHz = &hz
|
||||
}
|
||||
|
||||
q.RSTSent = rec["rst_sent"]
|
||||
q.RSTRcvd = rec["rst_rcvd"]
|
||||
|
||||
// Contacted station
|
||||
q.Name = rec["name"]
|
||||
q.QTH = rec["qth"]
|
||||
q.Address = rec["address"]
|
||||
q.Email = rec["email"]
|
||||
q.Web = rec["web"]
|
||||
q.Grid = strings.ToUpper(rec["gridsquare"])
|
||||
q.GridExt = strings.ToUpper(rec["gridsquare_ext"])
|
||||
q.VUCCGrids = strings.ToUpper(rec["vucc_grids"])
|
||||
q.Country = rec["country"]
|
||||
q.State = strings.ToUpper(rec["state"])
|
||||
q.County = rec["cnty"]
|
||||
if v, ok := parseInt(rec["dxcc"]); ok {
|
||||
q.DXCC = &v
|
||||
}
|
||||
q.Continent = strings.ToUpper(rec["cont"])
|
||||
if v, ok := parseInt(rec["cqz"]); ok {
|
||||
q.CQZ = &v
|
||||
}
|
||||
if v, ok := parseInt(rec["ituz"]); ok {
|
||||
q.ITUZ = &v
|
||||
}
|
||||
q.IOTA = strings.ToUpper(rec["iota"])
|
||||
q.SOTARef = strings.ToUpper(rec["sota_ref"])
|
||||
q.POTARef = strings.ToUpper(rec["pota_ref"])
|
||||
if v, ok := parseInt(rec["age"]); ok {
|
||||
q.Age = &v
|
||||
}
|
||||
if v, ok := parseFloat(rec["lat"]); ok {
|
||||
q.Lat = &v
|
||||
}
|
||||
if v, ok := parseFloat(rec["lon"]); ok {
|
||||
q.Lon = &v
|
||||
}
|
||||
q.Rig = rec["rig"]
|
||||
q.Ant = rec["ant"]
|
||||
|
||||
// QSL
|
||||
q.QSLSent = rec["qsl_sent"]
|
||||
q.QSLRcvd = rec["qsl_rcvd"]
|
||||
q.QSLSentDate = rec["qslsdate"]
|
||||
q.QSLRcvdDate = rec["qslrdate"]
|
||||
q.QSLVia = rec["qsl_via"]
|
||||
q.QSLMsg = rec["qslmsg"]
|
||||
q.QSLMsgRcvd = rec["qslmsg_rcvd"]
|
||||
q.LOTWSent = rec["lotw_qsl_sent"]
|
||||
q.LOTWRcvd = rec["lotw_qsl_rcvd"]
|
||||
q.LOTWSentDate = rec["lotw_qslsdate"]
|
||||
q.LOTWRcvdDate = rec["lotw_qslrdate"]
|
||||
q.EQSLSent = rec["eqsl_qsl_sent"]
|
||||
q.EQSLRcvd = rec["eqsl_qsl_rcvd"]
|
||||
q.EQSLSentDate = rec["eqsl_qslsdate"]
|
||||
q.EQSLRcvdDate = rec["eqsl_qslrdate"]
|
||||
q.ClublogUploadDate = rec["clublog_qso_upload_date"]
|
||||
q.ClublogUploadStatus = rec["clublog_qso_upload_status"]
|
||||
q.HRDLogUploadDate = rec["hrdlog_qso_upload_date"]
|
||||
q.HRDLogUploadStatus = rec["hrdlog_qso_upload_status"]
|
||||
|
||||
// Contest
|
||||
q.ContestID = rec["contest_id"]
|
||||
if v, ok := parseInt(rec["srx"]); ok {
|
||||
q.SRX = &v
|
||||
}
|
||||
if v, ok := parseInt(rec["stx"]); ok {
|
||||
q.STX = &v
|
||||
}
|
||||
q.SRXString = rec["srx_string"]
|
||||
q.STXString = rec["stx_string"]
|
||||
q.Check = rec["check"]
|
||||
q.Precedence = rec["precedence"]
|
||||
q.ARRLSect = strings.ToUpper(rec["arrl_sect"])
|
||||
|
||||
// Sat / propagation
|
||||
q.PropMode = strings.ToUpper(rec["prop_mode"])
|
||||
q.SatName = strings.ToUpper(rec["sat_name"])
|
||||
q.SatMode = rec["sat_mode"]
|
||||
if v, ok := parseFloat(rec["ant_az"]); ok {
|
||||
q.AntAz = &v
|
||||
}
|
||||
if v, ok := parseFloat(rec["ant_el"]); ok {
|
||||
q.AntEl = &v
|
||||
}
|
||||
q.AntPath = strings.ToUpper(rec["ant_path"])
|
||||
|
||||
// My station
|
||||
q.StationCallsign = strings.ToUpper(rec["station_callsign"])
|
||||
q.Operator = strings.ToUpper(rec["operator"])
|
||||
q.MyGrid = strings.ToUpper(rec["my_gridsquare"])
|
||||
q.MyGridExt = strings.ToUpper(rec["my_gridsquare_ext"])
|
||||
q.MyCountry = rec["my_country"]
|
||||
q.MyState = strings.ToUpper(rec["my_state"])
|
||||
q.MyCounty = rec["my_cnty"]
|
||||
q.MyIOTA = strings.ToUpper(rec["my_iota"])
|
||||
q.MySOTARef = strings.ToUpper(rec["my_sota_ref"])
|
||||
q.MyPOTARef = strings.ToUpper(rec["my_pota_ref"])
|
||||
if v, ok := parseInt(rec["my_dxcc"]); ok {
|
||||
q.MyDXCC = &v
|
||||
}
|
||||
if v, ok := parseInt(rec["my_cq_zone"]); ok {
|
||||
q.MyCQZone = &v
|
||||
}
|
||||
if v, ok := parseInt(rec["my_itu_zone"]); ok {
|
||||
q.MyITUZone = &v
|
||||
}
|
||||
if v, ok := parseFloat(rec["my_lat"]); ok {
|
||||
q.MyLat = &v
|
||||
}
|
||||
if v, ok := parseFloat(rec["my_lon"]); ok {
|
||||
q.MyLon = &v
|
||||
}
|
||||
q.MyStreet = rec["my_street"]
|
||||
q.MyCity = rec["my_city"]
|
||||
q.MyPostalCode = rec["my_postal_code"]
|
||||
q.MyRig = rec["my_rig"]
|
||||
q.MyAntenna = rec["my_antenna"]
|
||||
|
||||
// Misc
|
||||
if v, ok := parseFloat(rec["tx_pwr"]); ok {
|
||||
q.TXPower = &v
|
||||
}
|
||||
q.Comment = rec["comment"]
|
||||
q.Notes = rec["notes"]
|
||||
|
||||
// Everything else lands in extras (uppercased ADIF names).
|
||||
var extras map[string]string
|
||||
for k, v := range rec {
|
||||
if _, ok := adifPromoted[k]; ok {
|
||||
continue
|
||||
}
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
if extras == nil {
|
||||
extras = map[string]string{}
|
||||
}
|
||||
extras[strings.ToUpper(k)] = v
|
||||
}
|
||||
q.Extras = extras
|
||||
|
||||
return q, true
|
||||
}
|
||||
|
||||
// parseDateTime combines ADIF QSO_DATE (YYYYMMDD) with TIME (HHMMSS or HHMM).
|
||||
func parseDateTime(date, timeStr string) time.Time {
|
||||
date = strings.TrimSpace(date)
|
||||
timeStr = strings.TrimSpace(timeStr)
|
||||
if len(date) != 8 {
|
||||
return time.Time{}
|
||||
}
|
||||
layout := "20060102"
|
||||
val := date
|
||||
if len(timeStr) == 4 {
|
||||
layout = "200601021504"
|
||||
val = date + timeStr
|
||||
} else if len(timeStr) == 6 {
|
||||
layout = "20060102150405"
|
||||
val = date + timeStr
|
||||
}
|
||||
t, err := time.ParseInLocation(layout, val, time.UTC)
|
||||
if err != nil {
|
||||
return time.Time{}
|
||||
}
|
||||
return t.UTC()
|
||||
}
|
||||
|
||||
func parseFreqHz(s string) (int64, bool) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return 0, false
|
||||
}
|
||||
mhz, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil || mhz <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return int64(mhz*1_000_000 + 0.5), true
|
||||
}
|
||||
|
||||
func parseInt(s string) (int, bool) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return 0, false
|
||||
}
|
||||
v, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return v, true
|
||||
}
|
||||
|
||||
func parseFloat(s string) (float64, bool) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return 0, false
|
||||
}
|
||||
v, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return v, true
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// Package adif handles ADIF import and export (ADI text format).
|
||||
//
|
||||
// ADI tokenisation rules (per ADIF spec):
|
||||
// - Free-form text is allowed up to the first <EOH> (header end).
|
||||
// - After <EOH>, records are sequences of <FIELDNAME:LENGTH[:TYPE]>VALUE
|
||||
// terminated by <EOR>.
|
||||
// - The LENGTH is the byte count of the VALUE that immediately follows
|
||||
// the closing '>' (no separator).
|
||||
// - Tag names are case-insensitive.
|
||||
// - Bytes between fields (whitespace, junk) are ignored.
|
||||
package adif
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Record is a single ADIF record. Keys are lowercased field names.
|
||||
type Record map[string]string
|
||||
|
||||
// Parse reads an ADI stream and invokes fn for each record (after <EOH>).
|
||||
// Returning a non-nil error from fn stops parsing and is propagated.
|
||||
// The header (text before <EOH>) is silently discarded.
|
||||
func Parse(r io.Reader, fn func(Record) error) error {
|
||||
br := bufio.NewReaderSize(r, 64*1024)
|
||||
|
||||
rec := Record{}
|
||||
headerDone := false
|
||||
|
||||
for {
|
||||
// Seek next '<'. Bytes before it are either header text or
|
||||
// inter-field whitespace — both discardable.
|
||||
if err := seekByte(br, '<'); err != nil {
|
||||
if err == io.EOF {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
spec, err := readUntilByte(br, '>')
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("unterminated tag: %w", err)
|
||||
}
|
||||
name, length := parseSpec(spec)
|
||||
switch name {
|
||||
case "eoh":
|
||||
headerDone = true
|
||||
rec = Record{}
|
||||
continue
|
||||
case "eor":
|
||||
if headerDone && len(rec) > 0 {
|
||||
if err := fn(rec); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
rec = Record{}
|
||||
continue
|
||||
}
|
||||
// Skip value bytes regardless of header state; we only emit
|
||||
// records once we've crossed <EOH>.
|
||||
if length > 0 {
|
||||
val := make([]byte, length)
|
||||
if _, err := io.ReadFull(br, val); err != nil {
|
||||
return fmt.Errorf("read field %s: %w", name, err)
|
||||
}
|
||||
if headerDone && name != "" {
|
||||
rec[name] = string(val)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// parseSpec splits "callsign:5", "callsign:5:S" or "eor" into name and length.
|
||||
// name is lowercased; length is 0 for control tags or when missing.
|
||||
func parseSpec(spec string) (name string, length int) {
|
||||
parts := strings.SplitN(strings.TrimSpace(spec), ":", 3)
|
||||
name = strings.ToLower(strings.TrimSpace(parts[0]))
|
||||
if len(parts) >= 2 {
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(parts[1])); err == nil && n > 0 {
|
||||
length = n
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func seekByte(br *bufio.Reader, target byte) error {
|
||||
for {
|
||||
b, err := br.ReadByte()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if b == target {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func readUntilByte(br *bufio.Reader, target byte) (string, error) {
|
||||
var sb strings.Builder
|
||||
for {
|
||||
b, err := br.ReadByte()
|
||||
if err != nil {
|
||||
return sb.String(), err
|
||||
}
|
||||
if b == target {
|
||||
return sb.String(), nil
|
||||
}
|
||||
sb.WriteByte(b)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package adif
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseSimple(t *testing.T) {
|
||||
src := `Header text here
|
||||
Generated by HamLog
|
||||
<EOH>
|
||||
<CALL:5>F4XYZ<BAND:3>20m<MODE:3>SSB<QSO_DATE:8>20240101<TIME_ON:6>123456<EOR>
|
||||
<call:4>K1AB<band:3>40m<mode:2>CW<qso_date:8>20240102<time_on:4>0930<eor>
|
||||
`
|
||||
var got []Record
|
||||
err := Parse(strings.NewReader(src), func(r Record) error {
|
||||
got = append(got, r)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("want 2 records, got %d", len(got))
|
||||
}
|
||||
if got[0]["call"] != "F4XYZ" || got[0]["band"] != "20m" || got[0]["mode"] != "SSB" {
|
||||
t.Errorf("record 0 mismatch: %+v", got[0])
|
||||
}
|
||||
if got[1]["call"] != "K1AB" || got[1]["time_on"] != "0930" {
|
||||
t.Errorf("record 1 mismatch: %+v", got[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseValueWithAngleBracket(t *testing.T) {
|
||||
// Length-prefixed value can contain '<' and '>' bytes.
|
||||
src := `<EOH><CALL:5>F4XYZ<COMMENT:7>a<b>c<d<EOR>`
|
||||
var got []Record
|
||||
err := Parse(strings.NewReader(src), func(r Record) error {
|
||||
got = append(got, r)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("want 1, got %d", len(got))
|
||||
}
|
||||
if got[0]["comment"] != "a<b>c<d" {
|
||||
t.Errorf("comment mismatch: %q", got[0]["comment"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNoHeader(t *testing.T) {
|
||||
// Some loggers omit the header entirely — records before <EOH> are
|
||||
// discarded by design. Verify nothing is emitted in that case.
|
||||
src := `<CALL:5>F4XYZ<EOR>`
|
||||
var got int
|
||||
err := Parse(strings.NewReader(src), func(r Record) error {
|
||||
got++
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if got != 0 {
|
||||
t.Errorf("expected 0 records without <EOH>, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTypedField(t *testing.T) {
|
||||
// <FIELD:LEN:TYPE> form (e.g. <FREQ:6:N>).
|
||||
src := `<EOH><CALL:5>F4XYZ<FREQ:6:N>14.250<EOR>`
|
||||
var got Record
|
||||
err := Parse(strings.NewReader(src), func(r Record) error {
|
||||
got = r
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if got["freq"] != "14.250" {
|
||||
t.Errorf("freq mismatch: %q", got["freq"])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user