Initial commit
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const PaletteSize = 16
|
||||
|
||||
type Row []uint8
|
||||
|
||||
func (r Row) MarshalJSON() ([]byte, error) {
|
||||
vals := make([]int, len(r))
|
||||
for i, v := range r {
|
||||
vals[i] = int(v)
|
||||
}
|
||||
return json.Marshal(vals)
|
||||
}
|
||||
|
||||
func (r *Row) UnmarshalJSON(data []byte) error {
|
||||
var vals []int
|
||||
if err := json.Unmarshal(data, &vals); err != nil {
|
||||
return err
|
||||
}
|
||||
row := make(Row, len(vals))
|
||||
for i, v := range vals {
|
||||
if v < 0 || v > 255 {
|
||||
return fmt.Errorf("pixel value %d out of range", v)
|
||||
}
|
||||
row[i] = uint8(v)
|
||||
}
|
||||
*r = row
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
ErrOutOfBounds = errors.New("coordinates out of bounds")
|
||||
ErrInvalidColor = errors.New("invalid color index")
|
||||
ErrImageTooBig = errors.New("image too large")
|
||||
ErrEmptyMessage = errors.New("message is empty")
|
||||
ErrMessageLong = errors.New("message too long")
|
||||
)
|
||||
|
||||
type Message struct {
|
||||
Text string `json:"text"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
type State struct {
|
||||
mu sync.RWMutex
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
Pixels []Row `json:"pixels"`
|
||||
Messages []Message `json:"messages"`
|
||||
maxMessages int
|
||||
maxMsgLen int
|
||||
maxImageDim int
|
||||
}
|
||||
|
||||
func New(width, height int) *State {
|
||||
pixels := make([]Row, height)
|
||||
for i := range pixels {
|
||||
pixels[i] = make(Row, width)
|
||||
}
|
||||
return &State{
|
||||
Width: width,
|
||||
Height: height,
|
||||
Pixels: pixels,
|
||||
Messages: []Message{},
|
||||
maxMessages: 50,
|
||||
maxMsgLen: 140,
|
||||
maxImageDim: 32,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *State) CheckPixel(x, y int, color uint8) error {
|
||||
if x < 0 || x >= s.Width || y < 0 || y >= s.Height {
|
||||
return ErrOutOfBounds
|
||||
}
|
||||
if color >= PaletteSize {
|
||||
return ErrInvalidColor
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *State) SetPixel(x, y int, color uint8) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if err := s.CheckPixel(x, y, color); err != nil {
|
||||
return err
|
||||
}
|
||||
s.Pixels[y][x] = color
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *State) StampImage(x, y int, pixels []Row) error {
|
||||
if len(pixels) == 0 || len(pixels) > s.maxImageDim {
|
||||
return ErrImageTooBig
|
||||
}
|
||||
for _, row := range pixels {
|
||||
if len(row) == 0 || len(row) > s.maxImageDim {
|
||||
return ErrImageTooBig
|
||||
}
|
||||
for _, c := range row {
|
||||
if c >= PaletteSize {
|
||||
return ErrInvalidColor
|
||||
}
|
||||
}
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for dy, row := range pixels {
|
||||
for dx, c := range row {
|
||||
px, py := x+dx, y+dy
|
||||
if px >= 0 && px < s.Width && py >= 0 && py < s.Height {
|
||||
s.Pixels[py][px] = c
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *State) AddMessage(text string) error {
|
||||
if text == "" {
|
||||
return ErrEmptyMessage
|
||||
}
|
||||
if len(text) > s.maxMsgLen {
|
||||
return ErrMessageLong
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.Messages = append(s.Messages, Message{Text: text, At: time.Now().UTC()})
|
||||
if len(s.Messages) > s.maxMessages {
|
||||
s.Messages = s.Messages[len(s.Messages)-s.maxMessages:]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Snapshot struct {
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
Pixels []Row `json:"pixels"`
|
||||
Messages []Message `json:"messages"`
|
||||
}
|
||||
|
||||
func (s *State) Snapshot() Snapshot {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
pixels := make([]Row, s.Height)
|
||||
for i, row := range s.Pixels {
|
||||
cp := make(Row, len(row))
|
||||
copy(cp, row)
|
||||
pixels[i] = cp
|
||||
}
|
||||
msgs := make([]Message, len(s.Messages))
|
||||
copy(msgs, s.Messages)
|
||||
return Snapshot{Width: s.Width, Height: s.Height, Pixels: pixels, Messages: msgs}
|
||||
}
|
||||
|
||||
func (s *State) Save(path string) error {
|
||||
snap := s.Snapshot()
|
||||
data, err := json.Marshal(snap)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, path)
|
||||
}
|
||||
|
||||
func Load(path string) (*State, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var snap Snapshot
|
||||
if err := json.Unmarshal(data, &snap); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if snap.Width <= 0 || snap.Height <= 0 || len(snap.Pixels) != snap.Height {
|
||||
return nil, fmt.Errorf("invalid snapshot dimensions")
|
||||
}
|
||||
for _, row := range snap.Pixels {
|
||||
if len(row) != snap.Width {
|
||||
return nil, fmt.Errorf("invalid snapshot row width")
|
||||
}
|
||||
}
|
||||
st := New(snap.Width, snap.Height)
|
||||
st.Pixels = snap.Pixels
|
||||
if snap.Messages != nil {
|
||||
st.Messages = snap.Messages
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSetPixel(t *testing.T) {
|
||||
s := New(8, 8)
|
||||
if err := s.SetPixel(3, 4, 5); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := s.Snapshot().Pixels[4][3]; got != 5 {
|
||||
t.Fatalf("got %d, want 5", got)
|
||||
}
|
||||
if err := s.SetPixel(8, 0, 1); err != ErrOutOfBounds {
|
||||
t.Fatalf("want ErrOutOfBounds, got %v", err)
|
||||
}
|
||||
if err := s.SetPixel(0, 0, 16); err != ErrInvalidColor {
|
||||
t.Fatalf("want ErrInvalidColor, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStampImage(t *testing.T) {
|
||||
s := New(4, 4)
|
||||
img := []Row{{1, 2}, {3, 4}}
|
||||
if err := s.StampImage(1, 1, img); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p := s.Snapshot().Pixels
|
||||
if p[1][1] != 1 || p[1][2] != 2 || p[2][1] != 3 || p[2][2] != 4 {
|
||||
t.Fatalf("unexpected pixels: %v", p)
|
||||
}
|
||||
if err := s.StampImage(3, 3, img); err != nil {
|
||||
t.Fatalf("clipping should succeed: %v", err)
|
||||
}
|
||||
p = s.Snapshot().Pixels
|
||||
if p[3][3] != 1 {
|
||||
t.Fatalf("clipped stamp wrong: %v", p)
|
||||
}
|
||||
big := make([]Row, 33)
|
||||
for i := range big {
|
||||
big[i] = make(Row, 1)
|
||||
}
|
||||
if err := s.StampImage(0, 0, big); err != ErrImageTooBig {
|
||||
t.Fatalf("want ErrImageTooBig, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessages(t *testing.T) {
|
||||
s := New(4, 4)
|
||||
if err := s.AddMessage(""); err != ErrEmptyMessage {
|
||||
t.Fatalf("want ErrEmptyMessage, got %v", err)
|
||||
}
|
||||
long := make([]byte, 141)
|
||||
for i := range long {
|
||||
long[i] = 'a'
|
||||
}
|
||||
if err := s.AddMessage(string(long)); err != ErrMessageLong {
|
||||
t.Fatalf("want ErrMessageLong, got %v", err)
|
||||
}
|
||||
for i := 0; i < 60; i++ {
|
||||
if err := s.AddMessage("hi"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if got := len(s.Snapshot().Messages); got != 50 {
|
||||
t.Fatalf("ring buffer size %d, want 50", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPixelsEncodeAsNumbers(t *testing.T) {
|
||||
s := New(2, 1)
|
||||
s.SetPixel(1, 0, 7)
|
||||
data, err := json.Marshal(s.Snapshot())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(data), `"pixels":[[0,7]]`) {
|
||||
t.Fatalf("pixels must encode as number arrays, got %s", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshotRoundtrip(t *testing.T) {
|
||||
s := New(16, 8)
|
||||
s.SetPixel(5, 5, 9)
|
||||
s.AddMessage("hello")
|
||||
path := filepath.Join(t.TempDir(), "snap.json")
|
||||
if err := s.Save(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snap := loaded.Snapshot()
|
||||
if snap.Width != 16 || snap.Height != 8 {
|
||||
t.Fatalf("dims %dx%d", snap.Width, snap.Height)
|
||||
}
|
||||
if snap.Pixels[5][5] != 9 {
|
||||
t.Fatalf("pixel not preserved")
|
||||
}
|
||||
if len(snap.Messages) != 1 || snap.Messages[0].Text != "hello" {
|
||||
t.Fatalf("messages not preserved: %v", snap.Messages)
|
||||
}
|
||||
if _, err := Load(filepath.Join(t.TempDir(), "missing.json")); !os.IsNotExist(err) {
|
||||
t.Fatalf("want not-exist error, got %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user