diff --git a/sites/pizzint/cmd/pizzint/main.go b/sites/pizzint/cmd/pizzint/main.go new file mode 100644 index 0000000..5d71c87 --- /dev/null +++ b/sites/pizzint/cmd/pizzint/main.go @@ -0,0 +1,205 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "os" + "sync" + "time" + + "github.com/urfave/cli/v3" + + "gitea.stevedudenhoeffer.com/steve/go-extractor" + "gitea.stevedudenhoeffer.com/steve/go-extractor/cmd/browser/pkg/browser" + "gitea.stevedudenhoeffer.com/steve/go-extractor/sites/pizzint" +) + +func main() { + var flags []cli.Flag + flags = append(flags, browser.Flags...) + flags = append(flags, + &cli.BoolFlag{ + Name: "serve", + Usage: "Start an HTTP server instead of printing once", + }, + &cli.StringFlag{ + Name: "port", + Aliases: []string{"p"}, + Usage: "Port for the HTTP server", + DefaultText: "8080", + }, + &cli.StringFlag{ + Name: "cache-ttl", + Usage: "How long to cache results (e.g. 5m, 1h)", + DefaultText: "5m", + }, + ) + + app := &cli.Command{ + Name: "pizzint", + Usage: "Pentagon Pizza Index — DOUGHCON status tracker", + Flags: flags, + Action: func(ctx context.Context, cmd *cli.Command) error { + if cmd.Bool("serve") { + return runServer(ctx, cmd) + } + return runOnce(ctx, cmd) + }, + } + + if err := app.Run(context.Background(), os.Args); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } +} + +func runOnce(ctx context.Context, cmd *cli.Command) error { + b, err := browser.FromCommand(ctx, cmd) + if err != nil { + return fmt.Errorf("failed to create browser: %w", err) + } + defer extractor.DeferClose(b) + + status, err := pizzint.GetStatus(ctx, b) + if err != nil { + return fmt.Errorf("failed to get pizza status: %w", err) + } + + out, err := json.MarshalIndent(status, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal status: %w", err) + } + + fmt.Println(string(out)) + return nil +} + +func runServer(ctx context.Context, cmd *cli.Command) error { + port := cmd.String("port") + if port == "" { + port = "8080" + } + + cacheTTL := 5 * time.Minute + if ttlStr := cmd.String("cache-ttl"); ttlStr != "" { + d, err := time.ParseDuration(ttlStr) + if err != nil { + return fmt.Errorf("invalid cache-ttl %q: %w", ttlStr, err) + } + cacheTTL = d + } + + srv := &statusServer{ + cmd: cmd, + cacheTTL: cacheTTL, + } + + mux := http.NewServeMux() + mux.HandleFunc("GET /status", srv.handleStatus) + mux.HandleFunc("GET /", srv.handleIndex) + + addr := ":" + port + slog.Info("starting pizza status server", "addr", addr, "cache_ttl", cacheTTL) + fmt.Fprintf(os.Stderr, "Pizza status server listening on http://localhost%s\n", addr) + fmt.Fprintf(os.Stderr, " GET /status — JSON pizza status\n") + fmt.Fprintf(os.Stderr, " GET / — human-readable status\n") + + httpSrv := &http.Server{Addr: addr, Handler: mux} + + go func() { + <-ctx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = httpSrv.Shutdown(shutdownCtx) + }() + + if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + return fmt.Errorf("server error: %w", err) + } + return nil +} + +type statusServer struct { + cmd *cli.Command + cacheTTL time.Duration + + mu sync.Mutex + cached *pizzint.PizzaStatus +} + +func (s *statusServer) fetch(ctx context.Context) (*pizzint.PizzaStatus, error) { + s.mu.Lock() + defer s.mu.Unlock() + + if s.cached != nil && time.Since(s.cached.FetchedAt) < s.cacheTTL { + return s.cached, nil + } + + b, err := browser.FromCommand(ctx, s.cmd) + if err != nil { + return nil, fmt.Errorf("failed to create browser: %w", err) + } + defer extractor.DeferClose(b) + + status, err := pizzint.GetStatus(ctx, b) + if err != nil { + return nil, err + } + + s.cached = status + return status, nil +} + +func (s *statusServer) handleStatus(w http.ResponseWriter, r *http.Request) { + status, err := s.fetch(r.Context()) + if err != nil { + slog.Error("failed to fetch pizza status", "err", err) + http.Error(w, `{"error": "failed to fetch pizza status"}`, http.StatusServiceUnavailable) + return + } + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d", int(s.cacheTTL.Seconds()))) + + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + if err := enc.Encode(status); err != nil { + slog.Error("failed to encode response", "err", err) + } +} + +func (s *statusServer) handleIndex(w http.ResponseWriter, r *http.Request) { + status, err := s.fetch(r.Context()) + if err != nil { + slog.Error("failed to fetch pizza status", "err", err) + http.Error(w, "Failed to fetch pizza status", http.StatusServiceUnavailable) + return + } + + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + + fmt.Fprintf(w, "=== PENTAGON PIZZA INDEX ===\n\n") + fmt.Fprintf(w, " %s\n", status.DoughconLevel) + fmt.Fprintf(w, " Overall Index: %d/100\n\n", status.OverallIndex) + + fmt.Fprintf(w, "--- Monitored Locations ---\n\n") + for _, r := range status.Restaurants { + fmt.Fprintf(w, " %-30s %s", r.Name, r.Status()) + if r.CurrentPopularity > 0 { + fmt.Fprintf(w, " (popularity: %d)", r.CurrentPopularity) + } + fmt.Fprintln(w) + } + + if len(status.Events) > 0 { + fmt.Fprintf(w, "\n--- Active Events ---\n\n") + for _, e := range status.Events { + fmt.Fprintf(w, " %s (%d min ago)\n", e.Name, e.MinutesAgo) + } + } + + fmt.Fprintf(w, "\nFetched: %s\n", status.FetchedAt.Format(time.RFC3339)) +} diff --git a/sites/pizzint/pizzint.go b/sites/pizzint/pizzint.go new file mode 100644 index 0000000..35ad222 --- /dev/null +++ b/sites/pizzint/pizzint.go @@ -0,0 +1,273 @@ +package pizzint + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "strings" + "time" + + "gitea.stevedudenhoeffer.com/steve/go-extractor" +) + +const dashboardAPIURL = "https://www.pizzint.watch/api/dashboard-data" + +// DoughconLevel represents the DOUGHCON threat level (modeled after DEFCON). +// Lower numbers indicate higher activity. +type DoughconLevel int + +const ( + DoughconMaximum DoughconLevel = 1 // Maximum Alert + DoughconHigh DoughconLevel = 2 // High Activity + DoughconElevated DoughconLevel = 3 // Elevated Activity + DoughconWatch DoughconLevel = 4 // Increased Intelligence Watch + DoughconQuiet DoughconLevel = 5 // All Quiet +) + +func (d DoughconLevel) String() string { + switch d { + case DoughconQuiet: + return "DOUGHCON 5 - ALL QUIET" + case DoughconWatch: + return "DOUGHCON 4 - DOUBLE TAKE" + case DoughconElevated: + return "DOUGHCON 3 - ELEVATED" + case DoughconHigh: + return "DOUGHCON 2 - HIGH ACTIVITY" + case DoughconMaximum: + return "DOUGHCON 1 - MAXIMUM ALERT" + default: + return fmt.Sprintf("DOUGHCON %d", d) + } +} + +// Label returns a short label for the DOUGHCON level. +func (d DoughconLevel) Label() string { + switch d { + case DoughconQuiet: + return "ALL QUIET" + case DoughconWatch: + return "DOUBLE TAKE" + case DoughconElevated: + return "ELEVATED" + case DoughconHigh: + return "HIGH ACTIVITY" + case DoughconMaximum: + return "MAXIMUM ALERT" + default: + return "UNKNOWN" + } +} + +// Restaurant represents a monitored pizza restaurant near the Pentagon. +type Restaurant struct { + Name string `json:"name"` + CurrentPopularity int `json:"current_popularity"` + PercentOfUsual *int `json:"percent_of_usual,omitempty"` + IsSpike bool `json:"is_spike"` + SpikeMagnitude string `json:"spike_magnitude,omitempty"` + IsClosed bool `json:"is_closed"` + DataFreshness string `json:"data_freshness"` +} + +// Status returns a human-readable status string like "QUIET", "CLOSED", or "139% SPIKE". +func (r Restaurant) Status() string { + if r.IsClosed { + return "CLOSED" + } + if r.IsSpike && r.PercentOfUsual != nil { + return fmt.Sprintf("%d%% SPIKE", *r.PercentOfUsual) + } + if r.IsSpike { + return "SPIKE" + } + return "QUIET" +} + +// Event represents a detected spike event at a monitored location. +type Event struct { + Name string `json:"name"` + MinutesAgo int `json:"minutes_ago"` +} + +// PizzaStatus is the top-level result from the PizzINT dashboard. +type PizzaStatus struct { + DoughconLevel DoughconLevel `json:"doughcon_level"` + DoughconLabel string `json:"doughcon_label"` + OverallIndex int `json:"overall_index"` + Restaurants []Restaurant `json:"restaurants"` + Events []Event `json:"events,omitempty"` + FetchedAt time.Time `json:"fetched_at"` +} + +// Config holds configuration for the PizzINT extractor. +type Config struct{} + +// DefaultConfig is the default PizzINT configuration. +var DefaultConfig = Config{} + +func (c Config) validate() Config { + return c +} + +// GetStatus fetches the current pizza activity status from the PizzINT dashboard. +func (c Config) GetStatus(ctx context.Context, b extractor.Browser) (*PizzaStatus, error) { + c = c.validate() + + slog.Info("fetching pizza status", "url", dashboardAPIURL) + doc, err := b.Open(ctx, dashboardAPIURL, extractor.OpenPageOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to open pizzint API: %w", err) + } + defer extractor.DeferClose(doc) + + return extractStatus(doc) +} + +// GetStatus is a convenience function using DefaultConfig. +func GetStatus(ctx context.Context, b extractor.Browser) (*PizzaStatus, error) { + return DefaultConfig.GetStatus(ctx, b) +} + +func extractStatus(doc extractor.Document) (*PizzaStatus, error) { + // The browser renders the JSON API response as text in the page body. + // doc.Text() returns InnerText of the html element, which should + // contain the raw JSON (possibly with extra browser UI text). + body, err := doc.Text() + if err != nil { + return nil, fmt.Errorf("failed to get page text: %w", err) + } + + jsonStr, err := findJSON(body) + if err != nil { + // Fall back to Content() which returns the full HTML — the JSON + // will be embedded in it (e.g. inside a
tag in Chromium).
+ html, herr := doc.Content()
+ if herr != nil {
+ return nil, fmt.Errorf("failed to extract JSON from text (%w) and failed to get HTML: %w", err, herr)
+ }
+ jsonStr, err = findJSON(html)
+ if err != nil {
+ return nil, fmt.Errorf("no valid JSON found in API response: %w", err)
+ }
+ }
+
+ var resp dashboardResponse
+ if err := json.Unmarshal([]byte(jsonStr), &resp); err != nil {
+ return nil, fmt.Errorf("failed to parse dashboard response: %w", err)
+ }
+
+ if !resp.Success {
+ return nil, fmt.Errorf("API returned success=false")
+ }
+
+ return resp.toPizzaStatus(), nil
+}
+
+// findJSON extracts a JSON object from a string by matching braces.
+func findJSON(s string) (string, error) {
+ start := strings.Index(s, "{")
+ if start == -1 {
+ return "", fmt.Errorf("no opening brace found")
+ }
+
+ depth := 0
+ inString := false
+ escape := false
+
+ for i := start; i < len(s); i++ {
+ ch := s[i]
+
+ if escape {
+ escape = false
+ continue
+ }
+
+ if ch == '\\' && inString {
+ escape = true
+ continue
+ }
+
+ if ch == '"' {
+ inString = !inString
+ continue
+ }
+
+ if inString {
+ continue
+ }
+
+ switch ch {
+ case '{':
+ depth++
+ case '}':
+ depth--
+ if depth == 0 {
+ return s[start : i+1], nil
+ }
+ }
+ }
+
+ return "", fmt.Errorf("no matching closing brace found")
+}
+
+// dashboardResponse is the raw API response from /api/dashboard-data.
+type dashboardResponse struct {
+ Success bool `json:"success"`
+ OverallIndex int `json:"overall_index"`
+ DefconLevel int `json:"defcon_level"`
+ Data []dashboardRestaurant `json:"data"`
+ Events []dashboardEvent `json:"events"`
+}
+
+type dashboardRestaurant struct {
+ PlaceID string `json:"place_id"`
+ Name string `json:"name"`
+ Address string `json:"address"`
+ CurrentPopularity int `json:"current_popularity"`
+ PercentageOfUsual *int `json:"percentage_of_usual"`
+ IsSpike bool `json:"is_spike"`
+ SpikeMagnitude *string `json:"spike_magnitude"`
+ DataSource string `json:"data_source"`
+ DataFreshness string `json:"data_freshness"`
+ IsClosedNow bool `json:"is_closed_now"`
+}
+
+type dashboardEvent struct {
+ Name string `json:"name"`
+ MinutesAgo int `json:"minutes_ago"`
+}
+
+func (r dashboardResponse) toPizzaStatus() *PizzaStatus {
+ status := &PizzaStatus{
+ DoughconLevel: DoughconLevel(r.DefconLevel),
+ OverallIndex: r.OverallIndex,
+ FetchedAt: time.Now(),
+ }
+ status.DoughconLabel = status.DoughconLevel.Label()
+
+ for _, d := range r.Data {
+ rest := Restaurant{
+ Name: d.Name,
+ CurrentPopularity: d.CurrentPopularity,
+ PercentOfUsual: d.PercentageOfUsual,
+ IsSpike: d.IsSpike,
+ IsClosed: d.IsClosedNow,
+ DataFreshness: d.DataFreshness,
+ }
+ if d.SpikeMagnitude != nil {
+ rest.SpikeMagnitude = *d.SpikeMagnitude
+ }
+ status.Restaurants = append(status.Restaurants, rest)
+ }
+
+ for _, e := range r.Events {
+ status.Events = append(status.Events, Event{
+ Name: e.Name,
+ MinutesAgo: e.MinutesAgo,
+ })
+ }
+
+ return status
+}
diff --git a/sites/pizzint/pizzint_test.go b/sites/pizzint/pizzint_test.go
new file mode 100644
index 0000000..31fc710
--- /dev/null
+++ b/sites/pizzint/pizzint_test.go
@@ -0,0 +1,306 @@
+package pizzint
+
+import (
+ "context"
+ "testing"
+
+ "gitea.stevedudenhoeffer.com/steve/go-extractor/extractortest"
+)
+
+const sampleAPIResponse = `{
+ "success": true,
+ "overall_index": 42,
+ "defcon_level": 3,
+ "data": [
+ {
+ "place_id": "abc123",
+ "name": "DOMINO'S PIZZA",
+ "address": "https://maps.google.com/test",
+ "current_popularity": 15,
+ "percentage_of_usual": null,
+ "is_spike": false,
+ "spike_magnitude": null,
+ "data_source": "live",
+ "data_freshness": "fresh",
+ "is_closed_now": false
+ },
+ {
+ "place_id": "def456",
+ "name": "EXTREME PIZZA",
+ "address": "https://maps.google.com/test2",
+ "current_popularity": 0,
+ "percentage_of_usual": null,
+ "is_spike": false,
+ "spike_magnitude": null,
+ "data_source": "live",
+ "data_freshness": "stale",
+ "is_closed_now": true
+ },
+ {
+ "place_id": "ghi789",
+ "name": "PIZZATO PIZZA",
+ "address": "https://maps.google.com/test3",
+ "current_popularity": 85,
+ "percentage_of_usual": 239,
+ "is_spike": true,
+ "spike_magnitude": "EXTREME",
+ "data_source": "live",
+ "data_freshness": "fresh",
+ "is_closed_now": false
+ }
+ ],
+ "events": [
+ {
+ "name": "PIZZATO PIZZA",
+ "minutes_ago": 5
+ }
+ ]
+}`
+
+func TestExtractStatus(t *testing.T) {
+ doc := &extractortest.MockDocument{
+ URLValue: dashboardAPIURL,
+ MockNode: extractortest.MockNode{
+ TextValue: sampleAPIResponse,
+ },
+ }
+
+ status, err := extractStatus(doc)
+ if err != nil {
+ t.Fatalf("extractStatus returned error: %v", err)
+ }
+
+ if status.DoughconLevel != DoughconElevated {
+ t.Errorf("DoughconLevel = %d, want %d", status.DoughconLevel, DoughconElevated)
+ }
+
+ if status.OverallIndex != 42 {
+ t.Errorf("OverallIndex = %d, want 42", status.OverallIndex)
+ }
+
+ if status.DoughconLabel != "ELEVATED" {
+ t.Errorf("DoughconLabel = %q, want %q", status.DoughconLabel, "ELEVATED")
+ }
+
+ if len(status.Restaurants) != 3 {
+ t.Fatalf("len(Restaurants) = %d, want 3", len(status.Restaurants))
+ }
+
+ // First restaurant: quiet
+ r0 := status.Restaurants[0]
+ if r0.Name != "DOMINO'S PIZZA" {
+ t.Errorf("Restaurants[0].Name = %q, want %q", r0.Name, "DOMINO'S PIZZA")
+ }
+ if r0.Status() != "QUIET" {
+ t.Errorf("Restaurants[0].Status() = %q, want %q", r0.Status(), "QUIET")
+ }
+ if r0.CurrentPopularity != 15 {
+ t.Errorf("Restaurants[0].CurrentPopularity = %d, want 15", r0.CurrentPopularity)
+ }
+
+ // Second restaurant: closed
+ r1 := status.Restaurants[1]
+ if r1.Status() != "CLOSED" {
+ t.Errorf("Restaurants[1].Status() = %q, want %q", r1.Status(), "CLOSED")
+ }
+ if !r1.IsClosed {
+ t.Error("Restaurants[1].IsClosed = false, want true")
+ }
+
+ // Third restaurant: spike
+ r2 := status.Restaurants[2]
+ if r2.Status() != "239% SPIKE" {
+ t.Errorf("Restaurants[2].Status() = %q, want %q", r2.Status(), "239% SPIKE")
+ }
+ if r2.SpikeMagnitude != "EXTREME" {
+ t.Errorf("Restaurants[2].SpikeMagnitude = %q, want %q", r2.SpikeMagnitude, "EXTREME")
+ }
+ if r2.CurrentPopularity != 85 {
+ t.Errorf("Restaurants[2].CurrentPopularity = %d, want 85", r2.CurrentPopularity)
+ }
+
+ // Events
+ if len(status.Events) != 1 {
+ t.Fatalf("len(Events) = %d, want 1", len(status.Events))
+ }
+ if status.Events[0].Name != "PIZZATO PIZZA" {
+ t.Errorf("Events[0].Name = %q, want %q", status.Events[0].Name, "PIZZATO PIZZA")
+ }
+ if status.Events[0].MinutesAgo != 5 {
+ t.Errorf("Events[0].MinutesAgo = %d, want 5", status.Events[0].MinutesAgo)
+ }
+}
+
+func TestExtractStatusFromHTML(t *testing.T) {
+ // Simulate Chromium wrapping JSON in a tag. doc.Text() returns
+ // the InnerText which may include the JSON, but Content() returns the
+ // raw HTML with the JSON inside .
+ htmlWrapped := `` + sampleAPIResponse + `
`
+
+ doc := &extractortest.MockDocument{
+ URLValue: dashboardAPIURL,
+ MockNode: extractortest.MockNode{
+ // Text() might fail or return garbage
+ TextValue: "",
+ // Content() returns the HTML
+ ContentValue: htmlWrapped,
+ },
+ }
+
+ status, err := extractStatus(doc)
+ if err != nil {
+ t.Fatalf("extractStatus returned error: %v", err)
+ }
+
+ if status.DoughconLevel != DoughconElevated {
+ t.Errorf("DoughconLevel = %d, want %d", status.DoughconLevel, DoughconElevated)
+ }
+
+ if len(status.Restaurants) != 3 {
+ t.Errorf("len(Restaurants) = %d, want 3", len(status.Restaurants))
+ }
+}
+
+func TestExtractStatusFailure(t *testing.T) {
+ doc := &extractortest.MockDocument{
+ URLValue: dashboardAPIURL,
+ MockNode: extractortest.MockNode{
+ TextValue: `{"success": false}`,
+ ContentValue: `{"success": false}`,
+ },
+ }
+
+ _, err := extractStatus(doc)
+ if err == nil {
+ t.Fatal("expected error for success=false response")
+ }
+}
+
+func TestGetStatus(t *testing.T) {
+ mock := &extractortest.MockBrowser{
+ Documents: map[string]*extractortest.MockDocument{
+ dashboardAPIURL: {
+ URLValue: dashboardAPIURL,
+ MockNode: extractortest.MockNode{
+ TextValue: sampleAPIResponse,
+ },
+ },
+ },
+ }
+
+ status, err := GetStatus(context.Background(), mock)
+ if err != nil {
+ t.Fatalf("GetStatus returned error: %v", err)
+ }
+
+ if status.OverallIndex != 42 {
+ t.Errorf("OverallIndex = %d, want 42", status.OverallIndex)
+ }
+}
+
+func TestFindJSON(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ want string
+ wantErr bool
+ }{
+ {
+ name: "plain JSON",
+ input: `{"key": "value"}`,
+ want: `{"key": "value"}`,
+ },
+ {
+ name: "JSON in HTML",
+ input: `{"key": "value"}`,
+ want: `{"key": "value"}`,
+ },
+ {
+ name: "nested braces",
+ input: `{"a": {"b": "c"}}`,
+ want: `{"a": {"b": "c"}}`,
+ },
+ {
+ name: "braces in strings",
+ input: `{"a": "hello {world}"}`,
+ want: `{"a": "hello {world}"}`,
+ },
+ {
+ name: "escaped quotes",
+ input: `{"a": "he said \"hi\""}`,
+ want: `{"a": "he said \"hi\""}`,
+ },
+ {
+ name: "no JSON",
+ input: "just some text",
+ wantErr: true,
+ },
+ {
+ name: "empty string",
+ input: "",
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := findJSON(tt.input)
+ if tt.wantErr {
+ if err == nil {
+ t.Error("expected error, got nil")
+ }
+ return
+ }
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if got != tt.want {
+ t.Errorf("findJSON() = %q, want %q", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestDoughconLevelString(t *testing.T) {
+ tests := []struct {
+ level DoughconLevel
+ want string
+ }{
+ {DoughconQuiet, "DOUGHCON 5 - ALL QUIET"},
+ {DoughconWatch, "DOUGHCON 4 - DOUBLE TAKE"},
+ {DoughconElevated, "DOUGHCON 3 - ELEVATED"},
+ {DoughconHigh, "DOUGHCON 2 - HIGH ACTIVITY"},
+ {DoughconMaximum, "DOUGHCON 1 - MAXIMUM ALERT"},
+ {DoughconLevel(99), "DOUGHCON 99"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.want, func(t *testing.T) {
+ if got := tt.level.String(); got != tt.want {
+ t.Errorf("String() = %q, want %q", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestRestaurantStatus(t *testing.T) {
+ pct := 150
+ tests := []struct {
+ name string
+ r Restaurant
+ want string
+ }{
+ {"quiet", Restaurant{Name: "Test"}, "QUIET"},
+ {"closed", Restaurant{IsClosed: true}, "CLOSED"},
+ {"spike with percent", Restaurant{IsSpike: true, PercentOfUsual: &pct}, "150% SPIKE"},
+ {"spike without percent", Restaurant{IsSpike: true}, "SPIKE"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := tt.r.Status(); got != tt.want {
+ t.Errorf("Status() = %q, want %q", got, tt.want)
+ }
+ })
+ }
+}