From c1c1acdb00ef681c9e066b7ccb317931ecc0f97c Mon Sep 17 00:00:00 2001 From: Steve Dudenhoeffer Date: Sun, 22 Feb 2026 05:45:55 +0000 Subject: [PATCH] feature: add PizzINT (Pentagon Pizza Index) site extractor Adds a new site extractor for pizzint.watch, which tracks pizza shop activity near the Pentagon as an OSINT indicator. The extractor fetches the dashboard API and exposes DOUGHCON levels, restaurant activity, and spike events. Includes a CLI tool with an HTTP server mode (--serve) for embedding the pizza status in dashboards or status displays. Co-Authored-By: Claude Opus 4.6 --- sites/pizzint/cmd/pizzint/main.go | 205 ++++++++++++++++++++ sites/pizzint/pizzint.go | 273 ++++++++++++++++++++++++++ sites/pizzint/pizzint_test.go | 306 ++++++++++++++++++++++++++++++ 3 files changed, 784 insertions(+) create mode 100644 sites/pizzint/cmd/pizzint/main.go create mode 100644 sites/pizzint/pizzint.go create mode 100644 sites/pizzint/pizzint_test.go 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) + } + }) + } +} -- 2.49.1