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 <noreply@anthropic.com>
This commit is contained in:
205
sites/pizzint/cmd/pizzint/main.go
Normal file
205
sites/pizzint/cmd/pizzint/main.go
Normal file
@@ -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))
|
||||
}
|
||||
273
sites/pizzint/pizzint.go
Normal file
273
sites/pizzint/pizzint.go
Normal file
@@ -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 <pre> 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
|
||||
}
|
||||
306
sites/pizzint/pizzint_test.go
Normal file
306
sites/pizzint/pizzint_test.go
Normal file
@@ -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 <pre> tag. doc.Text() returns
|
||||
// the InnerText which may include the JSON, but Content() returns the
|
||||
// raw HTML with the JSON inside <pre>.
|
||||
htmlWrapped := `<html><head></head><body><pre style="word-wrap: break-word;">` + sampleAPIResponse + `</pre></body></html>`
|
||||
|
||||
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: `<html><pre>{"key": "value"}</pre></html>`,
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user