mirror of
https://github.com/IS1DI/x0gp.git
synced 2026-07-16 20:47:55 +00:00
Implement leaderboard service and POC HTTP handlers with Swagger documentation
This commit is contained in:
@@ -0,0 +1,453 @@
|
||||
package races
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// LeaderboardEntry is a single row in the leaderboard.
|
||||
type LeaderboardEntry struct {
|
||||
DriverID string
|
||||
Name string
|
||||
Nickname string
|
||||
AvatarURL string
|
||||
ClanID string
|
||||
ClanTag string
|
||||
Points int
|
||||
BestPos *int // deprecated/optional
|
||||
BestTimeMs *int64
|
||||
Rank int
|
||||
}
|
||||
|
||||
// LeaderboardResult groups the paginated items, total count, and current driver.
|
||||
type LeaderboardResult struct {
|
||||
Items []LeaderboardEntry
|
||||
Total int
|
||||
CurrentDriver *LeaderboardEntry
|
||||
}
|
||||
|
||||
func getYearTimestamps(year int) (int64, int64) {
|
||||
start := time.Date(year, time.January, 1, 0, 0, 0, 0, time.UTC)
|
||||
end := time.Date(year+1, time.January, 1, 0, 0, 0, 0, time.UTC)
|
||||
return start.UnixMilli(), end.UnixMilli()
|
||||
}
|
||||
|
||||
// GetTrackLeaderboard returns track-specific leaderboard entries for the given track and year.
|
||||
func (s *PgStore) GetTrackLeaderboard(ctx context.Context, trackID string, year int, limit, offset int) ([]LeaderboardEntry, int, error) {
|
||||
startMs, endMs := getYearTimestamps(year)
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
countQuery := `
|
||||
WITH best_results AS (
|
||||
SELECT
|
||||
rd.driver_id
|
||||
FROM race_drivers rd
|
||||
JOIN races r ON rd.race_id = r.id
|
||||
WHERE r.status = 'finished'
|
||||
AND r.track_id = $1
|
||||
AND r.finished_ms >= $2
|
||||
AND r.finished_ms < $3
|
||||
GROUP BY rd.driver_id
|
||||
)
|
||||
SELECT COUNT(*) FROM best_results`
|
||||
|
||||
var total int
|
||||
err := s.pool.QueryRow(ctx, countQuery, trackID, startMs, endMs).Scan(&total)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("count track leaderboard: %w", err)
|
||||
}
|
||||
|
||||
if total == 0 {
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
query := `
|
||||
WITH best_results AS (
|
||||
SELECT
|
||||
r.track_id,
|
||||
rd.driver_id,
|
||||
MIN(CASE WHEN rd.best_lap_ms > 0 THEN rd.best_lap_ms ELSE NULL END) as best_time
|
||||
FROM race_drivers rd
|
||||
JOIN races r ON rd.race_id = r.id
|
||||
WHERE r.status = 'finished'
|
||||
AND r.finished_ms >= $1
|
||||
AND r.finished_ms < $2
|
||||
GROUP BY r.track_id, rd.driver_id
|
||||
),
|
||||
ranked_times AS (
|
||||
SELECT
|
||||
track_id,
|
||||
driver_id,
|
||||
best_time,
|
||||
ROW_NUMBER() OVER (PARTITION BY track_id ORDER BY best_time ASC NULLS LAST, driver_id ASC) as time_rank
|
||||
FROM best_results
|
||||
),
|
||||
track_points AS (
|
||||
SELECT
|
||||
track_id,
|
||||
driver_id,
|
||||
best_time,
|
||||
CASE
|
||||
WHEN best_time IS NULL THEN 1
|
||||
WHEN time_rank <= 3 THEN 25
|
||||
WHEN time_rank <= 10 THEN 18
|
||||
WHEN time_rank <= 20 THEN 15
|
||||
WHEN time_rank <= 30 THEN 12
|
||||
WHEN time_rank <= 40 THEN 10
|
||||
WHEN time_rank <= 50 THEN 8
|
||||
WHEN time_rank <= 60 THEN 6
|
||||
WHEN time_rank <= 70 THEN 4
|
||||
WHEN time_rank <= 80 THEN 2
|
||||
WHEN time_rank <= 90 THEN 1
|
||||
ELSE 1
|
||||
END as points
|
||||
FROM ranked_times
|
||||
),
|
||||
ranked_leaderboard AS (
|
||||
SELECT
|
||||
tp.driver_id,
|
||||
d.name,
|
||||
d.nickname,
|
||||
d.avatar_url,
|
||||
COALESCE(d.clan_id, '') as clan_id,
|
||||
COALESCE(c.tag, '') as clan_tag,
|
||||
tp.points,
|
||||
tp.best_time,
|
||||
ROW_NUMBER() OVER (ORDER BY tp.points DESC, tp.best_time ASC NULLS LAST, tp.driver_id ASC) as rank
|
||||
FROM track_points tp
|
||||
JOIN drivers d ON tp.driver_id = d.id
|
||||
LEFT JOIN clans c ON d.clan_id = c.id
|
||||
WHERE tp.track_id = $3
|
||||
)
|
||||
SELECT rank, driver_id, name, nickname, avatar_url, clan_id, clan_tag, points, best_time
|
||||
FROM ranked_leaderboard
|
||||
ORDER BY rank ASC
|
||||
LIMIT $4 OFFSET $5`
|
||||
|
||||
rows, err := s.pool.Query(ctx, query, startMs, endMs, trackID, limit, offset)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("query track leaderboard: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var entries []LeaderboardEntry
|
||||
for rows.Next() {
|
||||
var entry LeaderboardEntry
|
||||
if err := rows.Scan(
|
||||
&entry.Rank, &entry.DriverID, &entry.Name, &entry.Nickname,
|
||||
&entry.AvatarURL, &entry.ClanID, &entry.ClanTag, &entry.Points, &entry.BestTimeMs,
|
||||
); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
return entries, total, nil
|
||||
}
|
||||
|
||||
// GetTrackLeaderboardDriver returns a single driver's rank on a track.
|
||||
func (s *PgStore) GetTrackLeaderboardDriver(ctx context.Context, trackID string, year int, driverID string) (*LeaderboardEntry, error) {
|
||||
startMs, endMs := getYearTimestamps(year)
|
||||
query := `
|
||||
WITH best_results AS (
|
||||
SELECT
|
||||
r.track_id,
|
||||
rd.driver_id,
|
||||
MIN(CASE WHEN rd.best_lap_ms > 0 THEN rd.best_lap_ms ELSE NULL END) as best_time
|
||||
FROM race_drivers rd
|
||||
JOIN races r ON rd.race_id = r.id
|
||||
WHERE r.status = 'finished'
|
||||
AND r.finished_ms >= $1
|
||||
AND r.finished_ms < $2
|
||||
GROUP BY r.track_id, rd.driver_id
|
||||
),
|
||||
ranked_times AS (
|
||||
SELECT
|
||||
track_id,
|
||||
driver_id,
|
||||
best_time,
|
||||
ROW_NUMBER() OVER (PARTITION BY track_id ORDER BY best_time ASC NULLS LAST, driver_id ASC) as time_rank
|
||||
FROM best_results
|
||||
),
|
||||
track_points AS (
|
||||
SELECT
|
||||
track_id,
|
||||
driver_id,
|
||||
best_time,
|
||||
CASE
|
||||
WHEN best_time IS NULL THEN 1
|
||||
WHEN time_rank <= 3 THEN 25
|
||||
WHEN time_rank <= 10 THEN 18
|
||||
WHEN time_rank <= 20 THEN 15
|
||||
WHEN time_rank <= 30 THEN 12
|
||||
WHEN time_rank <= 40 THEN 10
|
||||
WHEN time_rank <= 50 THEN 8
|
||||
WHEN time_rank <= 60 THEN 6
|
||||
WHEN time_rank <= 70 THEN 4
|
||||
WHEN time_rank <= 80 THEN 2
|
||||
WHEN time_rank <= 90 THEN 1
|
||||
ELSE 1
|
||||
END as points
|
||||
FROM ranked_times
|
||||
),
|
||||
ranked_leaderboard AS (
|
||||
SELECT
|
||||
tp.driver_id,
|
||||
d.name,
|
||||
d.nickname,
|
||||
d.avatar_url,
|
||||
COALESCE(d.clan_id, '') as clan_id,
|
||||
COALESCE(c.tag, '') as clan_tag,
|
||||
tp.points,
|
||||
tp.best_time,
|
||||
ROW_NUMBER() OVER (ORDER BY tp.points DESC, tp.best_time ASC NULLS LAST, tp.driver_id ASC) as rank
|
||||
FROM track_points tp
|
||||
JOIN drivers d ON tp.driver_id = d.id
|
||||
LEFT JOIN clans c ON d.clan_id = c.id
|
||||
WHERE tp.track_id = $3
|
||||
)
|
||||
SELECT rank, driver_id, name, nickname, avatar_url, clan_id, clan_tag, points, best_time
|
||||
FROM ranked_leaderboard
|
||||
WHERE driver_id = $4`
|
||||
|
||||
row := s.pool.QueryRow(ctx, query, startMs, endMs, trackID, driverID)
|
||||
var entry LeaderboardEntry
|
||||
if err := row.Scan(
|
||||
&entry.Rank, &entry.DriverID, &entry.Name, &entry.Nickname,
|
||||
&entry.AvatarURL, &entry.ClanID, &entry.ClanTag, &entry.Points, &entry.BestTimeMs,
|
||||
); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &entry, nil
|
||||
}
|
||||
|
||||
// GetOverallLeaderboard returns overall leaderboard entries for the given year.
|
||||
func (s *PgStore) GetOverallLeaderboard(ctx context.Context, year int, limit, offset int) ([]LeaderboardEntry, int, error) {
|
||||
startMs, endMs := getYearTimestamps(year)
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
countQuery := `
|
||||
WITH best_results AS (
|
||||
SELECT
|
||||
rd.driver_id
|
||||
FROM race_drivers rd
|
||||
JOIN races r ON rd.race_id = r.id
|
||||
WHERE r.status = 'finished'
|
||||
AND r.finished_ms >= $1
|
||||
AND r.finished_ms < $2
|
||||
GROUP BY rd.driver_id
|
||||
)
|
||||
SELECT COUNT(*) FROM best_results`
|
||||
|
||||
var total int
|
||||
err := s.pool.QueryRow(ctx, countQuery, startMs, endMs).Scan(&total)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("count overall leaderboard: %w", err)
|
||||
}
|
||||
|
||||
if total == 0 {
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
query := `
|
||||
WITH best_results AS (
|
||||
SELECT
|
||||
r.track_id,
|
||||
rd.driver_id,
|
||||
MIN(CASE WHEN rd.best_lap_ms > 0 THEN rd.best_lap_ms ELSE NULL END) as best_time
|
||||
FROM race_drivers rd
|
||||
JOIN races r ON rd.race_id = r.id
|
||||
WHERE r.status = 'finished'
|
||||
AND r.finished_ms >= $1
|
||||
AND r.finished_ms < $2
|
||||
GROUP BY r.track_id, rd.driver_id
|
||||
),
|
||||
ranked_times AS (
|
||||
SELECT
|
||||
track_id,
|
||||
driver_id,
|
||||
best_time,
|
||||
ROW_NUMBER() OVER (PARTITION BY track_id ORDER BY best_time ASC NULLS LAST, driver_id ASC) as time_rank
|
||||
FROM best_results
|
||||
),
|
||||
track_points AS (
|
||||
SELECT
|
||||
track_id,
|
||||
driver_id,
|
||||
best_time,
|
||||
CASE
|
||||
WHEN best_time IS NULL THEN 1
|
||||
WHEN time_rank <= 3 THEN 25
|
||||
WHEN time_rank <= 10 THEN 18
|
||||
WHEN time_rank <= 20 THEN 15
|
||||
WHEN time_rank <= 30 THEN 12
|
||||
WHEN time_rank <= 40 THEN 10
|
||||
WHEN time_rank <= 50 THEN 8
|
||||
WHEN time_rank <= 60 THEN 6
|
||||
WHEN time_rank <= 70 THEN 4
|
||||
WHEN time_rank <= 80 THEN 2
|
||||
WHEN time_rank <= 90 THEN 1
|
||||
ELSE 1
|
||||
END as points
|
||||
FROM ranked_times
|
||||
),
|
||||
overall_points AS (
|
||||
SELECT
|
||||
driver_id,
|
||||
SUM(points) as points
|
||||
FROM track_points
|
||||
GROUP BY driver_id
|
||||
),
|
||||
ranked_overall AS (
|
||||
SELECT
|
||||
op.driver_id,
|
||||
d.name,
|
||||
d.nickname,
|
||||
d.avatar_url,
|
||||
COALESCE(d.clan_id, '') as clan_id,
|
||||
COALESCE(c.tag, '') as clan_tag,
|
||||
op.points,
|
||||
ROW_NUMBER() OVER (ORDER BY op.points DESC, op.driver_id ASC) as rank
|
||||
FROM overall_points op
|
||||
JOIN drivers d ON op.driver_id = d.id
|
||||
LEFT JOIN clans c ON d.clan_id = c.id
|
||||
)
|
||||
SELECT rank, driver_id, name, nickname, avatar_url, clan_id, clan_tag, points
|
||||
FROM ranked_overall
|
||||
ORDER BY rank ASC
|
||||
LIMIT $3 OFFSET $4`
|
||||
|
||||
rows, err := s.pool.Query(ctx, query, startMs, endMs, limit, offset)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("query overall leaderboard: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var entries []LeaderboardEntry
|
||||
for rows.Next() {
|
||||
var entry LeaderboardEntry
|
||||
if err := rows.Scan(
|
||||
&entry.Rank, &entry.DriverID, &entry.Name, &entry.Nickname,
|
||||
&entry.AvatarURL, &entry.ClanID, &entry.ClanTag, &entry.Points,
|
||||
); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
return entries, total, nil
|
||||
}
|
||||
|
||||
// GetOverallLeaderboardDriver returns a single driver's overall rank.
|
||||
func (s *PgStore) GetOverallLeaderboardDriver(ctx context.Context, year int, driverID string) (*LeaderboardEntry, error) {
|
||||
startMs, endMs := getYearTimestamps(year)
|
||||
query := `
|
||||
WITH best_results AS (
|
||||
SELECT
|
||||
r.track_id,
|
||||
rd.driver_id,
|
||||
MIN(CASE WHEN rd.best_lap_ms > 0 THEN rd.best_lap_ms ELSE NULL END) as best_time
|
||||
FROM race_drivers rd
|
||||
JOIN races r ON rd.race_id = r.id
|
||||
WHERE r.status = 'finished'
|
||||
AND r.finished_ms >= $1
|
||||
AND r.finished_ms < $2
|
||||
GROUP BY r.track_id, rd.driver_id
|
||||
),
|
||||
ranked_times AS (
|
||||
SELECT
|
||||
track_id,
|
||||
driver_id,
|
||||
best_time,
|
||||
ROW_NUMBER() OVER (PARTITION BY track_id ORDER BY best_time ASC NULLS LAST, driver_id ASC) as time_rank
|
||||
FROM best_results
|
||||
),
|
||||
track_points AS (
|
||||
SELECT
|
||||
track_id,
|
||||
driver_id,
|
||||
best_time,
|
||||
CASE
|
||||
WHEN best_time IS NULL THEN 1
|
||||
WHEN time_rank <= 3 THEN 25
|
||||
WHEN time_rank <= 10 THEN 18
|
||||
WHEN time_rank <= 20 THEN 15
|
||||
WHEN time_rank <= 30 THEN 12
|
||||
WHEN time_rank <= 40 THEN 10
|
||||
WHEN time_rank <= 50 THEN 8
|
||||
WHEN time_rank <= 60 THEN 6
|
||||
WHEN time_rank <= 70 THEN 4
|
||||
WHEN time_rank <= 80 THEN 2
|
||||
WHEN time_rank <= 90 THEN 1
|
||||
ELSE 1
|
||||
END as points
|
||||
FROM ranked_times
|
||||
),
|
||||
overall_points AS (
|
||||
SELECT
|
||||
driver_id,
|
||||
SUM(points) as points
|
||||
FROM track_points
|
||||
GROUP BY driver_id
|
||||
),
|
||||
ranked_overall AS (
|
||||
SELECT
|
||||
op.driver_id,
|
||||
d.name,
|
||||
d.nickname,
|
||||
d.avatar_url,
|
||||
COALESCE(d.clan_id, '') as clan_id,
|
||||
COALESCE(c.tag, '') as clan_tag,
|
||||
op.points,
|
||||
ROW_NUMBER() OVER (ORDER BY op.points DESC, op.driver_id ASC) as rank
|
||||
FROM overall_points op
|
||||
JOIN drivers d ON op.driver_id = d.id
|
||||
LEFT JOIN clans c ON d.clan_id = c.id
|
||||
)
|
||||
SELECT rank, driver_id, name, nickname, avatar_url, clan_id, clan_tag, points
|
||||
FROM ranked_overall
|
||||
WHERE driver_id = $3`
|
||||
|
||||
row := s.pool.QueryRow(ctx, query, startMs, endMs, driverID)
|
||||
var entry LeaderboardEntry
|
||||
if err := row.Scan(
|
||||
&entry.Rank, &entry.DriverID, &entry.Name, &entry.Nickname,
|
||||
&entry.AvatarURL, &entry.ClanID, &entry.ClanTag, &entry.Points,
|
||||
); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &entry, nil
|
||||
}
|
||||
|
||||
// GetDriverLeaderboardProfile returns driver profile information.
|
||||
func (s *PgStore) GetDriverLeaderboardProfile(ctx context.Context, driverID string) (*LeaderboardEntry, error) {
|
||||
query := `
|
||||
SELECT d.id, d.name, d.nickname, d.avatar_url, COALESCE(d.clan_id, ''), COALESCE(c.tag, '')
|
||||
FROM drivers d
|
||||
LEFT JOIN clans c ON d.clan_id = c.id
|
||||
WHERE d.id = $1`
|
||||
|
||||
row := s.pool.QueryRow(ctx, query, driverID)
|
||||
var entry LeaderboardEntry
|
||||
if err := row.Scan(
|
||||
&entry.DriverID, &entry.Name, &entry.Nickname, &entry.AvatarURL, &entry.ClanID, &entry.ClanTag,
|
||||
); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &entry, nil
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
package races
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func TestLeaderboard(t *testing.T) {
|
||||
dsn := os.Getenv("DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("DATABASE_URL is not set, skipping database integration tests")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to connect to test database: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
store := NewPgStore(pool)
|
||||
|
||||
// Clean up any left-overs with our test prefix
|
||||
cleanup := func() {
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM race_drivers WHERE race_id LIKE 'test-race-%'")
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM races WHERE id LIKE 'test-race-%'")
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM drivers WHERE id LIKE 'test-driver-%'")
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM clans WHERE id LIKE 'test-clan-%'")
|
||||
}
|
||||
cleanup()
|
||||
defer cleanup()
|
||||
|
||||
// 1. Create a test clan and test drivers
|
||||
nowMs := time.Now().UnixMilli()
|
||||
_, err = pool.Exec(ctx, `
|
||||
INSERT INTO clans (id, tag, name, avatar_url, created_ms, updated_ms)
|
||||
VALUES ('test-clan-1', 'TST', 'Test Clan', 'http://avatar.clan', $1, $1)`, nowMs)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to insert test clan: %v", err)
|
||||
}
|
||||
|
||||
driversData := []struct {
|
||||
id string
|
||||
nick string
|
||||
name string
|
||||
}{
|
||||
{"test-driver-1", "DRV", "Driver One"},
|
||||
{"test-driver-2", "TWO", "Driver Two"},
|
||||
{"test-driver-3", "THR", "Driver Three"},
|
||||
{"test-driver-4", "FOR", "Driver Four"},
|
||||
}
|
||||
|
||||
for _, d := range driversData {
|
||||
_, err = pool.Exec(ctx, `
|
||||
INSERT INTO drivers (id, nickname, name, avatar_url, clan_id, created_ms, updated_ms)
|
||||
VALUES ($1, $2, $3, 'http://avatar', 'test-clan-1', $4, $4)`,
|
||||
d.id, d.nick, d.name, nowMs)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to insert test driver %s: %v", d.id, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Create test races on different tracks for current year
|
||||
currentYear := time.Now().Year()
|
||||
testYearMs := time.Date(currentYear, time.June, 1, 12, 0, 0, 0, time.UTC).UnixMilli()
|
||||
|
||||
racesData := []struct {
|
||||
id string
|
||||
trackID string
|
||||
}{
|
||||
{"test-race-1", "test-track-A"},
|
||||
{"test-race-2", "test-track-A"},
|
||||
{"test-race-3", "test-track-B"},
|
||||
}
|
||||
|
||||
for _, r := range racesData {
|
||||
_, err = pool.Exec(ctx, `
|
||||
INSERT INTO races (id, name, track_id, max_cars, laps, time_limit_s, status, created_ms, started_ms, finished_ms, updated_ms)
|
||||
VALUES ($1, 'Test Race', $2, 4, 10, 60, 'finished', $3, $3, $3, $3)`,
|
||||
r.id, r.trackID, testYearMs)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to insert test race %s: %v", r.id, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Insert race drivers results
|
||||
// Race 1 (Track A):
|
||||
// - test-driver-1: best_lap 100 (1st fastest -> 25 points)
|
||||
// - test-driver-2: best_lap 400 (currently 3rd fastest -> 25 points)
|
||||
// - test-driver-3: best_lap 300 (currently 2nd fastest -> 25 points)
|
||||
// - test-driver-4: best_lap 0 (DNF, completed 0 laps -> 1 point)
|
||||
resultsRace1 := []struct {
|
||||
driverID string
|
||||
bestLapMs int64
|
||||
pos any
|
||||
}{
|
||||
{"test-driver-1", 100, 1},
|
||||
{"test-driver-2", 400, 2},
|
||||
{"test-driver-3", 300, 3},
|
||||
{"test-driver-4", 0, nil},
|
||||
}
|
||||
for _, res := range resultsRace1 {
|
||||
_, err = pool.Exec(ctx, `
|
||||
INSERT INTO race_drivers (race_id, driver_id, slot, joined_ms, total_time_ms, best_lap_ms, position)
|
||||
VALUES ('test-race-1', $1, 0, $2, 1000, $3, $4)`,
|
||||
res.driverID, testYearMs, res.bestLapMs, res.pos)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to insert race driver result: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Race 2 (Track A - testing best results):
|
||||
// - test-driver-2: best_lap 150 (2nd fastest -> 25 points, overrides 400ms lap time)
|
||||
// This makes the times:
|
||||
// - test-driver-1: 100ms (rank 1 -> 25 points)
|
||||
// - test-driver-2: 150ms (rank 2 -> 25 points)
|
||||
// - test-driver-3: 300ms (rank 3 -> 25 points)
|
||||
// - test-driver-4: NULL (rank 4 -> 1 point)
|
||||
// Exactly 3 drivers have 25 points.
|
||||
_, err = pool.Exec(ctx, `
|
||||
INSERT INTO race_drivers (race_id, driver_id, slot, joined_ms, total_time_ms, best_lap_ms, position)
|
||||
VALUES ('test-race-2', 'test-driver-2', 0, $1, 1000, 150, 1)`, testYearMs)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to insert race 2 result: %v", err)
|
||||
}
|
||||
|
||||
// Race 3 (Track B):
|
||||
// - test-driver-1: best_lap 500 (rank 2 on Track B -> 25 points)
|
||||
// - test-driver-3: best_lap 200 (rank 1 on Track B -> 25 points)
|
||||
resultsRace3 := []struct {
|
||||
driverID string
|
||||
bestLapMs int64
|
||||
pos any
|
||||
}{
|
||||
{"test-driver-1", 500, 2},
|
||||
{"test-driver-3", 200, 1},
|
||||
}
|
||||
for _, res := range resultsRace3 {
|
||||
_, err = pool.Exec(ctx, `
|
||||
INSERT INTO race_drivers (race_id, driver_id, slot, joined_ms, total_time_ms, best_lap_ms, position)
|
||||
VALUES ('test-race-3', $1, 0, $2, 1000, $3, $4)`,
|
||||
res.driverID, testYearMs, res.bestLapMs, res.pos)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to insert race 3 driver result: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Test Track A Leaderboard
|
||||
t.Run("Track A Leaderboard", func(t *testing.T) {
|
||||
entries, total, err := store.GetTrackLeaderboard(ctx, "test-track-A", currentYear, 10, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get track leaderboard: %v", err)
|
||||
}
|
||||
if total != 4 {
|
||||
t.Errorf("expected total 4, got %d", total)
|
||||
}
|
||||
if len(entries) != 4 {
|
||||
t.Fatalf("expected 4 entries, got %d", len(entries))
|
||||
}
|
||||
|
||||
// Rank 1: test-driver-1 (25 pts, best_time 100ms)
|
||||
// Rank 2: test-driver-2 (25 pts, best_time 150ms)
|
||||
// Rank 3: test-driver-3 (25 pts, best_time 300ms)
|
||||
// Rank 4: test-driver-4 (1 pt, best_time NULL)
|
||||
drv1 := entries[0]
|
||||
if drv1.DriverID != "test-driver-1" || drv1.Points != 25 || *drv1.BestTimeMs != 100 || drv1.Rank != 1 {
|
||||
t.Errorf("invalid rank 1: %+v", drv1)
|
||||
}
|
||||
|
||||
drv2 := entries[1]
|
||||
if drv2.DriverID != "test-driver-2" || drv2.Points != 25 || *drv2.BestTimeMs != 150 || drv2.Rank != 2 {
|
||||
t.Errorf("invalid rank 2: %+v", drv2)
|
||||
}
|
||||
|
||||
// Check entries by looping to find specific drivers
|
||||
for _, e := range entries {
|
||||
if e.DriverID == "test-driver-3" {
|
||||
if e.Points != 25 || *e.BestTimeMs != 300 || e.Rank != 3 {
|
||||
t.Errorf("invalid test-driver-3: %+v", e)
|
||||
}
|
||||
}
|
||||
if e.DriverID == "test-driver-4" {
|
||||
if e.Points != 1 || e.BestTimeMs != nil || e.Rank != 4 {
|
||||
t.Errorf("invalid test-driver-4: %+v", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Test Track A Driver Rank
|
||||
t.Run("Track A Driver Rank", func(t *testing.T) {
|
||||
entry, err := store.GetTrackLeaderboardDriver(ctx, "test-track-A", currentYear, "test-driver-2")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get driver rank: %v", err)
|
||||
}
|
||||
if entry == nil || entry.Rank != 2 || entry.Points != 25 || *entry.BestTimeMs != 150 {
|
||||
t.Errorf("expected rank 2, points 25, best_time 150, got %+v", entry)
|
||||
}
|
||||
})
|
||||
|
||||
// Test Overall Leaderboard
|
||||
t.Run("Overall Leaderboard", func(t *testing.T) {
|
||||
// Overall points calculation:
|
||||
// - test-driver-1: Track A (25) + Track B (25) = 50 points
|
||||
// - test-driver-3: Track A (25) + Track B (25) = 50 points
|
||||
// - test-driver-2: Track A (25) + Track B (0) = 25 points
|
||||
// - test-driver-4: Track A (1) + Track B (0) = 1 point
|
||||
entries, _, err := store.GetOverallLeaderboard(ctx, currentYear, 200, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get overall leaderboard: %v", err)
|
||||
}
|
||||
|
||||
// Filter to only our test drivers
|
||||
var testEntries []LeaderboardEntry
|
||||
for _, entry := range entries {
|
||||
if len(entry.DriverID) >= 11 && entry.DriverID[:12] == "test-driver-" {
|
||||
testEntries = append(testEntries, entry)
|
||||
}
|
||||
}
|
||||
|
||||
if len(testEntries) != 4 {
|
||||
t.Fatalf("expected 4 test entries, got %d", len(testEntries))
|
||||
}
|
||||
|
||||
// test-driver-1 and test-driver-3 should both have 50 points
|
||||
if testEntries[0].Points != 50 || testEntries[1].Points != 50 {
|
||||
t.Errorf("expected top 2 test drivers to have 50 points, got: %+v and %+v", testEntries[0], testEntries[1])
|
||||
}
|
||||
if testEntries[2].DriverID != "test-driver-2" || testEntries[2].Points != 25 {
|
||||
t.Errorf("invalid overall rank 3: %+v", testEntries[2])
|
||||
}
|
||||
if testEntries[3].DriverID != "test-driver-4" || testEntries[3].Points != 1 {
|
||||
t.Errorf("invalid overall rank 4: %+v", testEntries[3])
|
||||
}
|
||||
})
|
||||
|
||||
// Test Overall Driver Rank
|
||||
t.Run("Overall Driver Rank", func(t *testing.T) {
|
||||
entry, err := store.GetOverallLeaderboardDriver(ctx, currentYear, "test-driver-3")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get overall driver rank: %v", err)
|
||||
}
|
||||
if entry == nil || entry.Points != 50 || entry.Rank <= 0 {
|
||||
t.Errorf("expected rank > 0, points 50, got %+v", entry)
|
||||
}
|
||||
})
|
||||
|
||||
// Test Fallback Profile
|
||||
t.Run("Fallback Profile", func(t *testing.T) {
|
||||
entry, err := store.GetDriverLeaderboardProfile(ctx, "test-driver-1")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get profile: %v", err)
|
||||
}
|
||||
if entry == nil || entry.Name != "Driver One" || entry.Nickname != "DRV" || entry.ClanTag != "TST" {
|
||||
t.Errorf("expected Driver One, DRV, TST, got %+v", entry)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -29,8 +29,6 @@ var DefaultNow = time.Now
|
||||
// run time. If no tracks exist, the seeder falls back to ["default"].
|
||||
var fallbackTracks = []string{"default"}
|
||||
|
||||
// Default driver nicknames used when the drivers table is empty. Three
|
||||
// uppercase letters each (per the drivers table constraint).
|
||||
var defaultDriverSeeds = []struct {
|
||||
nick string
|
||||
name string
|
||||
@@ -43,6 +41,18 @@ var defaultDriverSeeds = []struct {
|
||||
{"FRA", "Frank"},
|
||||
{"GRA", "Grace"},
|
||||
{"HEI", "Heidi"},
|
||||
{"IVY", "Ivy"},
|
||||
{"JAC", "Jack"},
|
||||
{"KEN", "Ken"},
|
||||
{"LEO", "Leo"},
|
||||
{"MAX", "Max"},
|
||||
{"NED", "Ned"},
|
||||
{"OLI", "Oliver"},
|
||||
{"PEP", "Peggy"},
|
||||
{"QIN", "Quincy"},
|
||||
{"ROY", "Roy"},
|
||||
{"SAM", "Sam"},
|
||||
{"TED", "Ted"},
|
||||
}
|
||||
|
||||
// Default clans.
|
||||
@@ -246,7 +256,7 @@ func (r *Runner) resetAll(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
if _, err := r.pg.Exec(ctx,
|
||||
`TRUNCATE TABLE race_plans, race_queue`); err != nil {
|
||||
`TRUNCATE TABLE race_plans, race_queue, drivers, clans CASCADE`); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, m := range r.lb.ListRaces() {
|
||||
|
||||
@@ -685,6 +685,61 @@ func (s *Service) DeletePlan(ctx context.Context, id string) error {
|
||||
return s.pg.DeletePlan(ctx, id)
|
||||
}
|
||||
|
||||
// GetLeaderboard retrieves the leaderboard page and current driver position.
|
||||
func (s *Service) GetLeaderboard(ctx context.Context, trackID string, year int, limit, offset int, currentDriverID string) (*LeaderboardResult, error) {
|
||||
if year == 0 {
|
||||
year = s.now().Year()
|
||||
}
|
||||
|
||||
var items []LeaderboardEntry
|
||||
var total int
|
||||
var err error
|
||||
|
||||
if trackID != "" {
|
||||
items, total, err = s.pg.GetTrackLeaderboard(ctx, trackID, year, limit, offset)
|
||||
} else {
|
||||
items, total, err = s.pg.GetOverallLeaderboard(ctx, year, limit, offset)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var currentDriver *LeaderboardEntry
|
||||
if currentDriverID != "" {
|
||||
if trackID != "" {
|
||||
currentDriver, err = s.pg.GetTrackLeaderboardDriver(ctx, trackID, year, currentDriverID)
|
||||
} else {
|
||||
currentDriver, err = s.pg.GetOverallLeaderboardDriver(ctx, year, currentDriverID)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if currentDriver == nil {
|
||||
// fallback: get profile from drivers table
|
||||
currentDriver, err = s.pg.GetDriverLeaderboardProfile(ctx, currentDriverID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// if driver exists but has no points, set points/rank to 0
|
||||
if currentDriver != nil {
|
||||
currentDriver.Points = 0
|
||||
currentDriver.Rank = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if items == nil {
|
||||
items = []LeaderboardEntry{}
|
||||
}
|
||||
|
||||
return &LeaderboardResult{
|
||||
Items: items,
|
||||
Total: total,
|
||||
CurrentDriver: currentDriver,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scheduler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user