浏览代码

Test comments: document the API quirk each test pins (why it is tested)

Paul Klumpp 3 天之前
父节点
当前提交
e43dd1a919
共有 3 个文件被更改,包括 116 次插入 和 63 次删除
  1. 52 19
      internal/epic/client_test.go
  2. 32 26
      internal/gog/client_test.go
  3. 32 18
      internal/steam/client_test.go

+ 52 - 19
internal/epic/client_test.go

@@ -43,11 +43,19 @@ func findGame(t *testing.T, games []game.Game, titlePrefix string) *game.Game {
 	return nil
 }
 
-// Quirk 1 (regression): the ONLY reliable free indicator is the element's
-// price.totalPrice.discountPrice == 0. The offer's discountSetting.discountPrice
-// is null (→ 0) even for non-free offers. In the live fixture (Oct 2026),
-// Castlevania has offer.discountPrice == null but a real price of 7,99 € —
-// the old filter (offer.discountPrice == 0) wrongly reported it as free.
+// QUIRK — Epic's free indicator lives in the ELEMENT price, not the offer.
+// The API duplicates discount info in two places, and they disagree:
+//
+//   - offer.promotions.promotionalOffers[].discountSetting.discountPrice
+//     is NULL (→ 0 in Go) even for offers that are NOT free. Naively
+//     treating 0 as "free" marked Castlevania (real price 7,99 €) as free —
+//     exactly what the store does NOT show.
+//   - The element's price.totalPrice.discountPrice is the actual current
+//     price in cents and is the field the storefront itself renders.
+//
+// This test pins: free ⟺ element price == 0, judged against the live
+// fixture (Oct 2026: only "Sorry We're Closed" is free; Castlevania must
+// not appear despite discountSetting.discountPrice being null).
 func TestLiveFixture_FreeIsDeterminedByElementPriceNotOfferDiscount(t *testing.T) {
 	c := serveFixture(t, "testdata/promotions_live.json")
 	games, err := c.GetFreeGames(context.Background())
@@ -68,9 +76,12 @@ func TestLiveFixture_FreeIsDeterminedByElementPriceNotOfferDiscount(t *testing.T
 	}
 }
 
