Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
190 changes: 190 additions & 0 deletions internal/registry/catalog_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
package registry

import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"

"github.com/aviorstudio/termcade/sdk"
)

// The catalog is paged, so browsing the marketplace is several requests. The
// failure this guards against is quiet: a client that stops early shows a
// short marketplace and nothing anywhere says a game is missing.

// pagedCatalog serves n games, limit per page, in the registry's envelope.
func pagedCatalog(t *testing.T, n, perPage int) (*httptest.Server, *int) {
t.Helper()
requests := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++
start := 0
if cursor := r.URL.Query().Get("cursor"); cursor != "" {
fmt.Sscanf(cursor, "at-%d", &start)
}
page := struct {
Games []Game `json:"games"`
Next string `json:"next,omitempty"`
}{}
for i := start; i < n && i < start+perPage; i++ {
page.Games = append(page.Games, Game{
ID: fmt.Sprintf("aviorstudio/game-%03d", i), Name: "Game", HasPackage: true,
})
}
if start+perPage < n {
page.Next = fmt.Sprintf("at-%d", start+perPage)
}
json.NewEncoder(w).Encode(page)
}))
t.Cleanup(server.Close)
return server, &requests
}

func TestGamesFollowsEveryCursor(t *testing.T) {
server, requests := pagedCatalog(t, 47, 10)

games, err := New(server.URL, "").Games()
if err != nil {
t.Fatalf("Games: %v", err)
}
if len(games) != 47 {
t.Fatalf("got %d games, want all 47 — a page was dropped", len(games))
}
if *requests != 5 {
t.Errorf("made %d requests for 5 pages", *requests)
}
seen := map[string]bool{}
for _, g := range games {
if seen[g.ID] {
t.Fatalf("%s came back twice", g.ID)
}
seen[g.ID] = true
}
}

// The end is an absent cursor. A page that happens to be empty in the middle
// of a walk — which the registry's compatibility filter can produce — is not
// the end, and stopping there loses everything after it.
func TestAnEmptyPageIsNotTheEnd(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Query().Get("cursor") {
case "":
fmt.Fprint(w, `{"games":[],"next":"second"}`)
case "second":
fmt.Fprint(w, `{"games":[{"id":"aviorstudio/tetris","has_package":true}]}`)
}
}))
defer server.Close()

games, err := New(server.URL, "").Games()
if err != nil {
t.Fatalf("Games: %v", err)
}
if len(games) != 1 || games[0].ID != "aviorstudio/tetris" {
t.Fatalf("an empty first page ended the walk: %v", games)
}
}

// A registry that never stops handing out cursors must not hang the arcade.
func TestGamesStopsWalkingEventually(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
fmt.Fprint(w, `{"games":[{"id":"a/b","has_package":true}],"next":"forever"}`)
}))
defer server.Close()

games, err := New(server.URL, "").Games()
if err != nil {
t.Fatalf("Games: %v", err)
}
if len(games) != maxCatalogPages {
t.Errorf("walked %d pages, want it bounded at %d", len(games), maxCatalogPages)
}
}

// Browsing asks for what this arcade can run. A marketplace full of entries
// that refuse to install is worse than a shorter one.
func TestGamesAsksForRunnableReleases(t *testing.T) {
var query string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
query = r.URL.RawQuery
fmt.Fprint(w, `{"games":[]}`)
}))
defer server.Close()

if _, err := New(server.URL, "").Games(); err != nil {
t.Fatal(err)
}
if want := fmt.Sprintf("abi=%d", sdk.ABIVersion); !strings.Contains(query, want) {
t.Errorf("catalog request %q does not carry %s", query, want)
}
}

func TestCatalogQueryEncodesOnlyWhatWasSet(t *testing.T) {
if got := (CatalogQuery{}).values().Encode(); got != "" {
t.Errorf("an empty query encoded as %q", got)
}
got := CatalogQuery{Cursor: "c", Limit: 10, Search: "tet", ABI: 1, Sort: "newest"}.values().Encode()
for _, want := range []string{"cursor=c", "limit=10", "q=tet", "abi=1", "sort=newest"} {
if !strings.Contains(got, want) {
t.Errorf("query %q is missing %s", got, want)
}
}
}

