1package logging_test23import (4 "bytes"5 "encoding/json"6 "flag"7 "log/slog"8 "os"9 "regexp"10 "strings"11 "testing"12 "time"1314 "github.com/FelineStateMachine/atlas/internal/logging"15)1617func TestParseLevel(t *testing.T) {18 good := map[string]slog.Level{19 "debug": slog.LevelDebug,20 "INFO": slog.LevelInfo,21 " warn ": slog.LevelWarn,22 "warning": slog.LevelWarn,23 "Error": slog.LevelError,24 }25 for name, want := range good {26 got, err := logging.ParseLevel(name)27 if err != nil || got != want {28 t.Errorf("ParseLevel(%q) = %v, %v, want %v", name, got, err, want)29 }30 }31 // A typo is a refusal, not a silent fall back: a run asked for debug and32 // got info has been lied to.33 for _, name := range []string{"", "verbose", "trace", "9"} {34 if _, err := logging.ParseLevel(name); err == nil {35 t.Errorf("ParseLevel(%q) was accepted", name)36 }37 }38}3940func TestLevelPrecedence(t *testing.T) {41 cases := []struct {42 name string43 flag string44 env string45 writes slog.Level46 silences slog.Level47 }{48 {"nothing said", "", "", slog.LevelInfo, slog.LevelDebug},49 {"the environment opens debug", "", "debug", slog.LevelDebug, slog.LevelDebug - 1},50 {"the flag opens debug", "debug", "", slog.LevelDebug, slog.LevelDebug - 1},51 {"the flag beats the environment", "error", "debug", slog.LevelError, slog.LevelWarn},52 }53 for _, test := range cases {54 t.Run(test.name, func(t *testing.T) {55 t.Setenv(logging.LevelEnv, test.env)56 var out bytes.Buffer57 logger, err := logging.New(&out, logging.Options{Level: test.flag})58 if err != nil {59 t.Fatal(err)60 }61 if !logger.Enabled(t.Context(), test.writes) {62 t.Errorf("%v is silenced", test.writes)63 }64 if logger.Enabled(t.Context(), test.silences) {65 t.Errorf("%v is written", test.silences)66 }67 })68 }69}7071func TestAnUnreadableLevelIsAUsageError(t *testing.T) {72 if _, err := logging.New(&bytes.Buffer{}, logging.Options{Level: "loud"}); err == nil {73 t.Error("an unreadable --log-level was accepted")74 }75 t.Setenv(logging.LevelEnv, "loud")76 _, err := logging.New(&bytes.Buffer{}, logging.Options{})77 if err == nil || !strings.Contains(err.Error(), logging.LevelEnv) {78 t.Errorf("an unreadable %s gave %v", logging.LevelEnv, err)79 }80}8182// Text is the default because a person reads it; JSON is a flag away because83// a machine does. Both carry the same attributes under the same keys.84func TestHandlersCarryTheVocabulary(t *testing.T) {85 attrs := []any{86 logging.Op("install"),87 logging.Volume("bend-or"),88 logging.World("2026-08-01"),89 logging.Lens("basemap"),90 logging.Stamp("ec3fe8c21cfe"),91 logging.Source("arcgis-hub"),92 logging.Enricher("national"),93 logging.Dur(250 * time.Millisecond),94 logging.Path("/tmp/x.atlas"),95 }9697 var text bytes.Buffer98 logger, err := logging.New(&text, logging.Options{})99 if err != nil {100 t.Fatal(err)101 }102 logger.Info("bundle installed", attrs...)103 for _, key := range logging.Vocabulary() {104 if !strings.Contains(text.String(), key+"=") {105 t.Errorf("the text stream drops %s: %s", key, text.String())106 }107 }108109 var machine bytes.Buffer110 logger, err = logging.New(&machine, logging.Options{JSON: true})111 if err != nil {112 t.Fatal(err)113 }114 logger.Info("bundle installed", attrs...)115 var event map[string]any116 if err := json.Unmarshal(machine.Bytes(), &event); err != nil {117 t.Fatalf("the JSON stream is not JSON: %v", err)118 }119 for _, key := range logging.Vocabulary() {120 if _, carried := event[key]; !carried {121 t.Errorf("the JSON stream drops %s: %s", key, machine.String())122 }123 }124 if event["msg"] != "bundle installed" || event["level"] != "INFO" {125 t.Errorf("the event reads as %v", event)126 }127}128129func TestBindRegistersTheDocumentedFlags(t *testing.T) {130 var options logging.Options131 fs := flag.NewFlagSet("atlas", flag.ContinueOnError)132 fs.SetOutput(&bytes.Buffer{})133 options.Bind(fs)134 if err := fs.Parse([]string{"--log-json", "--log-level", "debug"}); err != nil {135 t.Fatal(err)136 }137 if !options.JSON || options.Level != "debug" {138 t.Errorf("flags parsed to %+v", options)139 }140}141142// The vocabulary and its prose twin must name the same keys, the way the143// conventions registry and REGISTRY.md do.144func TestVocabularyAgreesWithItsDocument(t *testing.T) {145 doc, err := os.ReadFile("../../docs/logging.md")146 if err != nil {147 t.Fatal(err)148 }149 row := regexp.MustCompile("(?m)^\\| `([a-z]+)` \\| (string|duration)")150 documented := make(map[string]bool)151 for _, match := range row.FindAllStringSubmatch(string(doc), -1) {152 documented[match[1]] = true153 }154 for _, key := range logging.Vocabulary() {155 if !documented[key] {156 t.Errorf("%s is in the vocabulary but not documented", key)157 continue158 }159 delete(documented, key)160 }161 for key := range documented {162 t.Errorf("%s is documented but not in the vocabulary", key)163 }164165 // The level policy is the other half of the document; each level must166 // appear in its table.167 for _, level := range []string{"debug", "info", "warn", "error"} {168 if !strings.Contains(string(doc), "| `"+level+"` |") {169 t.Errorf("docs/logging.md does not state the %s level's intent", level)170 }171 }172}