feature: add PizzINT site extractor with HTTP API #67
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