// ------------------------------------------------------------- contract --

// The examples in contract/ are copied from aviorstudio/termcade-be, which
// generates them from the API itself. Decoding them here is what stops this
// client's idea of the wire format drifting from the server's: a renamed field
// becomes a zero value, and a zero value looks exactly like a game with no
// release rather than like a bug.
func contractExample(t *testing.T, name string, out any) {
t.Helper()
raw, err := os.ReadFile(filepath.Join("contract", name))
if err != nil {
t.Fatalf("reading the contract example: %v", err)
}
if err := json.Unmarshal(raw, out); err != nil {
t.Fatalf("%s does not decode into %T: %v", name, out, err)
}
}

func TestCatalogExampleDecodes(t *testing.T) {
var page CatalogPage
contractExample(t, "catalog.json", &page)

if len(page.Games) == 0 {
t.Fatal("the recorded catalog page has no games")
}
// The field the arcade has to learn to follow.
if page.Next == "" {
t.Error("the recorded page carries no cursor, so following one is untested")
}

game := page.Games[0]
if !strings.Contains(game.ID, "/") {
t.Errorf("id %q is not namespaced — the handle expansion is not reaching the wire", game.ID)
}
if game.Name == "" || !game.HasPackage {
t.Errorf("catalog row decoded thin: %+v", game)
}
if game.Version == "" || game.SHA256 == "" {
t.Errorf("release fields did not decode: %+v", game)
}
if game.CreatedAt == "" || game.ReleasedAt == "" {
t.Errorf("timestamps did not decode: %+v", game)
}
}

func TestGameExampleDecodes(t *testing.T) {
var game Game
contractExample(t, "game.json", &game)
if !strings.Contains(game.ID, "/") || game.Name == "" {
t.Errorf("game example decoded thin: %+v", game)
}
}
87 changes: 85 additions & 2 deletions internal/registry/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,10 @@ type Game struct {
Height int `json:"height"`
HasPackage bool `json:"has_package"`
SHA256 string `json:"sha256"`
// RFC 3339. When the game entered the catalog, and when its newest release
// was published; the second is absent on a game that has none.
CreatedAt string `json:"created_at,omitempty"`
ReleasedAt string `json:"released_at,omitempty"`
}

// Resolved is which release to install and what it must hash to. The registry
Expand Down Expand Up @@ -205,9 +209,88 @@ func (c *Client) do(method, path string, body, out any) error {
return json.NewDecoder(resp.Body).Decode(out)
}

// CatalogPage is one page of the marketplace and the cursor that continues it.
//
// Next empty is the end. A SHORT PAGE IS NOT: the registry applies its
// compatibility filter after reading a page, so a page can hold fewer games
// than asked for while the catalog continues. Stopping on a short page shows a
// truncated marketplace with nothing to say anything is missing.
type CatalogPage struct {
Games []Game `json:"games"`
Next string `json:"next,omitempty"`
}

// CatalogQuery narrows a catalog request. The zero value asks for the first
// page of everything.
type CatalogQuery struct {
Cursor string
// Limit is 1-200; zero lets the registry choose.
Limit int
// Search matches a game's name, id or description.
Search string
// ABI restricts to games this arcade can run. Set by Games(); the registry
// treats zero as "do not filter".
ABI int
Sort string // "slug" (default) or "newest"
}

func (q CatalogQuery) values() url.Values {
v := url.Values{}
if q.Cursor != "" {
v.Set("cursor", q.Cursor)
}
if q.Limit > 0 {
v.Set("limit", strconv.Itoa(q.Limit))
}
if q.Search != "" {
v.Set("q", q.Search)
}
if q.ABI > 0 {
v.Set("abi", strconv.Itoa(q.ABI))
}
if q.Sort != "" {
v.Set("sort", q.Sort)
}
return v
}

// CatalogPage fetches one page of the marketplace.
func (c *Client) CatalogPage(q CatalogQuery) (CatalogPage, error) {
path := "/v1/games"
if encoded := q.values().Encode(); encoded != "" {
path += "?" + encoded
}
var page CatalogPage
return page, c.do(http.MethodGet, path, nil, &page)
}

