aboutsummaryrefslogtreecommitdiff
path: root/hotline
diff options
context:
space:
mode:
authorJeff Halter <868228+jhalter@users.noreply.github.com>2025-11-28 11:46:53 -0800
committerJeff Halter <868228+jhalter@users.noreply.github.com>2025-11-28 11:46:53 -0800
commit1f5717031ac2b56301092e9a090b78a2ff55480a (patch)
treefc8a8c5a7b67c16e32ad5ea4790419721d7232c1 /hotline
parent8ddb9bb228389b198a76d6df21de005da4fad66b (diff)
Mobius client library quality of life improvements
- Introduce Logger interface to replace direct slog dependency in Client - Implement Write methods for NewsArtListData and NewsCategoryListData15 with support for partial/chunked writes - Enhance transaction logging with human-readable type names
Diffstat (limited to 'hotline')
-rw-r--r--hotline/client.go21
-rw-r--r--hotline/logger.go7
-rw-r--r--hotline/news.go153
-rw-r--r--hotline/news_test.go514
-rw-r--r--hotline/transaction.go1
5 files changed, 679 insertions, 17 deletions
diff --git a/hotline/client.go b/hotline/client.go
index e300bb8..544219c 100644
--- a/hotline/client.go
+++ b/hotline/client.go
@@ -7,7 +7,6 @@ import (
"encoding/binary"
"fmt"
"io"
- "log/slog"
"net"
"time"
)
@@ -27,7 +26,7 @@ func (cp *ClientPrefs) IconBytes() []byte {
type Client struct {
Connection net.Conn
- Logger *slog.Logger
+ Logger Logger
Pref *ClientPrefs
Handlers map[[2]byte]ClientHandler
activeTasks map[[4]byte]*Transaction
@@ -40,7 +39,7 @@ func (c *Client) HandleFunc(tranType [2]byte, handler ClientHandler) {
c.Handlers[tranType] = handler
}
-func NewClient(username string, logger *slog.Logger) *Client {
+func NewClient(username string, logger Logger) *Client {
c := &Client{
Logger: logger,
activeTasks: make(map[[4]byte]*Transaction),
@@ -143,7 +142,7 @@ func (c *Client) Handshake() error {
}
func (c *Client) Send(t Transaction) error {
- requestNum := binary.BigEndian.Uint16(t.Type[:])
+ //requestNum := binary.BigEndian.Uint16(t.Type[:])
// if transaction is NOT reply, add it to the list to transactions we're expecting a response for
if t.IsReply == 0 {
@@ -155,11 +154,8 @@ func (c *Client) Send(t Transaction) error {
return fmt.Errorf("error sending transaction: %w", err)
}
- c.Logger.Debug("Sent Transaction",
- "IsReply", t.IsReply,
- "type", requestNum,
- "sentBytes", n,
- )
+ c.Logger.Info(tranTypeNames[t.Type], "IsReply", t.IsReply, "type", t.Type[:], "sentBytes", n)
+
return nil
}
@@ -171,11 +167,8 @@ func (c *Client) HandleTransaction(ctx context.Context, t *Transaction) error {
}
if handler, ok := c.Handlers[t.Type]; ok {
- c.Logger.Debug(
- "Received transaction",
- "IsReply", t.IsReply,
- "type", t.Type[:],
- )
+ c.Logger.Info(tranTypeNames[t.Type], "IsReply", t.IsReply, "type", t.Type[:])
+
outT, err := handler(ctx, c, t)
if err != nil {
c.Logger.Error("error handling transaction", "err", err)
diff --git a/hotline/logger.go b/hotline/logger.go
new file mode 100644
index 0000000..22ba09f
--- /dev/null
+++ b/hotline/logger.go
@@ -0,0 +1,7 @@
+package hotline
+
+type Logger interface {
+ Debug(msg string, args ...any)
+ Info(msg string, args ...any)
+ Error(msg string, args ...any)
+}
diff --git a/hotline/news.go b/hotline/news.go
index 1ddeebd..f247db0 100644
--- a/hotline/news.go
+++ b/hotline/news.go
@@ -3,9 +3,10 @@ package hotline
import (
"cmp"
"encoding/binary"
- "github.com/stretchr/testify/mock"
"io"
"slices"
+
+ "github.com/stretchr/testify/mock"
)
var (
@@ -38,7 +39,9 @@ type NewsCategoryListData15 struct {
AddSN [4]byte `yaml:"-"` // What does this do? Undocumented and seeming unused.
DeleteSN [4]byte `yaml:"-"` // What does this do? Undocumented and seeming unused.
- readOffset int // Internal offset to track read progress
+ readOffset int // Internal offset to track read progress
+ writeOffset int // Internal offset to track write progress
+ writeBuf []byte // Buffer for accumulating partial writes
}
func (newscat *NewsCategoryListData15) GetNewsArtListData() (NewsArtListData, error) {
@@ -110,7 +113,9 @@ type NewsArtListData struct {
NewsArtList []byte // List of articles Optional (if article count > 0)
Count int
- readOffset int // Internal offset to track read progress
+ readOffset int // Internal offset to track read progress
+ writeOffset int // Internal offset to track write progress
+ writeBuf []byte // Buffer for accumulating partial writes
}
func (nald *NewsArtListData) Read(p []byte) (int, error) {
@@ -136,6 +141,82 @@ func (nald *NewsArtListData) Read(p []byte) (int, error) {
return n, nil
}
+func (nald *NewsArtListData) Write(p []byte) (int, error) {
+ // Accumulate incoming bytes into the write buffer
+ nald.writeBuf = append(nald.writeBuf, p...)
+ bytesConsumed := len(p)
+
+ // If we've already parsed the header (writeOffset > 0), just accumulate article list data
+ if nald.writeOffset > 0 {
+ // Append new data to existing NewsArtList
+ nald.NewsArtList = append(nald.NewsArtList, p...)
+ return bytesConsumed, nil
+ }
+
+ // Minimum size: ID(4) + Count(4) + NameLen(1) + DescLen(1) = 10 bytes
+ if len(nald.writeBuf) < 10 {
+ return bytesConsumed, nil // Need more data
+ }
+
+ offset := 0
+
+ // Read ID (4 bytes)
+ copy(nald.ID[:], nald.writeBuf[offset:offset+4])
+ offset += 4
+
+ // Read Count (4 bytes)
+ nald.Count = int(binary.BigEndian.Uint32(nald.writeBuf[offset : offset+4]))
+ offset += 4
+
+ // Read Name length (1 byte)
+ nameLen := int(nald.writeBuf[offset])
+ offset += 1
+
+ // Check if we have enough data for Name
+ if len(nald.writeBuf) < offset+nameLen {
+ return bytesConsumed, nil // Need more data
+ }
+
+ // Read Name (nameLen bytes)
+ nald.Name = make([]byte, nameLen)
+ copy(nald.Name, nald.writeBuf[offset:offset+nameLen])
+ offset += nameLen
+
+ // Check if we have Description length byte
+ if len(nald.writeBuf) < offset+1 {
+ return bytesConsumed, nil // Need more data
+ }
+
+ // Read Description length (1 byte)
+ descLen := int(nald.writeBuf[offset])
+ offset += 1
+
+ // Check if we have enough data for Description
+ if len(nald.writeBuf) < offset+descLen {
+ return bytesConsumed, nil // Need more data
+ }
+
+ // Read Description (descLen bytes)
+ nald.Description = make([]byte, descLen)
+ copy(nald.Description, nald.writeBuf[offset:offset+descLen])
+ offset += descLen
+
+ // Read remaining bytes as NewsArtList
+ if len(nald.writeBuf) > offset {
+ nald.NewsArtList = make([]byte, len(nald.writeBuf)-offset)
+ copy(nald.NewsArtList, nald.writeBuf[offset:])
+ } else {
+ nald.NewsArtList = []byte{}
+ }
+
+ // Mark that we've successfully parsed the header
+ nald.writeOffset = offset
+ // Clear the buffer as we've successfully parsed all data
+ nald.writeBuf = nil
+
+ return bytesConsumed, nil
+}
+
// NewsArtList is a summarized version of a NewArtData record for display in list view
type NewsArtList struct {
ID [4]byte
@@ -223,6 +304,72 @@ func (newscat *NewsCategoryListData15) Read(p []byte) (int, error) {
return n, nil
}
+func (newscat *NewsCategoryListData15) Write(p []byte) (int, error) {
+ // Accumulate incoming bytes into the write buffer
+ newscat.writeBuf = append(newscat.writeBuf, p...)
+ bytesConsumed := len(p)
+
+ // Minimum size: Type(2) + Count(2) + NameLen(1) = 5 bytes
+ if len(newscat.writeBuf) < 5 {
+ return bytesConsumed, nil // Need more data
+ }
+
+ offset := 0
+
+ // Read Type (2 bytes)
+ copy(newscat.Type[:], newscat.writeBuf[offset:offset+2])
+ offset += 2
+
+ // Read count (2 bytes) - stored but not directly used as it's derived from maps
+ _ = binary.BigEndian.Uint16(newscat.writeBuf[offset : offset+2])
+ offset += 2
+
+ // If Type is NewsCategory, read GUID, AddSN, DeleteSN
+ if newscat.Type == NewsCategory {
+ // Need additional 24 bytes: GUID(16) + AddSN(4) + DeleteSN(4)
+ if len(newscat.writeBuf) < offset+24 {
+ return bytesConsumed, nil // Need more data
+ }
+
+ copy(newscat.GUID[:], newscat.writeBuf[offset:offset+16])
+ offset += 16
+
+ copy(newscat.AddSN[:], newscat.writeBuf[offset:offset+4])
+ offset += 4
+
+ copy(newscat.DeleteSN[:], newscat.writeBuf[offset:offset+4])
+ offset += 4
+ }
+
+ // Read name length (1 byte)
+ if len(newscat.writeBuf) < offset+1 {
+ return bytesConsumed, nil // Need more data
+ }
+ nameLen := int(newscat.writeBuf[offset])
+ offset += 1
+
+ // Read name (nameLen bytes)
+ if len(newscat.writeBuf) < offset+nameLen {
+ return bytesConsumed, nil // Need more data
+ }
+ newscat.Name = string(newscat.writeBuf[offset : offset+nameLen])
+ offset += nameLen
+
+ // Initialize maps if needed
+ if newscat.Articles == nil {
+ newscat.Articles = make(map[uint32]*NewsArtData)
+ }
+ if newscat.SubCats == nil {
+ newscat.SubCats = make(map[string]NewsCategoryListData15)
+ }
+
+ // Clear the buffer as we've successfully parsed all expected data
+ newscat.writeBuf = newscat.writeBuf[offset:]
+ newscat.writeOffset = offset
+
+ return bytesConsumed, nil
+}
+
func (newscat *NewsCategoryListData15) nameLen() []byte {
return []byte{uint8(len(newscat.Name))}
}
diff --git a/hotline/news_test.go b/hotline/news_test.go
index 46df2e3..2af3cd2 100644
--- a/hotline/news_test.go
+++ b/hotline/news_test.go
@@ -270,6 +270,306 @@ func TestNewsArtListData_Read(t *testing.T) {
}
}
+func TestNewsArtListData_Write(t *testing.T) {
+ tests := []struct {
+ name string
+ input []byte
+ wantID [4]byte
+ wantCount int
+ wantName []byte
+ wantDesc []byte
+ wantArtList []byte
+ wantN int
+ wantErr bool
+ }{
+ {
+ name: "basic data",
+ input: []byte{
+ 0x00, 0x01, 0x02, 0x03, // ID
+ 0x00, 0x00, 0x00, 0x05, // Count: 5
+ 0x04, // Name length: 4
+ 0x74, 0x65, 0x73, 0x74, // Name: "test"
+ 0x04, // Description length: 4
+ 0x64, 0x65, 0x73, 0x63, // Description: "desc"
+ },
+ wantID: [4]byte{0x00, 0x01, 0x02, 0x03},
+ wantCount: 5,
+ wantName: []byte("test"),
+ wantDesc: []byte("desc"),
+ wantArtList: []byte{},
+ wantN: 18,
+ wantErr: false,
+ },
+ {
+ name: "with article list",
+ input: []byte{
+ 0x00, 0x01, 0x02, 0x03, // ID
+ 0x00, 0x00, 0x00, 0x01, // Count: 1
+ 0x04, // Name length: 4
+ 0x74, 0x65, 0x73, 0x74, // Name: "test"
+ 0x04, // Description length: 4
+ 0x64, 0x65, 0x73, 0x63, // Description: "desc"
+ 0xAA, 0xBB, 0xCC, // NewsArtList data
+ },
+ wantID: [4]byte{0x00, 0x01, 0x02, 0x03},
+ wantCount: 1,
+ wantName: []byte("test"),
+ wantDesc: []byte("desc"),
+ wantArtList: []byte{0xAA, 0xBB, 0xCC},
+ wantN: 21,
+ wantErr: false,
+ },
+ {
+ name: "empty name and description",
+ input: []byte{
+ 0xFF, 0xFE, 0xFD, 0xFC, // ID
+ 0x00, 0x00, 0x00, 0x00, // Count: 0
+ 0x00, // Name length: 0
+ 0x00, // Description length: 0
+ },
+ wantID: [4]byte{0xFF, 0xFE, 0xFD, 0xFC},
+ wantCount: 0,
+ wantName: []byte{},
+ wantDesc: []byte{},
+ wantArtList: []byte{},
+ wantN: 10,
+ wantErr: false,
+ },
+ {
+ name: "long name and description",
+ input: []byte{
+ 0x00, 0x00, 0x00, 0x00, // ID
+ 0x00, 0x00, 0x00, 0x0A, // Count: 10
+ 0x0A, // Name length: 10
+ 0x4C, 0x6F, 0x6E, 0x67, 0x65, 0x72, 0x4E, 0x61, 0x6D, 0x65, // Name: "LongerName"
+ 0x0B, // Description length: 11
+ 0x44, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6F, 0x6E, // Description: "Description"
+ },
+ wantID: [4]byte{0x00, 0x00, 0x00, 0x00},
+ wantCount: 10,
+ wantName: []byte("LongerName"),
+ wantDesc: []byte("Description"),
+ wantArtList: []byte{},
+ wantN: 31, // 4 (ID) + 4 (count) + 1 (name len) + 10 (name) + 1 (desc len) + 11 (desc)
+ wantErr: false,
+ },
+ {
+ name: "with large article list",
+ input: []byte{
+ 0x12, 0x34, 0x56, 0x78, // ID
+ 0x00, 0x00, 0x00, 0x03, // Count: 3
+ 0x02, // Name length: 2
+ 0x41, 0x42, // Name: "AB"
+ 0x02, // Description length: 2
+ 0x43, 0x44, // Description: "CD"
+ 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, // NewsArtList: 10 bytes
+ },
+ wantID: [4]byte{0x12, 0x34, 0x56, 0x78},
+ wantCount: 3,
+ wantName: []byte("AB"),
+ wantDesc: []byte("CD"),
+ wantArtList: []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A},
+ wantN: 24,
+ wantErr: false,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ nald := &NewsArtListData{}
+ gotN, err := nald.Write(tt.input)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("Write() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+ assert.Equal(t, tt.wantN, gotN)
+ assert.Equal(t, tt.wantID, nald.ID)
+ assert.Equal(t, tt.wantCount, nald.Count)
+ assert.Equal(t, tt.wantName, nald.Name)
+ assert.Equal(t, tt.wantDesc, nald.Description)
+ assert.Equal(t, tt.wantArtList, nald.NewsArtList)
+ })
+ }
+}
+
+func TestNewsArtListData_Write_PartialData(t *testing.T) {
+ tests := []struct {
+ name string
+ chunks [][]byte
+ wantID [4]byte
+ wantCount int
+ wantName []byte
+ wantDesc []byte
+ wantArtList []byte
+ finalBytes int
+ }{
+ {
+ name: "split across ID boundary",
+ chunks: [][]byte{
+ {0x00, 0x01}, // First 2 bytes of ID
+ {0x02, 0x03}, // Last 2 bytes of ID
+ {0x00, 0x00, 0x00, 0x02}, // Count: 2
+ {0x03}, // Name length: 3
+ {0x66, 0x6f, 0x6f}, // Name: "foo"
+ {0x03}, // Description length: 3
+ {0x62, 0x61, 0x72}, // Description: "bar"
+ },
+ wantID: [4]byte{0x00, 0x01, 0x02, 0x03},
+ wantCount: 2,
+ wantName: []byte("foo"),
+ wantDesc: []byte("bar"),
+ wantArtList: []byte{},
+ finalBytes: 16, // 2 + 2 + 4 + 1 + 3 + 1 + 3
+ },
+ {
+ name: "split across name",
+ chunks: [][]byte{
+ {0x00, 0x00, 0x00, 0x00}, // ID
+ {0x00, 0x00, 0x00, 0x01}, // Count: 1
+ {0x05}, // Name length: 5
+ {0x68, 0x65}, // "he"
+ {0x6c, 0x6c, 0x6f}, // "llo"
+ {0x00}, // Description length: 0
+ },
+ wantID: [4]byte{0x00, 0x00, 0x00, 0x00},
+ wantCount: 1,
+ wantName: []byte("hello"),
+ wantDesc: []byte{},
+ wantArtList: []byte{},
+ finalBytes: 15,
+ },
+ {
+ name: "split with article list",
+ chunks: [][]byte{
+ {0xAA, 0xBB, 0xCC, 0xDD}, // ID
+ {0x00, 0x00, 0x00, 0x05}, // Count: 5
+ {0x01, 0x41}, // Name length: 1, Name: "A"
+ {0x01, 0x42}, // Description length: 1, Description: "B"
+ {0x11, 0x22}, // Article list part 1
+ {0x33, 0x44, 0x55}, // Article list part 2
+ },
+ wantID: [4]byte{0xAA, 0xBB, 0xCC, 0xDD},
+ wantCount: 5,
+ wantName: []byte("A"),
+ wantDesc: []byte("B"),
+ wantArtList: []byte{0x11, 0x22, 0x33, 0x44, 0x55},
+ finalBytes: 17,
+ },
+ {
+ name: "single byte chunks",
+ chunks: [][]byte{
+ {0x01}, {0x02}, {0x03}, {0x04}, // ID
+ {0x00}, {0x00}, {0x00}, {0x00}, // Count: 0
+ {0x02}, // Name length: 2
+ {0x41}, {0x42}, // Name: "AB"
+ {0x00}, // Description length: 0
+ },
+ wantID: [4]byte{0x01, 0x02, 0x03, 0x04},
+ wantCount: 0,
+ wantName: []byte("AB"),
+ wantDesc: []byte{},
+ wantArtList: []byte{},
+ finalBytes: 12,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ nald := &NewsArtListData{}
+ totalBytes := 0
+ for _, chunk := range tt.chunks {
+ n, err := nald.Write(chunk)
+ assert.NoError(t, err)
+ assert.Equal(t, len(chunk), n)
+ totalBytes += n
+ }
+ assert.Equal(t, tt.finalBytes, totalBytes)
+ assert.Equal(t, tt.wantID, nald.ID)
+ assert.Equal(t, tt.wantCount, nald.Count)
+ assert.Equal(t, tt.wantName, nald.Name)
+ assert.Equal(t, tt.wantDesc, nald.Description)
+ assert.Equal(t, tt.wantArtList, nald.NewsArtList)
+ })
+ }
+}
+
+func TestNewsArtListData_WriteRead_RoundTrip(t *testing.T) {
+ tests := []struct {
+ name string
+ nald NewsArtListData
+ wantErr bool
+ }{
+ {
+ name: "basic round trip",
+ nald: NewsArtListData{
+ ID: [4]byte{0x00, 0x01, 0x02, 0x03},
+ Name: []byte("Test Name"),
+ Description: []byte("Test Description"),
+ NewsArtList: []byte{},
+ Count: 0,
+ },
+ wantErr: false,
+ },
+ {
+ name: "with article list",
+ nald: NewsArtListData{
+ ID: [4]byte{0xFF, 0xEE, 0xDD, 0xCC},
+ Name: []byte("Articles"),
+ Description: []byte("Article Description"),
+ NewsArtList: []byte{0x01, 0x02, 0x03, 0x04, 0x05},
+ Count: 5,
+ },
+ wantErr: false,
+ },
+ {
+ name: "empty fields",
+ nald: NewsArtListData{
+ ID: [4]byte{0x00, 0x00, 0x00, 0x00},
+ Name: []byte{},
+ Description: []byte{},
+ NewsArtList: []byte{},
+ Count: 0,
+ },
+ wantErr: false,
+ },
+ {
+ name: "max length name and description",
+ nald: NewsArtListData{
+ ID: [4]byte{0x12, 0x34, 0x56, 0x78},
+ Name: []byte("This is a very long name for testing purposes with lots of characters"),
+ Description: []byte("This is an equally long description to ensure we handle variable length fields properly"),
+ NewsArtList: []byte{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF},
+ Count: 100,
+ },
+ wantErr: false,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ // Read the struct into bytes
+ data, err := io.ReadAll(&tt.nald)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("ReadAll() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+
+ // Write the bytes back into a new struct
+ result := &NewsArtListData{}
+ n, err := result.Write(data)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("Write() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+
+ // Verify the round trip
+ assert.Equal(t, len(data), n)
+ assert.Equal(t, tt.nald.ID, result.ID)
+ assert.Equal(t, tt.nald.Count, result.Count)
+ assert.Equal(t, tt.nald.Name, result.Name)
+ assert.Equal(t, tt.nald.Description, result.Description)
+ assert.Equal(t, tt.nald.NewsArtList, result.NewsArtList)
+ })
+ }
+}
+
func TestNewsArtList_Read(t *testing.T) {
tests := []struct {
name string
@@ -368,3 +668,217 @@ func TestNewsPathScanner(t *testing.T) {
})
}
}
+
+func TestNewsCategoryListData15_Write(t *testing.T) {
+ tests := []struct {
+ name string
+ input []byte
+ wantType [2]byte
+ wantName string
+ wantGUID [16]byte
+ wantN int
+ wantErr bool
+ }{
+ {
+ name: "bundle type with name",
+ input: []byte{
+ 0x00, 0x02, // Type: Bundle
+ 0x00, 0x01, // Count: 1
+ 0x03, // Name length: 3
+ 0x66, 0x6f, 0x6f, // Name: "foo"
+ },
+ wantType: [2]byte{0x00, 0x02},
+ wantName: "foo",
+ wantN: 8, // 2 (type) + 2 (count) + 1 (name len) + 3 (name)
+ wantErr: false,
+ },
+ {
+ name: "category type with GUID, AddSN, DeleteSN",
+ input: []byte{
+ 0x00, 0x03, // Type: Category
+ 0x00, 0x01, // Count: 1
+ 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, // GUID part 1
+ 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x00, // GUID part 2
+ 0x01, 0x02, 0x03, 0x04, // AddSN
+ 0x05, 0x06, 0x07, 0x08, // DeleteSN
+ 0x03, // Name length: 3
+ 0x62, 0x61, 0x72, // Name: "bar"
+ },
+ wantType: [2]byte{0x00, 0x03},
+ wantName: "bar",
+ wantGUID: [16]byte{0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x00},
+ wantN: 32, // 2 (type) + 2 (count) + 16 (GUID) + 4 (AddSN) + 4 (DeleteSN) + 1 (name len) + 3 (name)
+ wantErr: false,
+ },
+ {
+ name: "empty name",
+ input: []byte{
+ 0x00, 0x02, // Type: Bundle
+ 0x00, 0x00, // Count: 0
+ 0x00, // Name length: 0
+ },
+ wantType: [2]byte{0x00, 0x02},
+ wantName: "",
+ wantN: 5,
+ wantErr: false,
+ },
+ {
+ name: "long name",
+ input: []byte{
+ 0x00, 0x02, // Type: Bundle
+ 0x00, 0x05, // Count: 5
+ 0x0a, // Name length: 10
+ 0x4c, 0x6f, 0x6e, 0x67, 0x65, 0x72, 0x4e, 0x61, 0x6d, 0x65, // Name: "LongerName"
+ },
+ wantType: [2]byte{0x00, 0x02},
+ wantName: "LongerName",
+ wantN: 15,
+ wantErr: false,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ newscat := &NewsCategoryListData15{}
+ gotN, err := newscat.Write(tt.input)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("Write() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+ assert.Equal(t, tt.wantN, gotN)
+ assert.Equal(t, tt.wantType, newscat.Type)
+ assert.Equal(t, tt.wantName, newscat.Name)
+ if tt.wantType == NewsCategory {
+ assert.Equal(t, tt.wantGUID, newscat.GUID)
+ }
+ assert.NotNil(t, newscat.Articles)
+ assert.NotNil(t, newscat.SubCats)
+ })
+ }
+}
+
+func TestNewsCategoryListData15_WriteRead_RoundTrip(t *testing.T) {
+ tests := []struct {
+ name string
+ newscat NewsCategoryListData15
+ wantErr bool
+ }{
+ {
+ name: "bundle round trip",
+ newscat: NewsCategoryListData15{
+ Type: NewsBundle,
+ Name: "Test Bundle",
+ Articles: map[uint32]*NewsArtData{
+ 1: {Title: "Article 1"},
+ },
+ SubCats: make(map[string]NewsCategoryListData15),
+ },
+ wantErr: false,
+ },
+ {
+ name: "category round trip",
+ newscat: NewsCategoryListData15{
+ Type: NewsCategory,
+ Name: "Test Category",
+ GUID: [16]byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10},
+ AddSN: [4]byte{0x00, 0x00, 0x00, 0x01},
+ DeleteSN: [4]byte{0x00, 0x00, 0x00, 0x02},
+ Articles: make(map[uint32]*NewsArtData),
+ SubCats: map[string]NewsCategoryListData15{
+ "subcat1": {
+ Type: NewsBundle,
+ Name: "Subcategory",
+ },
+ },
+ },
+ wantErr: false,
+ },
+ {
+ name: "empty name round trip",
+ newscat: NewsCategoryListData15{
+ Type: NewsBundle,
+ Name: "",
+ Articles: make(map[uint32]*NewsArtData),
+ SubCats: make(map[string]NewsCategoryListData15),
+ },
+ wantErr: false,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ // Read the struct into bytes
+ data, err := io.ReadAll(&tt.newscat)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("ReadAll() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+
+ // Write the bytes back into a new struct
+ result := &NewsCategoryListData15{}
+ n, err := result.Write(data)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("Write() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+
+ // Verify the round trip
+ assert.Equal(t, len(data), n)
+ assert.Equal(t, tt.newscat.Type, result.Type)
+ assert.Equal(t, tt.newscat.Name, result.Name)
+ if tt.newscat.Type == NewsCategory {
+ assert.Equal(t, tt.newscat.GUID, result.GUID)
+ assert.Equal(t, tt.newscat.AddSN, result.AddSN)
+ assert.Equal(t, tt.newscat.DeleteSN, result.DeleteSN)
+ }
+ })
+ }
+}
+
+func TestNewsCategoryListData15_Write_PartialData(t *testing.T) {
+ tests := []struct {
+ name string
+ chunks [][]byte
+ wantType [2]byte
+ wantName string
+ finalBytes int
+ }{
+ {
+ name: "split across type boundary",
+ chunks: [][]byte{
+ {0x00}, // First byte of type
+ {0x02}, // Second byte of type
+ {0x00, 0x01}, // Count
+ {0x03}, // Name length
+ {0x66, 0x6f, 0x6f}, // Name: "foo"
+ },
+ wantType: [2]byte{0x00, 0x02},
+ wantName: "foo",
+ finalBytes: 8, // 1 + 1 + 2 + 1 + 3
+ },
+ {
+ name: "split across name",
+ chunks: [][]byte{
+ {0x00, 0x02, 0x00, 0x01, 0x05}, // Type, count, name length: 5
+ {0x68, 0x65}, // "he"
+ {0x6c, 0x6c, 0x6f}, // "llo"
+ },
+ wantType: [2]byte{0x00, 0x02},
+ wantName: "hello",
+ finalBytes: 10,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ newscat := &NewsCategoryListData15{}
+ totalBytes := 0
+ for _, chunk := range tt.chunks {
+ n, err := newscat.Write(chunk)
+ assert.NoError(t, err)
+ assert.Equal(t, len(chunk), n)
+ totalBytes += n
+ }
+ assert.Equal(t, tt.finalBytes, totalBytes)
+ assert.Equal(t, tt.wantType, newscat.Type)
+ assert.Equal(t, tt.wantName, newscat.Name)
+ })
+ }
+}
diff --git a/hotline/transaction.go b/hotline/transaction.go
index a03030d..068a083 100644
--- a/hotline/transaction.go
+++ b/hotline/transaction.go
@@ -97,6 +97,7 @@ var tranTypeNames = map[TranType]string{
TranUserAccess: "User access",
TranNotifyDeleteUser: "User left",
TranAgreed: "Accept agreement",
+ TranLogin: "Log In",
TranChatSend: "Send chat",
TranDelNewsArt: "Delete news article",
TranDelNewsItem: "Delete news item",