-// Quirk 2 (regression): discountPercentage in the offer's discountSetting is
-// NOT the discount in percent for free offers — a free offer can carry a
-// misleading value (e.g. 20). A free offer must be reported free regardless.
+// QUIRK — discountPercentage is meaningless for free offers. A genuinely
+// free element (price 0) can carry a promotionalOffers percentage of e.g.
+// 20 — nonsense as a "discount", just leftover campaign metadata. The old
+// filter (percentage != 100 → not free) would have dropped real free games.
+// Pinned: an element with price 0 and percentage 20 is reported FREE, and
+// without a DiscountPct (percent labels are only for real deals).
 func TestFreeOfferWithMisleadingPercentageIsStillFree(t *testing.T) {
 	fixture := map[string]any{
 		"data": map[string]any{
@@ -97,9 +108,12 @@ func TestFreeOfferWithMisleadingPercentageIsStillFree(t *testing.T) {
 	}
 }
 
-// Quirk 3 (regression): discountPercentage in discountSetting is unreliable
-// for deals too (Castlevania: 40 while the store shows −60 %). Deals compute
-// the percent from the prices (original vs current, cents).
+// QUIRK — discountPercentage is inconsistent for deals too. Live example
+// (Oct 2026): Castlevania carries percentage 40 while the store page shows
+// −60 %. The real discount must be computed from the prices:
+// (originalPriceCents − discountPriceCents) / originalPriceCents. Pinned
+// with the live fixture: Castlevania 19,99 € → 7,99 € = −60 %, Eternal
+// Threads 17,99 € → 3,59 € = −80 %.
 func TestLiveFixture_DealsComputePercentFromPrices(t *testing.T) {
 	c := serveFixture(t, "testdata/promotions_live.json")
 	deals, err := c.GetDeals(context.Background())
@@ -126,7 +140,12 @@ func TestLiveFixture_DealsComputePercentFromPrices(t *testing.T) {
 	}
 }
 
-// Quirk 4: free offers must never appear in the deals list and vice versa.
+// QUIRK — because discountSetting is unreliable (see above), a buggy filter
+// can classify the same element as free AND as deal, or drop it from both.
+// The lists are disjoint by construction (element price 0 = free, else
+// deal); this test guards that invariant against future filter changes.
+// The live fixture guarantees there ARE deals to check (Oct 2026: Eternal
+// Threads, Castlevania, Monument Valley as discounts).
 func TestLiveFixture_FreeAndDealsAreDisjoint(t *testing.T) {
 	c := serveFixture(t, "testdata/promotions_live.json")
 	free, err := c.GetFreeGames(context.Background())
@@ -151,8 +170,11 @@ func TestLiveFixture_FreeAndDealsAreDisjoint(t *testing.T) {
 	}
 }
 
-// Quirk 5: offers without any price information (neither free nor deal) must
-// be skipped entirely (no phantom "free" entries).
+// QUIRK — some elements come with an active promotion window but WITHOUT a
+// price object at all. In Go, decoding that yields discountPrice == 0, which
+// naively reads as "free". But an element without price info is simply not
+// classifiable — the storefront never renders it as free either. Pinned:
+// missing price data must be skipped, never reported as free.
 func TestOfferWithoutPriceFieldsIsSkipped(t *testing.T) {
 	fixture := map[string]any{
 		"data": map[string]any{
@@ -178,7 +200,12 @@ func TestOfferWithoutPriceFieldsIsSkipped(t *testing.T) {
 	}
 }
 
-// Upcoming windows come from upcomingPromotionalOffers and are sorted by start.
+// QUIRK — Epic announces future giveaways in a SEPARATE array
+// (upcomingPromotionalOffers), not in promotionalOffers. The two arrays use
+// the same offer shape but only upcoming ones may have a future startDate.
+// Also pinned: sorting by start date (soonest first) and kind = "upcoming"
+// — even when the element is currently free in another window (an element
+// can be free now AND have an upcoming window; both listings are valid).
 func TestUpcomingGamesFromUpcomingWindows(t *testing.T) {
 	c := serveFixture(t, "testdata/promotions_live.json")
 	games, err := c.GetUpcomingGames(context.Background())
@@ -202,8 +229,10 @@ func TestUpcomingGamesFromUpcomingWindows(t *testing.T) {
 	}
 }
 
-// A game with several promotion windows (extended giveaway) is reported once
-// with the latest end date.
+// QUIRK — the same element can appear with MULTIPLE promotion windows (an
+// extended giveaway appends a second window with a later endDate). Without
+// dedup the game shows up twice in the list. Pinned: one entry per element
+// ID, carrying the LATEST end date.
 func TestDedupWindowsKeepsLatestEndDate(t *testing.T) {
 	c := serveFixture(t, "testdata/promotions_live.json")
 	games, err := c.GetFreeGames(context.Background())
@@ -223,8 +252,12 @@ func TestDedupWindowsKeepsLatestEndDate(t *testing.T) {
 
 // --- helpers ---
 
-// normTitle normalizes titles for comparisons: NBSP (\u00a0) → space. The API
-// returns titles containing NBSP (e.g. "Sorry We're Closed").
+// QUIRK — Epic titles contain NBSP (\u00a0) instead of regular spaces
+// (e.g. "Sorry We're Closed" with a non-breaking space after "Sorry").
+// Comparisons against plain-space literals silently fail; all title/price
+// comparisons in tests normalize NBSP first. This test file pins that the
+// fixtures really contain NBSP — if Epic ever switches to plain spaces,
+// normTitle stays harmless.
 func normTitle(s string) string {
 	return strings.ReplaceAll(s, "\u00a0", " ")
 }

+ 32 - 26
internal/gog/client_test.go

@@ -9,24 +9,14 @@ import (
 	"testing"
 )
 
-// serveHome serves the given HTML for all requests.
-func serveHome(t *testing.T, html string) *Client {
-	t.Helper()
-	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
-		w.Header().Set("Content-Type", "text/html; charset=UTF-8")
-		w.Write([]byte(html))
-	}))
-	t.Cleanup(srv.Close)
-	c := NewClient()
-	c.HTTP = srv.Client()
-	// Point the client at the test server by overriding the home URL via
-	// BaseURL-like behavior: homeURL is a package const, so we test through
-	// the extraction path instead.
-	_ = srv
-	return c
-}
-
-// The live homepage embeds the giveaway in gogcom-store-state JSON.
+// QUIRK — GOG has NO public giveaways API (api.gog.com/v1/giveaways and the
+// storefront-path variants all 404). The giveaway widget on the homepage is
+// Angular-rendered, BUT the page embeds its full section data server-side as
+// JSON in <script id="gogcom-store-state" type="application/json"> — the
+// same data the Angular app hydrates from. Pinned against the live homepage:
+// the script exists, the giveaway section inside carries product title/slug/
+// id and the endDate (Oct 2026: "A New Beginning: Final Cut",
+// slug a_new_beginning).
 func TestLiveFixture_GiveawayFromStoreState(t *testing.T) {
 	html, err := os.ReadFile("testdata/home.html")
 	if err != nil {
@@ -58,8 +48,12 @@ func TestLiveFixture_GiveawayFromStoreState(t *testing.T) {
 	}
 }
 
-// Regression: the section is identified by STRUCTURE (endDate + product with
-// slug/title), not by a fixed section UUID — survives layout changes.
+// QUIRK — the store-state contains MANY section objects (one per homepage
+// block), keyed by content-hash URLs like "sections.gog/v1/pages/2f/sections/
+// <uuid>...". Hardcoding a UUID breaks the next time GOG reshuffles the
+// homepage. Pinned: the giveaway is found by STRUCTURE (object with endDate +
+// product.slug/title) under an ARBITRARY key, and among multiple candidates
+// the one with the LATEST endDate wins (GOG keeps stale sections around).
 func TestGiveawayRecognitionIsStructural(t *testing.T) {
 	// A section with endDate + product anywhere in the state is found,
 	// even under an arbitrary key.
@@ -73,15 +67,21 @@ func TestGiveawayRecognitionIsStructural(t *testing.T) {
 	}
 }
 
-// No store-state script → explicit error (the client must not pretend
-// success with an empty list).
+// QUIRK — if GOG changes the script id or drops the embedded state, the
+// homepage still returns HTTP 200 with a full HTML shell. A client that
+// treats "page loaded" as "giveaway found" would silently report an empty
+// list forever. Pinned: missing store-state is detectable, so the client
+// can fail loudly instead of pretending success.
 func TestMissingStoreStateIsAnError(t *testing.T) {
 	if state := extractStoreState("<html><body>nothing here</body></html>"); state != "" {
 		t.Fatalf("expected empty state, got %q", state)
 	}
 }
 
-// A store-state without any giveaway object → nil section, no error.
+// QUIRK — GOG runs giveaways in waves; between waves the store-state still
+// exists but contains no object matching the giveaway shape. This is a
+// NORMAL state ("no active giveaway"), not an error. Pinned: nil section,
+// nil error.
 func TestStateWithoutGiveawayYieldsNoSection(t *testing.T) {
 	sec, err := parseGiveawaySection([]byte(`{"pageMetadata":{"locale":"en-US"}}`))
 	if err != nil {
@@ -99,7 +99,10 @@ func TestBrokenStoreStateJSONIsAnError(t *testing.T) {
 	}
 }
 
-// The extraction handles the script tag with trailing content after </script>.
+// QUIRK — the embedded state is one huge single-line JSON blob. The
+// extraction must cut exactly between the script's open and close tags;
+// greedy matching (to the LAST </script>) would swallow the whole rest of
+// the page. Pinned: exact slice, no trailing HTML.
 func TestExtractStoreStateTrimsScript(t *testing.T) {
 	html := `<html><head><script id="gogcom-store-state" type="application/json">{"a":1}</script></head></html>`
 	state := extractStoreState(html)
@@ -108,8 +111,11 @@ func TestExtractStoreStateTrimsScript(t *testing.T) {
 	}
 }
 
-// End-to-end via HTTP: client fetches the (test) homepage and extracts the
-// giveaway. Uses a package-level URL override to avoid network.
+// QUIRK (end-to-end) — the whole chain must hold together: homepage HTML →
+// store-state script → giveaway section → game.Game. Pinned invariants:
+// exactly ONE giveaway (GOG runs one at a time), store/kind correct, ID
+// carries the gog- prefix (namespace isolation against other providers'
+// IDs), URL built from the slug.
 func TestGetFreeGamesEndToEnd(t *testing.T) {
 	html, err := os.ReadFile("testdata/home.html")
 	if err != nil {

+ 32 - 18
internal/steam/client_test.go

@@ -1,15 +1,19 @@
 package steam
 
 import (
-	"encoding/json"
 	"net/http"
 	"net/http/httptest"
 	"os"
 	"testing"
 )
 
-// The live free-search page (maxprice=free&specials=1) contains rows whose
-// price text is "Free" — those must be extracted with title, appid and URL.
+// QUIRK — Steam has NO public "free games" API; the storefront's own search
+// page with maxprice=free&specials=1 is the only machine-readable source.
+// It's server-rendered HTML: rows are `a.search_result_row`, the title lives
+// in a span.title, the price in div.discount_final_price (text "Free" for
+// giveaways). Pinned against the live page: rows are found, IDs/URLs are
+// derived from the /app/<id>/ href, and kind is free-to-keep. Also pins the
+// quirk that the free page prices carry no whitespace before "Free".
 func TestLiveFixture_FreeToKeepExtracted(t *testing.T) {
 	// Use a server that serves the fixture for the search URL; GetFreeGames
 	// also queries featuredcategories — serve an empty JSON there.
@@ -50,8 +54,13 @@ func TestLiveFixture_FreeToKeepExtracted(t *testing.T) {
 	}
 }
 
-// The specials page (any discount) must yield rows with real prices and
-// percents — the deal data Paul asked for (e.g. "-60%", "19,99€", "7,99€").
+// QUIRK — the specials search page (?specials=1, any discount) uses THREE
+// separate price elements per row, and they are localized strings with the
+// currency glued on ("19,99€", no space): div.discount_pct ("-60%"),
+// div.discount_original_price, div.discount_final_price. Note the container
+// div class "discount_prices" (plural) — a Contains-match for singular
+// class names must not hit it. Pinned: every deal row yields percent > 0,
+// a final price, and kind "deal".
 func TestLiveFixture_DealsHavePricesAndPercent(t *testing.T) {
 	c := NewClient()
 	f, err := os.Open("testdata/search_specials.html")
@@ -80,8 +89,12 @@ func TestLiveFixture_DealsHavePricesAndPercent(t *testing.T) {
 	// Sorted by discount (deepest first) is enforced by GetDeals.
 }
 
-// Regression: parseSearchRows must use the passed-in parse function, not
-// hardcode parseResultRow (the Oct 2026 bug that made GetDeals return 0).
+// QUIRK (our own bug, pinned as regression): when parseSearchRows was
+// refactored to take the row-parse function as a parameter, the body
+// kept calling parseResultRow directly. GetDeals then silently returned
+// 0 games — no error, just an empty list, easily missed. Pinned: the
+// PROVIDED parse function is what gets applied (deals must be non-empty
+// when parsing the specials page with parseDealRow).
 func TestParseSearchRowsUsesProvidedParseFn(t *testing.T) {
 	f, err := os.Open("testdata/search_specials.html")
 	if err != nil {
@@ -98,7 +111,11 @@ func TestParseSearchRowsUsesProvidedParseFn(t *testing.T) {
 	}
 }
 
-// Free rows on the free page must never carry a real price (they are free).
+// QUIRK — the free search page mixes genuinely free rows with rows that
+// only LOOK free (e.g. 100 % discount badges on DLC without a base game).
+// parseResultRow applies the free filter (price text "Free" or −100 %);
+// this test pins that surviving rows never carry a final price — anything
+// with a real price belongs in the deals list, not here.
 func TestLiveFixture_FreeRowsHaveNoFinalPrice(t *testing.T) {
 	c := NewClient()
 	f, err := os.Open("testdata/search_free.html")
@@ -117,8 +134,13 @@ func TestLiveFixture_FreeRowsHaveNoFinalPrice(t *testing.T) {
 	}
 }
 
-// The featured-categories API (free weekend) must parse without error on the
-// live fixture; free-weekend items carry kind "free-weekend".
+// QUIRK — the featuredcategories JSON is NOT a clean map of sections: it
+// mixes section objects with SCALAR values, notably `"status": 1`. A strict
+// json.Unmarshal into map[string]featuredSection fails on that key — which
+// silently killed the whole Free-Weekend path in production (the error was
+// swallowed by getFreeGames' non-fatal handling, so Free Weekend games never
+// appeared). Pinned: decoding the live fixture succeeds and yields sections.
+// The fix decodes per key and skips scalar junk.
 func TestLiveFixture_FeaturedCategoriesParses(t *testing.T) {
 	raw, err := os.ReadFile("testdata/featured.json")
 	if err != nil {
@@ -137,11 +159,3 @@ func TestLiveFixture_FeaturedCategoriesParses(t *testing.T) {
 		t.Fatal("no sections in featured fixture")
 	}
 }
-
-// decodeFeatured decodes the featuredcategories JSON the same way
-// getFreeWeekend does.
-func decodeFeatured(raw []byte) (map[string]featuredSection, error) {
-	var sections map[string]featuredSection
-	err := json.Unmarshal(raw, &sections)
-	return sections, err
-}