// maxCatalogPages bounds how far Games will walk. The marketplace is a screen
// somebody scrolls, not an index anybody mirrors; at the registry's ceiling of
// 200 a page this is four thousand games, and a limit that is reached returns
// what it has rather than failing — a marketplace showing four thousand beats
// one showing an error.
const maxCatalogPages = 20

// Games lists the marketplace, following cursors to the end.
//
// The catalog is paged, so this is several requests rather than one. It asks
// for games this arcade can run: a marketplace full of entries that refuse to
// install is worse than a shorter one.
func (c *Client) Games() ([]Game, error) {
var games []Game
return games, c.do(http.MethodGet, "/v1/games", nil, &games)
var all []Game
query := CatalogQuery{ABI: sdk.ABIVersion}
for range maxCatalogPages {
page, err := c.CatalogPage(query)
if err != nil {
return nil, err
}
all = append(all, page.Games...)
if page.Next == "" {
return all, nil
}
query.Cursor = page.Next
}
return all, nil
}

// Resolve asks the registry which release to install: the newest one this
Expand Down
16 changes: 16 additions & 0 deletions internal/registry/contract/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# contract

Recorded examples of the registry's response models, copied from
[termcade-be](https://github.com/aviorstudio/termcade-be)'s `contract/`, which
generates them from the API itself.

`catalog_test.go` decodes them into this package's types. That is the only
thing standing between the arcade's idea of the wire format and the server's:
a renamed field decodes as a zero value, and a zero value looks exactly like a
game with no release rather than like a bug.

**Copied, not shared.** A Go module cannot read files out of a sibling
repository, and vendoring the API to get four JSON files would be a dependency
on the whole backend. The cost is that these can go stale — so when the API
changes a response model it regenerates its own copy, and updating this one is
part of the change that follows here.
10 changes: 10 additions & 0 deletions internal/registry/contract/activity.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
[
{
"id": "aviorstudio/brickough",
"personal_best": 4242,
"plays": 1,
"last_played": "2026-08-01T10:00:00Z",
"last_completed": "2026-08-01T10:00:00Z",
"last_version": "1.2.0"
}
]
19 changes: 19 additions & 0 deletions internal/registry/contract/catalog.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"games": [
{
"id": "aviorstudio/brickough",
"name": "Brickough",
"description": "Breakout-style brick breaker",
"repo": "https://github.com/aviorstudio/termcade-games",
"version": "1.2.0",
"abi": 1,
"width": 64,
"height": 40,
"sha256": "0000000000000000000000000000000000000000000000000000000000000000",
"has_package": true,
"created_at": "2026-07-30T09:00:00Z",
"released_at": "2026-08-01T12:00:00Z"
}
],
"next": "MQBzbHVnAGJyaWNrb3VnaABnLWJyaWNr"
}
14 changes: 14 additions & 0 deletions internal/registry/contract/game.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"id": "aviorstudio/brickough",
"name": "Brickough",
"description": "Breakout-style brick breaker",
"repo": "https://github.com/aviorstudio/termcade-games",
"version": "1.2.0",
"abi": 1,
"width": 64,
"height": 40,
"sha256": "0000000000000000000000000000000000000000000000000000000000000000",
"has_package": true,
"created_at": "2026-07-30T09:00:00Z",
"released_at": "2026-08-01T12:00:00Z"
}
24 changes: 24 additions & 0 deletions internal/registry/contract/library.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
[
{
"id": "aviorstudio/brickough",
"name": "Brickough",
"description": "Breakout-style brick breaker",
"repo": "https://github.com/aviorstudio/termcade-games",
"version": "1.2.0",
"abi": 1,
"width": 64,
"height": 40,
"sha256": "0000000000000000000000000000000000000000000000000000000000000000",
"has_package": true,
"created_at": "2026-07-30T09:00:00Z",
"released_at": "2026-08-01T12:00:00Z",
"activity": {
"id": "aviorstudio/brickough",
"personal_best": 4242,
"plays": 1,
"last_played": "2026-08-01T10:00:00Z",
"last_completed": "2026-08-01T10:00:00Z",
"last_version": "1.2.0"
}
}
]