10 changed files with 380 additions and 24 deletions
@ -0,0 +1,31 @@ |
|||||
|
# HANDOFF |
||||
|
|
||||
|
Append-only role handoff log. Each role adds one entry when its step is complete. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
### T-001, T-002, T-003 — plan — 2026-10-12T10:58:09Z |
||||
|
|
||||
|
| Field | Value | |
||||
|
|-------|-------| |
||||
|
| Agent | claude | |
||||
|
| Summary | Planned the laundry status service as three tasks: status logic and payload parsing, sampler/MQTT/HTTP/page, then Docker and README | |
||||
|
| Files Changed | .ai/PLAN.md, .ai/TASKS.md, .ai/HANDOFF.md, ROADMAP.md | |
||||
|
| Next Role | implement | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
### T-001 — next_task (dev/all_task) — 2026-10-12T11:03:20Z |
||||
|
|
||||
|
| Field | Value | |
||||
|
|-------|-------| |
||||
|
| Agent | codex | |
||||
|
| Summary | Added a three-sample ring, laundry state evaluation, and numeric Tasmota power parsing with tests first and package documentation | |
||||
|
| Validation | fmt/vet/test PASS; E2E NOT_CONFIGURED; review PASS | |
||||
|
| Commit | feat(status): evaluate laundry state from the last three power samples | |
||||
|
| Next Role | dev commit | |
||||
|
|
||||
|
### T-001 — commit_task (all_task) — 2026-10-12T11:03:56Z |
||||
|
|
||||
|
- Status: done. Commit message read from the T-001 next_task entry. |
||||
|
- Included the existing roadmap and planning artifacts that define this cycle. |
||||
@ -0,0 +1,80 @@ |
|||||
|
# Plan |
||||
|
|
||||
|
Status: **ready_for_implement** |
||||
|
|
||||
|
Goal: implement the scope defined in `ROADMAP.md` — a phone-first webpage, served by a small Go backend in Docker, that shows whether the laundry is done based on the last 3 one-minute power samples from `tele/tasmota_washingmashine_C21183/SENSOR`. |
||||
|
|
||||
|
## Scope |
||||
|
- Go module `mqtt_washingmachine` (Go 1.26), standard library plus `github.com/eclipse/paho.mqtt.golang` for MQTT. |
||||
|
- Layout: |
||||
|
- `main.go`: config from env, wiring, graceful shutdown. |
||||
|
- `internal/status/`: pure logic (sample window, status evaluation). |
||||
|
- `internal/sensor/`: Tasmota payload parsing, MQTT subscription, per-minute sampler. |
||||
|
- `internal/web/`: HTTP handlers and embedded static page (`go:embed`). |
||||
|
- Docker packaging and documentation. |
||||
|
- Out of scope: persistence, TLS/auth, history beyond 3 samples, multiple devices. |
||||
|
|
||||
|
## Design decisions (from ROADMAP.md) |
||||
|
- Threshold: 3.00 W. A sample is "idle" only if `power < 3.0`; `>= 3.0` is "active". |
||||
|
- Sampling: the MQTT handler stores only the latest `ENERGY.Power` value and its receive time. A ticker fires every `SAMPLE_INTERVAL` (default 60s). At each tick: if a message was received since the previous tick, record its power as a sample; otherwise record a missing sample. Then clear the "received" flag. |
||||
|
- Window: ring of the last 3 samples (oldest first). Each sample is `{Power float64, Valid bool}`. |
||||
|
- Status evaluation (first match wins): |
||||
|
1. fewer than 3 samples recorded since start -> `waiting`, "Waiting for data...", grey |
||||
|
2. any of the last 3 samples missing -> `incomplete`, "Incomplete data", grey |
||||
|
3. any sample `>= 3.0` -> `running`, "Laundry not yet done", red |
||||
|
4. otherwise (all 3 `< 3.0`) -> `done`, "Laundry done", default text color |
||||
|
- JSON contract for `GET /api/status`: |
||||
|
```json |
||||
|
{"state":"running","message":"Laundry not yet done","readings":[{"power":47.7,"valid":true},{"power":0,"valid":false}]} |
||||
|
``` |
||||
|
`readings` has 0–3 entries, oldest first. The page renders the display text, color class, and `47.70W` / `--` formatting. |
||||
|
- Config env vars: `MQTT_BROKER` (default `tcp://192.168.1.12:1883`), `MQTT_TOPIC` (default `tele/tasmota_washingmashine_C21183/SENSOR`), `MQTT_USERNAME`, `MQTT_PASSWORD` (optional), `HTTP_ADDR` (default `:8080`), `SAMPLE_INTERVAL` (Go duration, default `60s`). |
||||
|
- MQTT client uses auto-reconnect and re-subscribes in the `OnConnect` handler. |
||||
|
|
||||
|
## Acceptance Criteria |
||||
|
- `GET /` returns a white-background page with a viewport meta tag, a very large status text, and the last 3 readings below it in normal-size black text formatted like `47.70W`; missing readings show `--`. |
||||
|
- The page fetches `/api/status` on load and every 60 s; no horizontal scroll at 360 px width. |
||||
|
- Status states and colors match the table in `ROADMAP.md` exactly, including `>= 3.00 W` = not done. |
||||
|
- The backend collects samples continuously with no page open; a page load immediately reflects the current state. |
||||
|
- A minute with no MQTT message produces a missing sample and `incomplete` state. |
||||
|
- Malformed payloads or payloads without `ENERGY.Power` are ignored (logged), without crashing or changing the latest value. |
||||
|
- `docker build` succeeds and the container runs with env-var configuration; README documents setup. |
||||
|
- `go fmt ./...`, `go vet ./...`, `go test ./...` all pass. |
||||
|
|
||||
|
## Tasks |
||||
|
|
||||
|
### T-001 — Status logic and Tasmota payload parsing |
||||
|
Tests first, then implementation. |
||||
|
- `internal/status/status_test.go`, `internal/status/status.go`: window of 3, `Evaluate`, boundary tests (2.99 -> idle, 3.00 -> active, 0 W all three -> done, mixed -> running, gap -> incomplete, <3 samples -> waiting). |
||||
|
- `internal/sensor/payload_test.go`, `internal/sensor/payload.go`: parse the sample payload from the roadmap (`Power` 139.60 etc.), missing `ENERGY`, missing `Power`, invalid JSON. |
||||
|
- `go.mod` (module init, no external deps yet in this task). |
||||
|
- Files to change: `go.mod`, `internal/status/*`, `internal/sensor/payload*.go`. |
||||
|
- Documentation: package doc comments; no README change yet. |
||||
|
|
||||
|
### T-002 — Sampler, MQTT subscription, HTTP server and page |
||||
|
Tests first, then implementation. |
||||
|
- `internal/sensor/sampler_test.go`, `internal/sensor/sampler.go`: latest-value store plus ticker-driven sampling into the window, with an injectable tick channel/clock for deterministic tests (message between ticks -> valid sample, none -> missing). |
||||
|
- `internal/sensor/mqtt.go`: paho client, auto-reconnect, subscribe in `OnConnect`, feeds payloads to the parser then the sampler (thin glue, not unit-tested against a real broker). |
||||
|
- `internal/web/handler_test.go`, `internal/web/handler.go`, `internal/web/static/index.html`: `/` serves the embedded page, `/api/status` returns the JSON contract (test with `httptest`, including state/message/readings per state). Page styling: white background, `font-size` about 3rem+ bold for status via `clamp()`, readings in black at normal size, red/grey classes, `meta viewport`, 60 s `setInterval` fetch, graceful display if the fetch fails. |
||||
|
- `main.go`: env config with defaults, wiring, signal-based shutdown. |
||||
|
- `go.mod`, `go.sum`: add `github.com/eclipse/paho.mqtt.golang`. |
||||
|
- Documentation: code comments; README deferred to T-003. |
||||
|
|
||||
|
### T-003 — Docker packaging and README |
||||
|
- `Dockerfile` (multi-stage: golang builder with `CGO_ENABLED=0`, final `scratch` or `distroless/static` image, `EXPOSE 8080`). |
||||
|
- `docker-compose.yml` example with the env vars from this plan. |
||||
|
- `.dockerignore`. |
||||
|
- `README.md`: replace the project TODOs (Overview, Getting Started) with what it does, status rules, env var table, `docker compose up` instructions, the Tasmota `TelePeriod` note (10 s, any value works since sampling is per minute), and limitations (in-memory state, LAN only, no auth). Keep the existing AI Workflow section untouched. English only. |
||||
|
- Files to change: `Dockerfile`, `docker-compose.yml`, `.dockerignore`, `README.md`. |
||||
|
- Validation extra: `docker build .` if Docker is available; otherwise note it in the handoff. |
||||
|
|
||||
|
## Validation |
||||
|
- `go fmt ./...` |
||||
|
- `go vet ./...` |
||||
|
- `go test ./...` |
||||
|
- Manual check after T-002: run with `HTTP_ADDR=:8080`, publish test payloads with `mosquitto_pub` (if available), open `/` at a phone-width viewport. |
||||
|
|
||||
|
## Commit subjects (suggested) |
||||
|
- T-001: `feat(status): evaluate laundry state from the last three power samples` |
||||
|
- T-002: `feat(web): show live laundry status page fed by MQTT power readings` |
||||
|
- T-003: `feat(docker): package laundry status service with Docker and README` |
||||
@ -0,0 +1,12 @@ |
|||||
|
# Review Log |
||||
|
|
||||
|
## Task: T-001 |
||||
|
|
||||
|
### Review Round 1 — 2026-10-12T11:03:20Z |
||||
|
|
||||
|
- Findings: no required fixes; implementation and package comments match the plan. |
||||
|
- Tests first: targeted tests initially failed on missing symbols, then passed after implementation. |
||||
|
- Validation: `go fmt ./...`, `go vet ./...`, `go test ./...`, and `git diff --check` PASS. |
||||
|
- Environment retry: initial validation hit a read-only Go build cache; rerun with approved cache access passed. No code retries or weakened assertions. |
||||
|
- E2E: NOT_CONFIGURED; this task contains only pure logic and parsing. |
||||
|
- Verdict: PASS, approved for the per-task commit under `all_task`. |
||||
@ -0,0 +1,25 @@ |
|||||
|
# TASKS |
||||
|
|
||||
|
Use this board to coordinate handoff between planner, implementer, and reviewer. |
||||
|
|
||||
|
Status values: |
||||
|
- `in_planning` |
||||
|
- `ready_for_implement` |
||||
|
- `in_implementation` |
||||
|
- `ready_for_review` |
||||
|
- `in_review` |
||||
|
- `ready_to_commit` |
||||
|
- `changes_requested` |
||||
|
- `done` |
||||
|
|
||||
|
Command expectations: |
||||
|
- planner moves tasks into `in_planning` and `ready_for_implement` |
||||
|
- implementer moves tasks into `in_implementation`, `ready_for_review`, and `done`, and resumes work from `changes_requested` and `ready_to_commit` |
||||
|
- reviewer moves tasks into `in_review`, `ready_to_commit`, or `changes_requested` |
||||
|
- `status_cycle` should report deterministic task status, current owner role, and next recommended action based on this board |
||||
|
|
||||
|
| Task ID | Scope | Status | Acceptance Criteria | Evidence | Next Role | |
||||
|
| --- | --- | --- | --- | --- | --- | |
||||
|
| T-001 | Status evaluation logic (3-sample window, 3.00 W threshold, waiting/incomplete/running/done) and Tasmota payload parsing, with tests first | done | All state/boundary tests pass (2.99 idle, 3.00 active, gap -> incomplete, <3 samples -> waiting); payload parser handles valid, missing ENERGY/Power, and invalid JSON; fmt/vet/test pass | 2026-10-12T11:03:20Z: tests first, fmt/vet/test PASS; review PASS | none | |
||||
|
| T-002 | Per-minute sampler, MQTT subscription, HTTP `/api/status` and phone-first page, `main.go` env config | ready_for_implement | Sampler records valid/missing samples per tick; `/api/status` matches JSON contract; page has white background, large status, `47.70W` readings, viewport meta, 60 s refresh; fmt/vet/test pass | n/a | implement | |
||||
|
| T-003 | Dockerfile, docker-compose example, .dockerignore, README documentation | ready_for_implement | Image builds (or noted if Docker unavailable); README documents rules, env vars, deployment, limitations; existing AI Workflow section untouched | n/a | implement | |
||||
@ -1,29 +1,38 @@ |
|||||
# ROADMAP |
# ROADMAP |
||||
|
|
||||
Goal: define and deliver the scope for this cycle. |
Goal: a small, phone-first webpage that tells whether the laundry is done, based on the washing machine's power consumption. |
||||
|
|
||||
Delete any unused example sections below. Only the Goal and one concrete priority are required. |
|
||||
|
|
||||
## Priority 1 |
## Priority 1 |
||||
|
|
||||
Objective: replace with objective. |
Objective: show the laundry status from the last 3 one-minute power samples of the Tasmota plug. |
||||
|
|
||||
- Replace with planned outcome. |
### Decided |
||||
|
|
||||
## Examples |
- Data source: MQTT topic `tele/tasmota_washingmashine_C21183/SENSOR` (Tasmota JSON, field `ENERGY.Power` in watts). |
||||
|
- Broker: `192.168.1.12:1883`, no credentials assumed. Address, port, topic and credentials (optional) are configurable via env vars. |
||||
These example sections are optional illustrations, not required structure. |
- Tasmota `TelePeriod` is 10 s, so the plug publishes about 6 messages per minute. |
||||
|
- Architecture: small Go backend, packaged in Docker (Dockerfile plus docker-compose example), deployed by the user on their own server. |
||||
<!-- Example: remove or replace this section --> |
- Subscribes to the broker over TCP and runs continuously, whether or not any page is open. |
||||
## Priority 2 |
- Sampling: it keeps the most recent `ENERGY.Power` message and records one sample per minute. A minute with no message received is recorded as a missing sample. |
||||
|
- Keeps the last 3 samples in memory. |
||||
Objective: optional second objective. |
- Serves the webpage and a JSON status endpoint (e.g. `/api/status`). Status is computed on the backend, so a page load shows the correct state at once. |
||||
|
- HTTP port is configurable, default 8080. |
||||
- Replace with optional planned outcome. |
- The page re-fetches the status every 60 s. |
||||
|
- Status rule (threshold 3 W, strictly below means idle): |
||||
<!-- Example: remove or replace this section --> |
|
||||
## Priority 3 |
| Condition | Message | Color | |
||||
|
|---|---|---| |
||||
Objective: optional third objective. |
| fewer than 3 samples since backend start | "Waiting for data..." | grey | |
||||
|
| any of the last 3 samples is missing | "Incomplete data" | grey | |
||||
- Replace with optional planned outcome. |
| all 3 samples under 3.00 W | "Laundry done" | default text color | |
||||
|
| otherwise (any sample 3.00 W or more) | "Laundry not yet done" | red | |
||||
|
|
||||
|
- Page design: |
||||
|
- White background, status text very large (the main element), readable on phones (responsive, viewport meta, no horizontal scroll). |
||||
|
- Below the status, the last 3 power values in normal-size black text with a `W` suffix and 2 decimals (e.g. `47.70W`). A missing sample is shown as `--`. |
||||
|
- Access: home Wi-Fi only. No TLS or authentication in scope. |
||||
|
- Language: English for UI text, code comments, logs, README. |
||||
|
|
||||
|
### Notes |
||||
|
|
||||
|
- State is in memory only. After a backend restart the page shows "Waiting for data..." until 3 samples are collected (about 3 minutes). |
||||
|
|||||
@ -0,0 +1,3 @@ |
|||||
|
module mqtt_washingmachine |
||||
|
|
||||
|
go 1.26.0 |
||||
@ -0,0 +1,25 @@ |
|||||
|
// Package sensor reads Tasmota ENERGY.Power measurements in watts.
|
||||
|
package sensor |
||||
|
|
||||
|
import ( |
||||
|
"encoding/json" |
||||
|
"errors" |
||||
|
"fmt" |
||||
|
) |
||||
|
|
||||
|
// ParsePower accepts numeric ENERGY.Power, including zero. Missing, null, or
|
||||
|
// malformed measurements return an error so callers can preserve the last value.
|
||||
|
func ParsePower(payload []byte) (float64, error) { |
||||
|
var message struct { |
||||
|
Energy *struct { |
||||
|
Power *float64 `json:"Power"` |
||||
|
} `json:"ENERGY"` |
||||
|
} |
||||
|
if err := json.Unmarshal(payload, &message); err != nil { |
||||
|
return 0, fmt.Errorf("decode sensor payload: %w", err) |
||||
|
} |
||||
|
if message.Energy == nil || message.Energy.Power == nil { |
||||
|
return 0, errors.New("sensor payload has no numeric ENERGY.Power") |
||||
|
} |
||||
|
return *message.Energy.Power, nil |
||||
|
} |
||||
@ -0,0 +1,34 @@ |
|||||
|
package sensor |
||||
|
|
||||
|
import "testing" |
||||
|
|
||||
|
func TestParsePower(t *testing.T) { |
||||
|
for _, tt := range []struct { |
||||
|
name string |
||||
|
payload string |
||||
|
power float64 |
||||
|
wantErr bool |
||||
|
}{ |
||||
|
{"tasmota", `{"Time":"2026-10-12T12:00:00","ENERGY":{"Total":12.5,"Voltage":230,"Power":139.60}}`, 139.6, false}, |
||||
|
{"zero", `{"ENERGY":{"Power":0}}`, 0, false}, |
||||
|
{"threshold", `{"ENERGY":{"Power":3.00}}`, 3, false}, |
||||
|
{"missing energy", `{}`, 0, true}, |
||||
|
{"missing power", `{"ENERGY":{"Voltage":230}}`, 0, true}, |
||||
|
{"null energy", `{"ENERGY":null}`, 0, true}, |
||||
|
{"null power", `{"ENERGY":{"Power":null}}`, 0, true}, |
||||
|
{"string power", `{"ENERGY":{"Power":"139.60"}}`, 0, true}, |
||||
|
{"overflow", `{"ENERGY":{"Power":1e1000}}`, 0, true}, |
||||
|
{"invalid json", `{"ENERGY":`, 0, true}, |
||||
|
{"trailing json", `{"ENERGY":{"Power":1}} {}`, 0, true}, |
||||
|
} { |
||||
|
t.Run(tt.name, func(t *testing.T) { |
||||
|
got, err := ParsePower([]byte(tt.payload)) |
||||
|
if (err != nil) != tt.wantErr { |
||||
|
t.Fatalf("ParsePower() error = %v; want error %v", err, tt.wantErr) |
||||
|
} |
||||
|
if !tt.wantErr && got != tt.power { |
||||
|
t.Errorf("ParsePower() = %v; want %v", got, tt.power) |
||||
|
} |
||||
|
}) |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,67 @@ |
|||||
|
// Package status evaluates laundry state from the latest three power samples.
|
||||
|
// Power below 3 W is idle; missing samples take precedence over active readings.
|
||||
|
package status |
||||
|
|
||||
|
const windowSize = 3 |
||||
|
|
||||
|
// Sample represents one sampling interval. Invalid samples have no power reading.
|
||||
|
type Sample struct { |
||||
|
Power float64 `json:"power"` |
||||
|
Valid bool `json:"valid"` |
||||
|
} |
||||
|
|
||||
|
// Result is the status API response, with readings ordered oldest first.
|
||||
|
type Result struct { |
||||
|
State string `json:"state"` |
||||
|
Message string `json:"message"` |
||||
|
Readings []Sample `json:"readings"` |
||||
|
} |
||||
|
|
||||
|
// Window holds the last three samples. Callers synchronize concurrent access.
|
||||
|
// The zero value is an empty window.
|
||||
|
type Window struct { |
||||
|
samples [windowSize]Sample |
||||
|
next int |
||||
|
count int |
||||
|
} |
||||
|
|
||||
|
// Add records an interval, including intervals without a valid reading.
|
||||
|
func (w *Window) Add(sample Sample) { |
||||
|
w.samples[w.next] = sample |
||||
|
w.next = (w.next + 1) % windowSize |
||||
|
if w.count < windowSize { |
||||
|
w.count++ |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Readings returns an independent snapshot, oldest first, including missing data.
|
||||
|
func (w *Window) Readings() []Sample { |
||||
|
readings := make([]Sample, w.count) |
||||
|
for i := range w.count { |
||||
|
readings[i] = w.samples[(w.next-w.count+i+windowSize)%windowSize] |
||||
|
} |
||||
|
return readings |
||||
|
} |
||||
|
|
||||
|
// Evaluate applies waiting, incomplete, running, then done precedence to a
|
||||
|
// window's readings. An empty window serializes as [] rather than null.
|
||||
|
func Evaluate(readings []Sample) Result { |
||||
|
result := Result{State: "waiting", Message: "Waiting for data...", Readings: append([]Sample{}, readings...)} |
||||
|
if len(readings) < windowSize { |
||||
|
return result |
||||
|
} |
||||
|
for _, sample := range readings { |
||||
|
if !sample.Valid { |
||||
|
result.State, result.Message = "incomplete", "Incomplete data" |
||||
|
return result |
||||
|
} |
||||
|
} |
||||
|
for _, sample := range readings { |
||||
|
if sample.Power >= 3 { |
||||
|
result.State, result.Message = "running", "Laundry not yet done" |
||||
|
return result |
||||
|
} |
||||
|
} |
||||
|
result.State, result.Message = "done", "Laundry done" |
||||
|
return result |
||||
|
} |
||||
@ -0,0 +1,70 @@ |
|||||
|
package status |
||||
|
|
||||
|
import ( |
||||
|
"reflect" |
||||
|
"testing" |
||||
|
) |
||||
|
|
||||
|
func TestEvaluate(t *testing.T) { |
||||
|
valid := func(power float64) Sample { return Sample{Power: power, Valid: true} } |
||||
|
for _, tt := range []struct { |
||||
|
name string |
||||
|
samples []Sample |
||||
|
state string |
||||
|
message string |
||||
|
}{ |
||||
|
{"no samples", nil, "waiting", "Waiting for data..."}, |
||||
|
{"one sample", []Sample{valid(10)}, "waiting", "Waiting for data..."}, |
||||
|
{"two samples with gap", []Sample{valid(10), {}}, "waiting", "Waiting for data..."}, |
||||
|
{"all zero", []Sample{valid(0), valid(0), valid(0)}, "done", "Laundry done"}, |
||||
|
{"below threshold", []Sample{valid(2.99), valid(2.99), valid(2.99)}, "done", "Laundry done"}, |
||||
|
{"at threshold", []Sample{valid(0), valid(3), valid(0)}, "running", "Laundry not yet done"}, |
||||
|
{"mixed", []Sample{valid(47.7), valid(0), valid(2.99)}, "running", "Laundry not yet done"}, |
||||
|
{"gap overrides active", []Sample{valid(47.7), {}, valid(0)}, "incomplete", "Incomplete data"}, |
||||
|
{"all missing", []Sample{{}, {}, {}}, "incomplete", "Incomplete data"}, |
||||
|
} { |
||||
|
t.Run(tt.name, func(t *testing.T) { |
||||
|
got := Evaluate(tt.samples) |
||||
|
if got.State != tt.state || got.Message != tt.message { |
||||
|
t.Fatalf("Evaluate() = %q, %q; want %q, %q", got.State, got.Message, tt.state, tt.message) |
||||
|
} |
||||
|
if len(got.Readings) != len(tt.samples) || got.Readings == nil { |
||||
|
t.Fatalf("readings = %#v; want non-nil slice of length %d", got.Readings, len(tt.samples)) |
||||
|
} |
||||
|
for i, sample := range tt.samples { |
||||
|
if got.Readings[i] != sample { |
||||
|
t.Errorf("readings[%d] = %+v; want %+v", i, got.Readings[i], sample) |
||||
|
} |
||||
|
} |
||||
|
}) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func TestWindowKeepsLastThreeOldestFirst(t *testing.T) { |
||||
|
var window Window |
||||
|
if got := window.Readings(); len(got) != 0 { |
||||
|
t.Fatalf("new window = %v", got) |
||||
|
} |
||||
|
for i := range 7 { |
||||
|
window.Add(Sample{Power: float64(i), Valid: true}) |
||||
|
} |
||||
|
want := []Sample{{Power: 4, Valid: true}, {Power: 5, Valid: true}, {Power: 6, Valid: true}} |
||||
|
if got := window.Readings(); !reflect.DeepEqual(got, want) { |
||||
|
t.Fatalf("Readings() = %v; want %v", got, want) |
||||
|
} |
||||
|
snapshot := window.Readings() |
||||
|
snapshot[0].Power = 999 |
||||
|
if got := window.Readings(); !reflect.DeepEqual(got, want) { |
||||
|
t.Fatal("mutating a snapshot changed the window") |
||||
|
} |
||||
|
for range 3 { |
||||
|
window.Add(Sample{Valid: true}) |
||||
|
} |
||||
|
if got := Evaluate(window.Readings()); got.State != "done" { |
||||
|
t.Fatalf("old active samples did not age out: %+v", got) |
||||
|
} |
||||
|
window.Add(Sample{}) |
||||
|
if got := Evaluate(window.Readings()); got.State != "incomplete" { |
||||
|
t.Fatalf("missing sample was not recorded: %+v", got) |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue