This commit is contained in:
Ilia Denisov
2025-09-03 18:43:28 +03:00
commit 31957206b8
40 changed files with 2947 additions and 0 deletions
+195
View File
@@ -0,0 +1,195 @@
package repo
import (
"15-puzzle/internal/model"
"cmp"
"context"
"encoding/json"
"fmt"
"maps"
"os"
"path"
"slices"
"sync"
"time"
)
type FileRepo struct {
dataFile string
latch sync.RWMutex
data *model.Data
}
func NewFileRepo(ctx context.Context, dataFile string) (*FileRepo, error) {
dataFile = path.Clean(dataFile)
r := &FileRepo{
dataFile: dataFile,
data: &model.Data{Users: make(map[int]model.User)},
}
b, err := os.ReadFile(dataFile)
if err != nil && !os.IsNotExist(err) {
return nil, err
}
if len(b) == 0 {
// init with empty writeable file
if err := r.withData(func(d *model.Data) {}); err != nil {
return nil, err
}
return r, nil
}
if err := json.Unmarshal(b, &r.data); err != nil {
return nil, err
}
return r, nil
}
func (r *FileRepo) Monitoring() (model.Monitoring, error) {
r.latch.RLock()
defer r.latch.RUnlock()
m := &model.Monitoring{Users: len(r.data.Users)}
for i := range r.data.Users {
m.GamesStarted += r.data.Users[i].GamesStarted
m.GamesSolved += r.data.Users[i].GamesSolved
}
return *m, nil
}
func (r *FileRepo) Stats(UserID int) (model.User, error) {
r.latch.RLock()
defer r.latch.RUnlock()
user, ok := r.data.Users[UserID]
if !ok {
return model.User{}, fmt.Errorf("user not found: user_id=%d", UserID)
}
return user, nil
}
func (r *FileRepo) AddUser(UserID int) error {
if err := r.withUser(UserID, func(*model.User) {}); err != nil {
return err
}
return nil
}
func (r *FileRepo) RegisterGameStart(UserID int) (model.User, error) {
var result model.User
if err := r.withUser(UserID, func(u *model.User) {
u.GamesStarted++
ts := model.JSONTimestamp(time.Now().UTC())
u.LastStartTime = &ts
result = *u
}); err != nil {
return result, err
}
return result, nil
}
func (r *FileRepo) RegisterGameSolve(UserID, moves int) (model.User, error) {
var result model.User
if err := r.withUser(UserID, func(u *model.User) {
u.GamesSolved++
if u.LastStartTime != nil {
moveAverage := float32(time.Since(time.Time(*u.LastStartTime)).Seconds() / float64(moves))
if u.BestResult == nil || moveAverage < *u.BestResult {
ts := model.JSONTimestamp(time.Now().UTC())
u.BestSolveTime = &ts
u.BestResult = &moveAverage
u.LastStartTime = nil
}
}
result = *u
}); err != nil {
return result, err
}
return result, nil
}
func (r *FileRepo) Rating() []int {
r.latch.RLock()
defer r.latch.RUnlock()
return slices.SortedFunc(maps.Keys(r.data.Users), func(a, b int) int { return SortRating(r.data.Users[a], r.data.Users[b]) })
}
func SortRating(a, b model.User) int {
return cmp.Or(
compareBest(a.BestResult, b.BestResult), // lower value is higher
compareTs(a.BestSolveTime, b.BestSolveTime), // earlier value is higher
cmp.Compare(b.GamesSolved, a.GamesSolved), // greater value is higher
cmp.Compare(a.UserID, b.UserID), // sadly, compare user ids when nobody has solved just once
)
}
func compareBest(a, b *float32) int {
switch {
case a == nil && b == nil:
return 0
case a == nil:
return +1
case b == nil:
return -1
default:
return cmp.Compare(*a, *b)
}
}
func compareTs(a, b *model.JSONTimestamp) int {
switch {
case a == nil && b == nil:
return 0
case a == nil:
return +1
case b == nil:
return -1
default:
return time.Time(*a).Compare(time.Time(*b))
}
}
func (r *FileRepo) withUser(userID int, acceptor func(d *model.User)) error {
return r.withData(func(d *model.Data) {
user, ok := d.Users[userID]
if !ok {
user = model.User{UserID: userID}
}
acceptor(&user)
d.Users[userID] = user
})
}
func (r *FileRepo) withData(acceptor func(d *model.Data)) error {
r.latch.Lock()
defer r.latch.Unlock()
acceptor(r.data)
b, err := json.Marshal(r.data)
if err != nil {
return fmt.Errorf("data marshall: %s", err)
}
repoDir := path.Dir(r.dataFile)
tf, err := os.CreateTemp(repoDir, "15-puzzle")
if err != nil {
return fmt.Errorf("create temp file at %s: %s", repoDir, err)
}
if _, err := tf.Write(b); err != nil {
return fmt.Errorf("write %s: %s", tf.Name(), err)
}
if err := tf.Close(); err != nil {
return fmt.Errorf("close %s to %s: %s", tf.Name(), r.dataFile, err)
}
if err := os.Rename(tf.Name(), r.dataFile); err != nil {
return fmt.Errorf("rename %s to %s: %s", tf.Name(), r.dataFile, err)
}
return nil
}
+154
View File
@@ -0,0 +1,154 @@
package repo_test
import (
"15-puzzle/internal/model"
"15-puzzle/internal/repo"
"context"
"maps"
"math/rand/v2"
"os"
"slices"
"testing"
"time"
)
func ref(t time.Time) *model.JSONTimestamp {
if t.IsZero() {
return nil
}
r := model.JSONTimestamp(t)
return &r
}
func TestSortRating(t *testing.T) {
fp := func(v float32) *float32 { return &v }
users := map[int]model.User{
1: {UserID: 1, BestResult: fp(5), GamesStarted: 7, GamesSolved: 5, BestSolveTime: ref(time.Now().Add(-time.Second * 3))},
2: {UserID: 2, BestResult: fp(5), GamesStarted: 7, GamesSolved: 4, BestSolveTime: ref(time.Now().Add(-time.Second * 3))},
3: {UserID: 3, BestResult: fp(5), GamesStarted: 7, GamesSolved: 3, BestSolveTime: ref(time.Now().Add(-time.Second * 3))},
4: {UserID: 4, BestResult: fp(5), GamesStarted: 4, GamesSolved: 3, BestSolveTime: ref(time.Now().Add(-time.Second * 2))},
5: {UserID: 5, BestResult: fp(5), GamesStarted: 4, GamesSolved: 3, BestSolveTime: ref(time.Now().Add(-time.Second * 1))},
6: {UserID: 6, BestResult: fp(6), GamesStarted: 4, GamesSolved: 3, BestSolveTime: ref(time.Now().Add(-time.Second * 1))},
7: {UserID: 7, BestResult: fp(7), GamesStarted: 3, GamesSolved: 0, BestSolveTime: ref(time.Time{})},
}
keys := slices.Collect(maps.Keys(users))
rand.Shuffle(len(keys), func(i, j int) { keys[i], keys[j] = keys[j], keys[i] })
result := slices.SortedFunc(slices.Values(keys), func(a, b int) int { return repo.SortRating(users[a], users[b]) })
expected := []int{1, 2, 3, 4, 5, 6, 7}
if slices.Compare(expected, result) != 0 {
t.Errorf("rating not correct\nexpected: %v\nactual: %v", expected, result)
}
}
func TestRepo(t *testing.T) {
testWithNewRepo(t, func(t *testing.T, r *repo.FileRepo) { assertRating(t, []int{}, r) })
testWithNewRepo(t, testPlayersAndRatings)
}
func testWithNewRepo(t *testing.T, test func(t *testing.T, r *repo.FileRepo)) {
f, err := os.CreateTemp("", "puzzle15-repo-test")
if err != nil {
t.Fatalf("temporary file create: %s", err)
}
if err := f.Close(); err != nil {
t.Fatalf("temporary file close: %s", err)
}
if err := os.Remove(f.Name()); err != nil {
t.Fatalf("temporary file remove before: %s", err)
}
defer func() {
if err := os.Remove(f.Name()); err != nil {
t.Errorf("temporary file remove after: %s", err)
}
}()
testWithRepo(t, f.Name(), test)
}
func testWithRepo(t *testing.T, file string, test func(t *testing.T, r *repo.FileRepo)) {
r, err := repo.NewFileRepo(context.Background(), file)
if err != nil {
t.Fatalf("repo init: %s", err)
}
test(t, r)
}
func testPlayersAndRatings(t *testing.T, r *repo.FileRepo) {
assertRating(t, []int{}, r)
testUserOne := 1
assertRegisterGameStart(t, testUserOne, r,
model.User{UserID: testUserOne, GamesStarted: 1, GamesSolved: 0, BestSolveTime: ref(time.Time{})})
assertRating(t, []int{testUserOne}, r)
testUserTwo := 2
assertRegisterGameStart(t, testUserTwo, r,
model.User{UserID: testUserTwo, GamesStarted: 1, GamesSolved: 0, BestSolveTime: ref(time.Time{})})
assertRating(t, []int{testUserOne, testUserTwo}, r)
testUserThree := 3
assertRegisterGameStart(t, testUserThree, r,
model.User{UserID: testUserThree, GamesStarted: 1, GamesSolved: 0, BestSolveTime: ref(time.Time{})})
assertRegisterGameSolve(t, testUserThree, 30, r,
model.User{UserID: testUserThree, GamesStarted: 1, GamesSolved: 1, BestSolveTime: ref(time.Now())})
assertRating(t, []int{testUserThree, testUserOne, testUserTwo}, r)
assertRegisterGameSolve(t, testUserTwo, 20, r,
model.User{UserID: testUserTwo, GamesStarted: 1, GamesSolved: 1, BestSolveTime: ref(time.Now())})
assertRating(t, []int{testUserThree, testUserTwo, testUserOne}, r)
assertRegisterGameStart(t, testUserTwo, r,
model.User{UserID: testUserTwo, GamesStarted: 2, GamesSolved: 1, BestSolveTime: ref(time.Now())})
assertRegisterGameSolve(t, testUserTwo, 10, r,
model.User{UserID: testUserTwo, GamesStarted: 2, GamesSolved: 2, BestSolveTime: ref(time.Now())})
assertRating(t, []int{testUserThree, testUserTwo, testUserOne}, r)
}
func assertUserHaveValues(t *testing.T, expected, actual model.User) {
if expected.UserID != actual.UserID {
t.Errorf("expect UserID=%d, actual: %d", expected.UserID, actual.UserID)
}
if expected.GamesStarted != actual.GamesStarted {
t.Errorf("expect GamesStarted=%d, actual: %d", expected.GamesStarted, actual.GamesStarted)
}
if expected.GamesSolved != actual.GamesSolved {
t.Errorf("expect GamesSolved=%d, actual: %d", expected.GamesSolved, actual.GamesSolved)
}
expectedBst := time.Time{}
if expected.BestSolveTime != nil {
expectedBst = time.Time(*expected.BestSolveTime)
}
actualLst := time.Time{}
if actual.BestSolveTime != nil {
actualLst = time.Time(*actual.BestSolveTime)
}
var delta time.Duration = time.Millisecond * 20
if actualLst.After(expectedBst.Add(delta*time.Millisecond)) ||
actualLst.Before(expectedBst.Add(-delta*time.Millisecond)) {
t.Errorf("expect BestSolveTime=%v, actual: %v, delta: %d", expectedBst, actualLst, delta)
}
}
func assertRegisterGameStart(t *testing.T, UserID int, r *repo.FileRepo, expected model.User) {
u, err := r.RegisterGameStart(UserID)
if err != nil {
t.Fatalf("RegisterGameStart: %s", err)
}
assertUserHaveValues(t, expected, u)
}
func assertRegisterGameSolve(t *testing.T, UserID, moves int, r *repo.FileRepo, expected model.User) {
u, err := r.RegisterGameSolve(UserID, moves)
if err != nil {
t.Fatalf("RegisterGameSolve: %s", err)
}
assertUserHaveValues(t, expected, u)
}
func assertRating(t *testing.T, expected []int, r *repo.FileRepo) {
actual := r.Rating()
if slices.Compare(expected, actual) != 0 {
t.Errorf("get rating: wrong value\nexpected: %#v\nactual: %#v", expected, actual)
}
}