Compare commits

..
Author SHA1 Message Date
xavierk d894ae290f docs: draft TUI polish companion spec 2026-09-14 02:13:34 +05:30
29 changed files with 582 additions and 7469 deletions
-10
View File
@@ -9,16 +9,6 @@ backfill releases from before this changelog.
## [Unreleased]
## [0.3.5] - 2026-09-14
### Added
- Show trustworthy first-use history, qualifying-day progress, and confidence in the dashboard.
- Add an interactive daily-writes graph with hourly drill-down and evidence-anchored scenario windows.
- Repair retained observation history safely and keep its retention state visible.
- Present a unified monitoring status, drive-health context, and Fenris identity in the dashboard.
- Remember accessible colour and motion preferences and keep the dashboard usable at constrained terminal sizes.
## [0.3.4] - 2026-09-10
### Added
+23 -3
View File
@@ -32,13 +32,25 @@ _Avoid_: Data directory, history.jsonl, the database (generic)
The condition where the observation store is present but cannot be read or trusted — unreadable, corrupt, or written by a newer Fenris — degrading every view that depends on it rather than crashing or guessing.
_Avoid_: Database error, corruption, broken data
**Usage interval**:
The elapsed span between two compatible counter observations, with a measured usage total whose distribution within that span may be unknown.
_Avoid_: Estimated hourly usage, interpolated sample
**Unallocated usage**:
Measured usage whose share in a particular hour, calendar day, or monitoring period cannot be established from the available evidence.
_Avoid_: Zero usage, evenly distributed writes
**Hour observation**:
One row per UTC hour in the observation store, recording that hour's usage-habit split into active, idle, powered-off, and unknown seconds, plus write/read deltas, thermal evidence, and coverage.
The usage-habit evidence for a UTC hour's represented elapsed span: active, idle, powered-off, and unknown time, measured usage, thermal evidence, and coverage. A partial hour does not describe future time.
_Avoid_: Hourly record, hourly.jsonl entry
**Day aggregate**:
One row per UTC day derived from hour observations; the grain at which usage-habit evidence is judged.
_Avoid_: Daily summary, daily stats
The UTC-day summary at which usage-habit evidence is judged; distinct from a local display day.
_Avoid_: Local daily total, daily stats
**Local display day**:
A calendar day in the user's current system timezone, used to browse observation history; its elapsed length can vary with timezone transitions.
_Avoid_: UTC evidence day, fixed 24-hour day
**Controller segment**:
A span of observation history within which the drive's controller identity is unchanged and counters are monotonic; write deltas are never computed across a segment boundary.
@@ -76,6 +88,14 @@ _Avoid_: Confidence interval, error bar
The share of wall-clock seconds inside monitoring periods whose usage-habit classification is known rather than unknown.
_Avoid_: Uptime, sample count
**Byte-allocation completeness**:
Whether the available evidence establishes all monitored writes attributable to a specified span, without missing counter evidence or unknown boundary shares; distinct from usage-habit classification coverage.
_Avoid_: Coverage, estimated allocation
**Qualifying day**:
A UTC date whose represented monitored time meets the coverage requirement for projection evidence. Qualification is provisional while the date is in progress and does not establish byte-allocation completeness.
_Avoid_: Completed day, supported day, calibration day
**Collection run**:
One scheduled or on-demand execution of the collector that interrogates the drive and extends the observation history.
_Avoid_: Poll, daemon tick
+16
View File
@@ -144,3 +144,19 @@ Decided in [Write the dashboard clarity acceptance criteria](https://git.bongbet
- **DC-6** (A) CHANGELOG.md shape (Keep a Changelog 1.1): `## [Unreleased]` always present at top, even empty; version headings `## [X.Y.Z] - YYYY-MM-DD` with strict ISO date; categories Added/Changed/Fixed only, security folding into Fixed; entries are single `- ` bullets, imperative mood, user-facing, no commit hashes or issue numbers.
- **DC-7** (A) Extraction fails closed: `scripts/extract_changelog.py` slices the requested version's section verbatim and never reads `[Unreleased]`; a missing or empty section or a malformed date produces `::error::` and a nonzero exit; the release workflow fails when the pushed tag ≠ `v{version from pyproject.toml}` (guard skipped on `workflow_dispatch`).
- **DC-8** (A/P) Release body: the body is the extracted section verbatim plus the standing footer from `packaging/release-footer.md`; a re-run against an existing release PATCHes the body (re-sync is a feature) while uploaded assets skip idempotently. A covers assembly/PATCH-logic unit tests; P is one scripted `workflow_dispatch` verification of body assembly.
## TUI polish and hourly history ([Fenris TUI polish and hourly history](https://git.bongbetic.com/xavierk/Fenris/issues/65))
Proposed by [Approve the Fenris TUI polish specification and handoff](https://git.bongbetic.com/xavierk/Fenris/issues/70), from the companion specification [`fenris-tui-polish-hourly-history.md`](fenris-tui-polish-hourly-history.md). This section amends the frozen redesign and prior dashboard-clarity criteria without editing their historical source specs. Where these criteria conflict with older TUI/header/history criteria, these newer criteria win. In particular, **TPH-1** supersedes **DC-1**'s header/credit placement; **TPH-2** and **TPH-3** refine **CI-2**, **LC-10**, and **TUI-4** status rendering; **TPH-4** through **TPH-7** refine **ST-5**, **PR-2** through **PR-9**, and **FL-1** through **FL-4** with the approved hourly-history and UTC-accounting contracts.
- **TPH-1** (A) *Titlebox and maker credit*: the TUI renders a top titlebox exactly `🐺 Fenris by Bongbetic`, falling back exactly to `Fenris by Bongbetic` when the wolf glyph is unsupported or width-unstable; no replacement-box glyph is shown; the old service-strip `by Bongbetic` credit is absent; `fenris status` renders no titlebox. Continuity, paused, quit, auth, parity, and release-notes behavior from **DC-2** through **DC-8** remains unchanged.
- **TPH-2** (A) *Status lattice and precedence*: fixture-driven TUI status rendering covers Monitoring, Collecting, Paused, Waiting, Interrupted, Error, Stale, and Unknown with the approved glyphs/text, semantic colours, and reason lines; only Monitoring's dot blinks, never text; reduced motion makes it steady; precedence is Error > Interrupted > Paused > Stale > Waiting > Monitoring > Unknown, with Collecting as an overlay except over store fault.
- **TPH-3** (A/P) *CLI/status parity and timing*: `fenris status` renders the same status vocabulary, glyphs, precedence, and reason lines statically; freshness, last outcome, boot enablement, and collection activity remain separate facts; a lightweight 5 s unit-state poll can move cached freshness boundaries without a store read, while new samples appear only after the normal store refresh; store faults render `observation store unreadable — see journal` and suppress store-dependent views.
- **TPH-4** (A) *Warm-up and withheld estimates*: projection warm-up shows `Building evidence — N of 14 days observed · Q qualifying` plus `First lifespan estimate after 12 qualifying days`; the gate is 14 represented UTC dates in the current controller segment with at least 12 qualifying, while Supported separately requires 14 qualifying dates and all existing prerequisites. Missing baseline, unsupported write counters, warm-up, stale evidence, paused days, and unavailable numerators render explicit reason lines, never blank or misleading zero; first graph-data availability is independent of lifespan-estimate availability.
- **TPH-5** (A) *Collector-owned history publication and first data*: collection publishes validated sample → usage interval → hour observation/day aggregate results consistently before reporting success; the TUI remains read-only. Zero samples show awaiting-first-sample; one sample shows `Awaiting another sample`; the first compatible sample pair can show measured partial-hour `so far`; measured zero is `0 B`; missing, unsupported, invalid, or unavailable evidence is never converted to zero.
- **TPH-6** (A) *Local display days, attribution, gaps, pauses, and repair*: history browsing groups retained evidence by the current system timezone with the timezone label visible, including DST and fractional-offset cases; timestamped usage intervals are retained indefinitely alongside hour/day summaries, while raw samples keep the 14-day policy except needed boundary anchors. Measured interval totals are preserved once; cross-boundary shares render as unallocated usage rather than interpolation or endpoint assignment; gaps remain distinct from zero; future time is not counted; deliberate-disable time is excluded; pause-crossing bytes are not counted as monitored totals; repair is transactional/idempotent and cannot overwrite valid older history with incomplete reconstruction.
- **TPH-7** (A) *UTC projection accounting and evaluability*: 7/28/90-day scenario windows end at the latest published usage-evidence endpoint `T` and start exactly 7/28/90 × 86,400 seconds earlier; denominators are monitored wall-clock seconds in the same span; rates are withheld when the monitored numerator cannot be established. Byte-allocation completeness and coverage are independent. Current partial UTC dates can qualify provisionally using elapsed monitored time; habit changes require completed consecutive UTC days with evaluable totals; unknown daily totals block burst/habit checks and Supported confidence; resets/replacements and legacy summaries obey the approved segment and actual-precision rules without changing lifespan math or numeric thresholds.
- **TPH-8** (A/M) *Writes-only graph and drill-down*: the TUI renders a writes-only daily bar graph, default 14 days, selectable 7/14/28/90 days, labelled `usage history · Local · UTC±HH:MM · <tz name>`; graph focus supports `←`/`→`, `Enter`, `Esc`/`Backspace`, and `1`/`2`/`3`/`4`, with mouse equivalents for select/drill/back where Textual support is available. Daily bars drill into hourly bars and back. The legend distinguishes allocated `█`, unallocated `▒`, gap `░`, measured zero `·`, partial `┄`, and selection `▼`; selected readout states totals, evidenced hours, unallocated usage, coverage, and partial elapsed facts. Reads graphing is out of scope.
- **TPH-9** (A) *Terminal size and graph implementation*: at 80×24 the default range graph and hourly drill-down fit; below 80×24 the graph region hides and shows a one-line textual history summary plus exactly `graph needs ≥80×24`, while titlebox, status reason, drive health, service facts, quit rail/action affordances, and selected-day context survive. The graph uses a custom block-glyph renderable; no new plotting dependency is added for this graph.
- **TPH-10** (A/M) *Colour presets, persistence, and reduced motion*: Amber, Nord, and High Contrast presets are available; Amber is the default and keeps the graph amber by default; status semantic colours/glyphs/text outrank theme styling. Preset and reduced-motion choices persist per unprivileged user at `${XDG_CONFIG_HOME:-~/.config}/fenris/tui.json`, not in `/etc/fenris/fenris.conf`, the observation store, helper state, package config, or collector/device configuration; missing preferences default to Amber and normal motion; `t preset` and `m motion` controls plus accessible clickable equivalents are available; preferences never affect collection, projection, history evidence, or `fenris status`.
- **TPH-11** (A) *Drive health and settings grouping*: vendor wear renders under Drive health with temperature, spare, media errors, unsafe shutdowns, power-on hours, cycles, capacity, and written-total context, and remains context rather than a second projection. Settings is read-only and limited to device selector, endurance baseline/provenance, retention facts, and TUI display preferences; no custom colour editor exists.
@@ -0,0 +1,351 @@
# Fenris TUI polish and hourly history companion specification
**Status:** approval-ready draft for [Approve the Fenris TUI polish specification and handoff](https://git.bongbetic.com/xavierk/Fenris/issues/70). It becomes implementation-ready only when that ticket records human approval. This is a planning asset: no production code, release gate, package, installation change, or runtime diagnosis is made here.
**Canonical sources.** This companion integrates the closed decisions on [Fenris TUI polish and hourly history](https://git.bongbetic.com/xavierk/Fenris/issues/65): [Define trustworthy hourly history and first-data availability](https://git.bongbetic.com/xavierk/Fenris/issues/66), [Choose daily graph encoding and hourly drill-down](https://git.bongbetic.com/xavierk/Fenris/issues/68), [Reconcile unallocated usage with UTC projection evidence](https://git.bongbetic.com/xavierk/Fenris/issues/71), [Define monitoring signals and honest warm-up estimates](https://git.bongbetic.com/xavierk/Fenris/issues/67), and [Approve titlebox, health layout and colour presets](https://git.bongbetic.com/xavierk/Fenris/issues/69). Terminology follows [`CONTEXT.md`](../../CONTEXT.md), including *Usage interval*, *Unallocated usage*, *Local display day*, *Byte-allocation completeness*, and *Qualifying day*.
**Relationship to existing specs.** [`fenris-redesign.md`](fenris-redesign.md) stays frozen. This companion amends it without editing it. [`dashboard-clarity.md`](dashboard-clarity.md) remains binding except where this document explicitly supersedes its header/credit placement. In the tracker, [Implement dashboard clarity and release notes](https://git.bongbetic.com/xavierk/Fenris/issues/61) is currently closed; this companion is a new follow-on handoff, not a rewrite or reopening of that shipped umbrella.
**Binding language.** *Must*, *exactly*, and *never* are normative.
## 1. Supersession and preserved requirements
This companion supersedes only these dashboard-clarity identity placements:
- the old header `Fenris — NVMe endurance monitor`;
- the old dimmed `by Bongbetic` credit in the service strip.
The new identity contract is the top titlebox in §2. The titlebox is the sole maker-credit surface.
Everything else from [Chart Fenris dashboard clarity](https://git.bongbetic.com/xavierk/Fenris/issues/55) remains intact unless a more specific clause below amends its placement: the continuity wording, paused block and Deliberate-disable semantics, `q QUIT TUI` rail, footer action ownership, polkit-accurate auth banner, TUI/CLI wording parity where binding, changelog-driven release notes, and the quit-versus-pause distinction. Polkit wording remains polkit-accurate; no new user-facing TUI/status string says "sudo".
## 2. Product identity, health layout, and settings
### 2.1 Titlebox
The TUI's top titlebox must read exactly:
```text
🐺 Fenris by Bongbetic
```
If the wolf glyph is unsupported or would disturb titlebox width, the fallback is exactly:
```text
Fenris by Bongbetic
```
Do not render tofu, replacement boxes, or an unstable emoji-width layout. The lifespan headline remains a drive/projection data surface, not the application title. `fenris status` does not render this titlebox.
### 2.2 Maker credit
Remove the duplicate `by Bongbetic` service-strip credit in the polished layout. The titlebox is the only maker-credit surface. This is the explicit amendment to the prior dashboard-clarity header/credit placement; it does not weaken the preserved continuity, pause, quit, auth, parity, or release-notes requirements.
### 2.3 Drive health and settings grouping
Move vendor wear under **Drive health**, alongside temperature, spare, media errors, unsafe shutdowns, power-on hours, cycles, capacity, and written-total context. Vendor wear remains context, never a second projection.
**Settings** remains read-only and limited to the device selector, endurance baseline/provenance, retention facts, and TUI display preferences. No custom colour editor is added.
## 3. Monitoring status, collection activity, and warm-up messaging
### 3.1 Status lattice
Every status line carries a glyph and text label. Colour is never the sole carrier. Text never blinks.
| State | Colour | Glyph | Motion | Exact status / explanation contract |
|---|---|---|---|---|
| Monitoring | green | `●` | blink 750 ms on / 750 ms off, status dot only | `● Monitoring` |
| Collecting | green | `◐` | steady | `◐ Collecting` — run in flight, bounded by the 90 s collection timeout |
| Paused | amber | `‖` | steady | `‖ Paused` — `monitoring paused — paused time excluded from your usage habit` |
| Waiting | amber | `○` | steady | `○ Waiting` — `last sample X ago`, `awaiting first sample`, or `awaiting another sample` |
| Interrupted | red | `⊘` | steady | `⊘ Interrupted` — `collection stopped outside Fenris — monitoring period still open` |
| Error | red | `✖` | steady | `✖ Error` — `last run failed (exit N)` plus `last good sample X ago` when data is still fresh, or `observation store unreadable — see journal` |
| Stale | red | `◌` | steady | `◌ Stale` — `last sample X days ago` when the timer is active and no failure is recorded |
| Unknown | grey | `?` | steady | `? Unknown` — `service state unavailable` |
Reduced motion, either from Textual's reduced-motion signal or the user's preference, renders Monitoring as a steady `● Monitoring`. The CLI form is always steady.
### 3.2 Precedence
Base-state precedence is:
```text
Error > Interrupted > Paused > Stale > Waiting > Monitoring > Unknown
```
Additional rules:
- Paused outranks Stale. A drive paused for 49 days is amber Paused with `last sample 49 days ago` as a fact line, not a stale alarm.
- Error outranks Interrupted; the external stop rides in the explanation line when both facts exist.
- Unknown is used only when the service query fails and no store-derived fact, such as an open monitoring period, freshness, or deliberate-pause row, places the state higher.
- Collecting is an overlay, not a base rung. While the oneshot is in flight it overrides every base state except a store fault. While overlaying Paused, Interrupted, or retry-after-failure, the explanation line names the underlying state: for example, `run in flight — paused` or `run in flight — retry`. It reverts within the 90 s collection timeout to whatever state the outcome earns.
### 3.3 Separate facts
Do not fold these facts into the status word:
- `Last sample: X ago` freshness;
- last outcome: ok, failed with exit code, or none;
- `Start at boot: on/off` boot enablement;
- collection activity, which is represented by the Collecting overlay.
Blink means exactly "monitoring enabled and data fresh". It never means that collection just succeeded; collection activity is explicit Collecting.
### 3.4 Timing and refresh
A lightweight 5 s `systemctl show` poll of the timer and service units drives the status strip. The full store refresh remains at the 5-minute cadence. Status transitions are re-evaluated every poll tick from cached newest-sample timestamp plus current clock, so freshness aging boundaries cross within roughly 5 s without a store read. A new sample requires the normal store refresh.
The existing constants remain unchanged: fresh is newest sample within 2 × cadence + `AccuracySec` + 60 s, stale is at 48 h, collection timeout is 90 s, and default cadence is 5 min.
### 3.5 Canonical transitions
The implementation must make these states directly observable:
- failed run, data 4 min old: `✖ Error` — `last run failed (exit 3) · last good sample 4 min ago`;
- next successful run: `● Monitoring` after the status poll/refresh clears the failure;
- failed run with retry in flight: `◐ Collecting` — `run in flight — retry`, then Error or Monitoring;
- suspend for 3 h, wake, timer fires: `○ Waiting` → `◐ Collecting` → `● Monitoring`;
- external stop with fresh data: `⊘ Interrupted` — `collection stopped outside Fenris`;
- external stop 3 days later: still `⊘ Interrupted`, with `last sample 3 days ago` visible;
- fresh install with zero samples: `○ Waiting` — `awaiting first sample`;
- one sample but no compatible pair: `○ Waiting` — `awaiting another sample`;
- unreadable observation store: `✖ Error` — `observation store unreadable — see journal`, with store-dependent views suppressed.
### 3.6 Warm-up and withheld estimates
Projection warm-up uses the UTC accounting rules in §5. The progress block during warm-up is:
```text
Building evidence — N of 14 days observed · Q qualifying
First lifespan estimate after 12 qualifying days
```
`N` counts distinct represented UTC dates in the current controller segment, capped at 14 for the display. `Q` counts dates whose represented monitored span has at least 50% coverage. The gate for the first lifespan estimate is 14 represented UTC dates with at least 12 qualifying; Supported confidence still separately requires at least 14 qualifying dates plus every other confidence prerequisite.
Do not promise a countdown by hours. Days are the grain, and a provisional day can still fail qualification. Graph-data availability is independent: the graph can render from first usable evidence while the lifespan estimate remains withheld.
Withheld-estimate reason lines are explicit, never blank and never zero-filled:
- `No endurance baseline — set a rated TBW to see an estimate`;
- `Drive does not report write counters`;
- the warm-up progress block above;
- stale evidence: show the estimate frozen at the latest published usage-evidence endpoint with `estimate not updating — last sample X ago`;
- paused days are excluded from the day count, and the Paused status carries that fact.
Once warm-up clears, the existing lifespan line and Limited/Supported label from the frozen redesign specification render unchanged. This companion adds the progress block, reason lines, status precedence, and frozen-note behavior; it does not invent a new steady-state projection format.
### 3.7 CLI parity
`fenris status` adopts the same status vocabulary, precedence, glyphs, and reason lines, rendered statically. It shows Collecting only if a run is in flight at query time. Display preferences and TUI themes never affect CLI facts.
## 4. History evidence, local browsing, and publication
### 4.1 Ownership and publication
The collector owns sample → usage interval → hour observation/day aggregate derivation. It must publish a consistent validated result before reporting collection success. Readers must not see a new sample advertised as fully derived while dependent history is missing.
The TUI is read-only. Its next successful refresh sees whatever the collector has durably published, regardless of whether the TUI was running during collection. There is no hourly batch wait and no projection-confidence gate on usage history. Failure preserves prior valid history and remains explicit.
### 4.2 First visible data
One successful sample establishes counter, health, and freshness evidence, but not a usage delta; display `Awaiting another sample`. The first usable sample pair may display measured partial-hour usage labelled `so far` when attribution supports it. A usable pair has valid ordered timestamps and supported, nonnegative monotonic counters in the same controller segment; monitored totals additionally require an interval fully inside one monitoring period.
A cross-hour pair is a real usage interval total, not two invented hour values. Measured zero is visible `0 B`. Missing, unsupported, invalid, or absent evidence is never converted to zero.
### 4.3 Local display days
Storage timestamps and projection evidence days remain UTC. History browsing uses the user's current system timezone, visibly labelled. If the system timezone changes, the same retained evidence regroups into the new local display days. Use real calendar boundaries: DST days may be 23 or 25 hours, repeated local hours have distinct offsets, and fractional UTC offsets must work without synthetic splitting.
### 4.4 Retention and repair
Retain timestamped usage intervals indefinitely alongside hour observations and day aggregates. Full raw samples retain the 14-day policy, with a boundary-anchor exception: do not prune a raw sample that is still needed to durably derive an unfinished interval/hour/day representation.
Repair derives only what surviving raw evidence supports, transactionally and idempotently. It preserves original evidence and valid historical summaries. Incomplete reconstruction must not overwrite valid older history. Unsupported historical precision stays unavailable; it is not repaired by interpolation or waiting. A failed repair remains visible and retryable.
### 4.5 Attribution and gaps
Preserve measured usage interval totals. Never divide them proportionally across hours or calendar days, never assign them to an endpoint as if timing were observed, and never double-count interval totals and summaries derived from the same evidence.
An interval entirely inside a local display day and one monitoring period may contribute its total to that local-day total even if individual hour shares are unknown. Otherwise the total is shown separately as unallocated usage. An incomplete allocated subtotal must not be presented as a complete total.
Missing samples reduce usage-habit coverage where classification is unknown, but they do not erase a compatible measured gap total. Byte-allocation completeness and usage-habit classification coverage are distinct. Do not infer zero writes from missing samples.
Partial summaries represent elapsed time only. Future time is neither zero nor unknown. Deliberately disabled time is excluded, not an hour state. Intervals crossing a deliberate pause cannot distinguish monitored from paused writes: preserve the original evidence, exclude ambiguous bytes from monitored totals, and explain why. External service stops remain unexplained in-period gaps, not Deliberate disables.
### 4.6 Controller segments in browsing
The default history view is the current controller segment. Older segments remain browsable with explicit reset/replacement boundaries. Never form a delta across a controller-segment boundary or silently combine different drives.
## 5. UTC projection accounting and confidence amendments
This section amends the projection contract without changing lifespan mathematics, numeric thresholds, or confidence categories.
### 5.1 Measured totals and requested spans
For any requested span, count a compatible interval's total exactly once when its complete span is inside the requested span, inside one monitoring period, and has eligible controller provenance. It may supply a complete window total even when individual UTC-hour/day shares are unknown.
A positive interval crossing a requested boundary cannot supply that window's unknown share. Preserve its measured total separately as unallocated usage. Do not split proportionally, assign to the ending day, silently omit possible bytes, or label an incomplete subtotal as complete.
Withhold a rate whenever its monitored numerator cannot be established: unresolved boundary shares, missing initial/resume/reset counter support, and legacy eligibility ambiguity are not zero. Do not shorten the requested window or remove unknown monitored seconds merely to obtain a number. A compatible monotonic interval with zero counter delta proves zero writes throughout its represented span, including a requested subspan; that is direct counter evidence, not interpolation.
### 5.2 Projection endpoints and denominator
Let `T` be the latest published usage-evidence endpoint. The 7/28/90-day scenario windows end at `T` and start exactly 7/28/90 × 86,400 seconds earlier. Do not round starts to UTC midnight, and do not dilute rates as the TUI read clock advances without new published usage evidence. Evidence age remains a separate fact.
The default sustained regime is eligible observation history capped at 90 days, ending at `T`. A detected habit-change regime starts at the first divergence day's UTC midnight. Any unresolved share at those boundaries invokes the unavailable-rate rule; there is no silent fallback to a more convenient start.
For the chosen span, the rate denominator is wall-clock seconds inside monitoring periods, including powered-off and unknown time, excluding deliberate-disable time. Numerator and denominator describe the same requested span. Pause-crossing intervals cannot establish which writes were monitored and therefore cannot supply affected monitored totals. No bridge crosses controller-segment boundaries.
### 5.3 UTC evidence dates, warm-up, and confidence
Projection evidence remains UTC; local graph regrouping never changes projection eligibility. Coverage uses known-classified seconds divided by represented elapsed monitored seconds, excluding deliberately disabled and future time.
A qualifying day is a distinct UTC date whose represented monitored span has coverage at least 50%. A current partial date counts provisionally and can lose qualification as unknown time accumulates. Count a date once, not once per hour, interval, or controller fragment. Entirely paused dates have no represented monitored span and do not count.
Warm-up clears when the current controller segment has at least 14 distinct represented UTC dates, at least 12 of which qualify. Supported confidence still requires at least 14 qualifying dates plus every other existing condition. Thus 12 qualifying plus 2 poor dates clears warm-up but remains Limited. Same-day segment breaks use only the current segment's eligible portion for its new-segment warm-up; a shared UTC date does not import old-segment qualification.
Habit-change comparisons require completed, consecutive UTC days with evaluable daily write totals. Do not compress missing calendar dates into adjacent-row windows or treat missing/disabled time as zero. Unknown daily totals cannot establish habit change and cannot pass the burst/concentration guard.
An affected scenario rate is withheld while other independently computable rates remain visible. If the headline regime lacks a complete monitored numerator, no lifespan number renders; show the specific evidence-unavailable reason. If a complete positive headline rate exists while daily shares remain unknown, the lifespan may render as Limited, but Supported is blocked wherever a required confidence check cannot be established.
### 5.4 Segment and legacy evidence
Never compute a delta across a reset or replacement boundary, even within one UTC hour/day. Same-identity reset preserves prior eligible habit evidence, subject to the existing current-segment re-warm gate before a lifespan number can render. Re-warming does not reconstruct missing counter support.
A controller-identity change, including to or from a blank key, quarantines previous identity from every projection horizon. Returning to a previously seen key does not undo the intervening quarantine. History browsing may still show older segments explicitly.
Trusted legacy day-only summaries remain usable at their actual represented precision: only in a window fully containing their represented span and only when monitoring-period/controller eligibility is known. They cannot supply subday detail, local-midnight splits, or partial rolling-window totals. Mixed legacy summaries that cannot separate eligible from ineligible writes remain browsable but cannot supply affected projection totals.
### 5.5 Projection acceptance examples
Future implementation must make these cases observable without using fabricated history:
1. 100 MB from 23:55 to 00:05 UTC in one period/segment: retain 100 MB once. Neither UTC-day share is known. A containing window can consume 100 MB; a window starting at midnight cannot claim an exact share or rate.
2. Sparse three-day recovery interval with compatible counters: a measured 900 MB total remains real and can supply a containing window. There is no 300 MB/day allocation.
3. A 7-day boundary cuts a positive interval and the 28-day boundary does not: withhold the 7-day rate, preserve the independently computable 28-day rate, and do not omit the unresolved old-enough horizon to manufacture Supported confidence.
4. The same boundary with zero measured delta: exact zero contribution is permitted for the represented subspan; missing time outside it remains unknown.
5. `T` at UTC noon: a 7-day window spans exactly 604,800 wall-clock seconds before subtracting deliberate-disable time. Refreshing the TUI without new evidence does not change the endpoint.
6. First sample arrives after monitoring starts: preceding monitored time lacks a write total. Keep its time, do not invent zero bytes, and withhold affected rates.
7. Pause/resume crossed by one counter interval: preserve total as original evidence, but do not count ambiguous paused writes as monitored.
8. 12 qualifying dates plus 2 poor dates: warm-up clears, Supported still fails the 14-qualifying-date requirement.
9. Good coverage with unknown daily shares: day progress can qualify; burst and habit checks remain unknown, not passed or zero-filled.
10. Only 14 days of eligible history: do not require 28/90-day horizons yet, but evaluate the burst guard over observed eligible history and keep unknown daily totals blocking.
11. Reset/replacement at 10:30 UTC: no cross-break delta or whole-date shortcut. Same-key reset preserves eligible prior habit evidence but requires new-segment re-warm; replacement excludes prior identity from scenarios too.
12. Trusted old UTC-day summary with raw samples gone: usable only in a window fully containing its represented span and known eligibility, once only; no partial local-day/hour split.
## 6. Usage history graph and interaction
### 6.1 Encoding
Adopt a writes-only daily bar graph with hourly drill-down. Reads are out of scope. Candles are rejected: they bury the daily total, import price-chart semantics that usage data does not have, and distort gap/partial evidence. A rolling hourly strip is rejected as the default because it lacks day totals without reintroducing the day level.
The graph's value is bytes written. The daily range view shows one bar per local display day. Activating a day drills into hourly bars for that selected local display day. Back returns to the range view.
### 6.2 Ranges, labels, and readout
Default range: 14 days. Selectable ranges: 7, 14, 28, and 90 days. Ranges limit the viewport only; they never delete retained history or hide older controller segments from browsing.
The header line is:
```text
usage history · Local · UTC±HH:MM · <tz name>
```
The day row uses day-of-month labels. The selected-day readout uses `Wed 09 Sep` style. Hourly detail labels every third hour `00…21` plus `midnight → 23:00 local`. DST and repeated local hours follow the Local display day contract.
The selected-item readout states totals, evidenced hours, unallocated usage separately, coverage, and partial-state text such as `partial · N h elapsed · so far`.
### 6.3 Controls
The graph pane is focusable. Keyboard controls:
- `←` / `→` select day or hour;
- `Enter` drills into the selected day;
- `Esc` / `Backspace` returns from hourly detail;
- `1` / `2` / `3` / `4` switch 7 / 14 / 28 / 90 days.
Mouse controls use widget-local coordinates to select bars. Where Textual mouse support is available, clickable equivalents must cover select, drill, and back. Global `p` / `r` / `c` / `d` / `q` remain unchanged, and the footer shows graph keys while the graph is focused.
### 6.4 State rendering
The legend is always visible when the graph is visible. Distinct glyphs:
| Glyph | Meaning |
|---|---|
| `█` | allocated measured writes |
| `▒` | unallocated measured writes, stacked separately |
| `░` | gap / no evidence, never zero |
| `·` | measured zero bytes |
| `┄` | partial-day cap |
| `▼` | selection marker |
Unallocated usage is measured, not fabricated; it is never spread into hours to make the graph look complete. Gaps remain distinct from measured zero. Deliberate-disable annotations remain visible.
### 6.5 Minimum terminal
At 80×24, the range view must fit the default 14-day graph using 4 columns per day, and hourly drill-down must fit 24 single-column hourly bars. Below 80×24, hide the graph region and show a one-line textual history summary plus exactly:
```text
graph needs ≥80×24
```
This is not an error. The titlebox, status label/reason, drive health, service facts, quit rail/action affordances, and selected-day readout or equivalent textual context must survive. Resizing must not leave stale graph state visible.
### 6.6 Framework obligation
Current Textual facts verified during charting: Textual 8.2.8 has no BarChart widget, Sparkline is non-interactive, and `textual-plotext` is not an installed dependency. Implement the graph as a custom block-glyph renderable in the usage-history pane, with click mapping from widget-local coordinates. Do not add a plotting dependency for the adopted graph.
## 7. Colour presets, persistence, and accessibility
### 7.1 Presets
Approved presets: Amber, Nord, and High Contrast. Amber is the default and keeps the graph amber by default. Other presets may use theme-appropriate graph/accent colours.
Themes style chrome, graph, borders, accents, and muted text. They do not override status semantics. The status lattice keeps semantic colours: green Monitoring/Collecting, amber Paused/Waiting, red Interrupted/Error/Stale, grey Unknown, always with glyph and text.
Preset palettes must maintain readable contrast in normal and focused states. High Contrast is a first-class preset, not merely a lightened Amber.
### 7.2 Persistence scope
Preset and reduced-motion choices are user-scoped TUI display preferences. Persist them at:
```text
${XDG_CONFIG_HOME:-~/.config}/fenris/tui.json
```
Do not store these preferences in `/etc/fenris/fenris.conf`, the observation store, helper state, package config, or collector/device configuration. Missing preferences default to Amber and normal motion. This preference file never affects collection, projection, history evidence, or CLI `status` facts.
### 7.3 Controls and reduced motion
Add accessible TUI controls for `t preset` and `m motion`, with clickable equivalents where Textual mouse support is available. Focusable graph/settings panes are acceptable as long as status text and global actions remain reachable.
Normal motion: only the Monitoring status dot blinks at the approved 750 ms on / 750 ms off cadence. Text never blinks, and no other state animates. Reduced motion: Monitoring renders steady as `● Monitoring`. No animation may imply successful collection.
Long reasons, paused states, degraded/error states, missing data, and warm-up/withheld-estimate lines must remain text-visible. Do not collapse them into colour, blank space, or a misleading zero.
### 7.4 Framework facts
The charting prototype verified current Textual documentation: `App.register_theme(theme)` and `App.theme` support theme registration/activation; Textual theme variables plus `$text` / `color: auto` support legibility; mouse events provide screen/widget-relative coordinates and focusable widgets can be resolved/clicked for keyboard+mouse proof paths. The existing lockfile still pins Textual 8.2.8; prototype branches remain throwaway assets and add no runtime dependency.
## 8. Future implementation proof paths
These are direct observable probes the execution effort can derive tests from; they are not a build-only checklist.
- Synthetic first-run store with zero samples: TUI and `fenris status` show Waiting/awaiting-first-sample; graph has no zero-filled bars; estimate is withheld with the correct reason.
- One sample followed by a compatible pair inside one hour: first state says `Awaiting another sample`; after the pair, graph shows a partial `so far` value. If the counter is unchanged, the evidenced interval is visible `0 B`.
- Cross-hour and cross-local-midnight intervals: totals are retained once; hour/day shares remain unallocated unless evidence supports them; gaps and zeros use distinct glyphs.
- Pause/resume-crossing interval: paused time is excluded, ambiguous bytes do not enter monitored totals, Paused status explains the consequence, and quitting the TUI does not pause monitoring.
- Failed collection with fresh data, retry in flight, external stop, and store fault: status precedence matches §3 and store fault suppresses store-dependent views.
- UTC projection horizon cut by a positive interval: affected rate is unavailable with a specific reason while independent horizons remain; refreshing without new evidence does not shift `T`.
- Warm-up fixtures for 12 qualifying + 2 poor dates and 14 qualifying dates: the first clears the progress gate but remains Limited; the second can be Supported only if all other prerequisites pass.
- Graph keyboard and mouse path: range switch → selected day → hourly detail → back, with focus/footer behavior and no conflict with global actions.
- 80×24 and narrower terminal captures: 80×24 keeps the graph; below 80×24 shows the exact fallback and preserves status/health/action context.
- Theme fixture: Amber default, switch to Nord/High Contrast, restart TUI under the same unprivileged user and see preference persist; `fenris status` and collection behavior are unchanged.
## 9. Out of scope
- Production implementation, release gates, package publishing, installation changes, and closing or reopening prior implementation umbrellas.
- Changing lifespan mathematics, evidence/confidence thresholds, collector cadence, or controller-segment semantics except for the explicit accounting/evaluability amendments in §5.
- Fabricating, interpolating, proportionally splitting, or endpoint-assigning missing history.
- Read-throughput graph selector, custom colour editor, web GUI, notifications, alerting, unrelated release-workflow changes, or a general application redesign beyond the named TUI requirements.
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "fenris"
version = "0.3.5"
version = "0.3.4"
description = "NVMe wear monitor with persistent TUI"
requires-python = ">=3.9"
dependencies = [
+5 -40
View File
@@ -16,8 +16,6 @@ from pathlib import Path
from typing import Any, Dict, Optional, Tuple
from .store import init_store, get_store_path
from .monitoring_periods import ensure_period_open
from .derive import find_previous_sample, derive_hours_from_interval
class AcquisitionError(Exception):
@@ -223,11 +221,7 @@ def write_sample(
open_segment(conn, now, identity, identity_key, identity_degraded)
segment_opened = True
# Get current segment_id for provenance
current_segment = find_current_segment(conn)
segment_id = current_segment["id"] if current_segment else None
# Insert sample with segment_id
# Insert sample
cursor = conn.execute(
"""
INSERT INTO samples (
@@ -235,8 +229,8 @@ def write_sample(
percentage_used, available_spare, media_errors, power_on_hours,
power_cycles, unsafe_shutdowns, temperature_c,
data_units_written, data_units_read, bytes_written, bytes_read,
critical_warning, segment_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
critical_warning
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
sample["ts"],
@@ -258,7 +252,6 @@ def write_sample(
sample["bytes_written"],
sample["bytes_read"],
sample["critical_warning"],
segment_id,
),
)
@@ -269,7 +262,6 @@ def write_sample(
"segment_reason": reason,
"identity_key": identity_key,
"identity_degraded": identity_degraded,
"segment_id": segment_id,
}
@@ -311,38 +303,11 @@ def run_collection(
try:
# Ensure monitoring period is open (issue #73 AC2)
ensure_period_open(conn, clock.utcnow())
# Validate invariants
validate_sample_invariants(sample, conn)
# Write sample and get segment info
seg_info = write_sample(sample, identity, conn, clock)
# Derive hour observations from interval with previous sample
try:
# Find the sample we just wrote
cursor = conn.execute("SELECT id FROM samples ORDER BY id DESC LIMIT 1")
current_id = cursor.fetchone()[0]
prev = find_previous_sample(conn, seg_info.get("segment_id"), current_id)
if prev is not None:
# Build current sample dict for derivation
current = {
"id": current_id,
"ts": sample["ts"],
"bytes_written": sample["bytes_written"],
"bytes_read": sample["bytes_read"],
"power_on_hours": sample["power_on_hours"],
"temperature_c": sample["temperature_c"],
"data_units_written": sample["data_units_written"],
"data_units_read": sample["data_units_read"],
}
derive_hours_from_interval(conn, prev, current)
except Exception:
# Derivation failure must not prevent sample persistence (issue #73 AC6)
pass
# Write sample
write_sample(sample, identity, conn, clock)
return {
"ok": True,
-237
View File
@@ -1,237 +0,0 @@
"""Interval derivation: samples → hour observations → day aggregates.
After each collection run, the collector calls into this module to:
1. Find the previous sample in the same segment
2. Compute deltas (bytes, POH, temperature)
3. Classify the hour(s) the interval spans
4. Write/update hour_observations for each affected hour
5. Update day_aggregates with unattributed cross-hour bytes
Cross-hour deltas are retained once with unknown shares explicit (issue #73 AC4).
No proportional allocation, endpoint assignment, or double counting.
"""
import sqlite3
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional, Tuple
from .hour_classify import classify_hour, HourSplit
def find_previous_sample(
conn: sqlite3.Connection,
segment_id: Optional[int],
current_sample_id: int,
) -> Optional[Dict[str, Any]]:
"""Find the most recent sample before current_sample_id in the same segment.
Returns None if no previous sample exists (first sample in segment).
"""
if segment_id is not None:
cursor = conn.execute(
"SELECT id, ts, bytes_written, bytes_read, power_on_hours, "
" temperature_c, data_units_written, data_units_read "
"FROM samples WHERE id < ? AND segment_id = ? "
"ORDER BY id DESC LIMIT 1",
(current_sample_id, segment_id),
)
else:
cursor = conn.execute(
"SELECT id, ts, bytes_written, bytes_read, power_on_hours, "
" temperature_c, data_units_written, data_units_read "
"FROM samples WHERE id < ? "
"ORDER BY id DESC LIMIT 1",
(current_sample_id,),
)
row = cursor.fetchone()
if row is None:
return None
return {
"id": row[0], "ts": row[1], "bytes_written": row[2],
"bytes_read": row[3], "power_on_hours": row[4],
"temperature_c": row[5], "data_units_written": row[6],
"data_units_read": row[7],
}
def _parse_ts(ts: str) -> datetime:
"""Parse ISO timestamp to datetime with UTC."""
dt = datetime.fromisoformat(ts)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt
def _hour_floor(dt: datetime) -> datetime:
"""Floor a datetime to its UTC hour boundary."""
return dt.replace(minute=0, second=0, microsecond=0)
def _hours_spanned(start: datetime, end: datetime) -> List[datetime]:
"""Return list of UTC hour boundaries spanned by [start, end)."""
hours = []
h = _hour_floor(start)
while h < end:
hours.append(h)
h += timedelta(hours=1)
return hours
def _compute_sampled_seconds_in_hour(
start: datetime, end: datetime, hour_start: datetime
) -> int:
"""How many seconds of the sample interval fall within this hour."""
hour_end = hour_start + timedelta(hours=1)
effective_start = max(start, hour_start)
effective_end = min(end, hour_end)
if effective_start >= effective_end:
return 0
return int((effective_end - effective_start).total_seconds())
def derive_hours_from_interval(
conn: sqlite3.Connection,
prev_sample: Dict[str, Any],
next_sample: Dict[str, Any],
) -> List[Dict[str, Any]]:
"""Derive hour observations from a sample pair interval.
Returns list of hour observation dicts that were written/updated.
"""
prev_ts = _parse_ts(prev_sample["ts"])
next_ts = _parse_ts(next_sample["ts"])
# Deltas
bw_delta = max(0, next_sample["bytes_written"] - prev_sample["bytes_written"])
br_delta = max(0, next_sample["bytes_read"] - prev_sample["bytes_read"])
poh_delta_s = max(0, (next_sample["power_on_hours"] - prev_sample["power_on_hours"])) * 3600
hours = _hours_spanned(prev_ts, next_ts)
total_span_s = int((next_ts - prev_ts).total_seconds())
results = []
if len(hours) == 1:
# Same-hour interval: fully attributed to this hour
hour_key = hours[0].strftime("%Y-%m-%dT%H:00:00+00:00")
sampled_s = total_span_s
# Classify hour
split = classify_hour(
wall_clock_seconds=3600,
poh_delta=poh_delta_s,
duw_delta=bw_delta,
dur_delta=br_delta,
sampled_seconds=sampled_s,
)
_upsert_hour_observation(
conn, hour_key, split,
bw_delta, br_delta,
prev_sample.get("temperature_c"), next_sample.get("temperature_c"),
2, # 2 samples contributed (prev + next)
)
results.append({"hour": hour_key, "bytes_written": bw_delta, "attributed": True})
elif len(hours) >= 2:
# Cross-hour interval: split wall-clock time, bytes unattributed
for h in hours:
hour_key = h.strftime("%Y-%m-%dT%H:00:00+00:00")
sampled_s = _compute_sampled_seconds_in_hour(prev_ts, next_ts, h)
# For cross-hour, we classify based on time only (no byte attribution)
# The hour gets its time split but NOT the byte delta
split = classify_hour(
wall_clock_seconds=3600,
poh_delta=0, # POH attribution unknown for cross-hour
duw_delta=0, # Bytes unattributed
dur_delta=0,
sampled_seconds=sampled_s,
)
_upsert_hour_observation(
conn, hour_key, split,
0, 0, # No byte attribution for cross-hour
None, None,
0, # No sample falls IN this hour
)
results.append({"hour": hour_key, "bytes_written": 0, "attributed": False})
# Track unattributed bytes at day level
_add_unattributed_bytes(conn, prev_ts, next_ts, bw_delta, br_delta)
return results
def _upsert_hour_observation(
conn: sqlite3.Connection,
hour_key: str,
split: HourSplit,
bw_delta: int,
br_delta: int,
temp_min: Optional[int],
temp_max: Optional[int],
sample_count: int,
) -> None:
"""Insert or update an hour observation."""
# Check if hour exists
existing = conn.execute(
"SELECT id, bytes_written_delta, sample_count FROM hour_observations WHERE hour = ?",
(hour_key,),
).fetchone()
if existing is None:
temp_avg = ((temp_min or 0) + (temp_max or 0)) / 2 if temp_min is not None else None
coverage = (split.seconds_active + split.seconds_idle + split.seconds_powered_off) / 3600.0
conn.execute(
"""INSERT INTO hour_observations
(hour, active_seconds, idle_seconds, powered_off_seconds, unknown_seconds,
bytes_written_delta, bytes_read_delta,
temperature_min, temperature_avg, temperature_max,
sample_count, coverage)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(hour_key, split.seconds_active, split.seconds_idle,
split.seconds_powered_off, split.seconds_unknown,
bw_delta, br_delta,
temp_min, temp_avg, temp_max,
sample_count, coverage),
)
else:
# Merge: accumulate bytes and sample count
new_bw = existing[1] + bw_delta
new_samples = existing[2] + sample_count
conn.execute(
"UPDATE hour_observations SET bytes_written_delta = ?, sample_count = ? WHERE id = ?",
(new_bw, new_samples, existing[0]),
)
conn.commit()
def _add_unattributed_bytes(
conn: sqlite3.Connection,
prev_ts: datetime,
next_ts: datetime,
bw_delta: int,
br_delta: int,
) -> None:
"""Add unattributed byte deltas to day aggregates for each day touched."""
prev_day = prev_ts.strftime("%Y-%m-%d")
next_day = next_ts.strftime("%Y-%m-%d")
days = {prev_day, next_day}
for day in days:
existing = conn.execute(
"SELECT id FROM day_aggregates WHERE day = ?", (day,)
).fetchone()
if existing is None:
conn.execute(
"INSERT INTO day_aggregates (day, unattributed_bytes_written, unattributed_bytes_read) "
"VALUES (?, ?, ?)",
(day, bw_delta, br_delta),
)
else:
conn.execute(
"UPDATE day_aggregates SET unattributed_bytes_written = unattributed_bytes_written + ?, "
"unattributed_bytes_read = unattributed_bytes_read + ? WHERE day = ?",
(bw_delta, br_delta, day),
)
conn.commit()
-98
View File
@@ -1,98 +0,0 @@
"""User-scoped TUI preferences (issue #80).
Persists theme preset and reduced-motion choice per unprivileged user.
Preferences live at XDG_CONFIG_HOME/fenris/preferences.json and must not
affect collection, projection, history evidence, helper state, package
config, or CLI status.
Safe failures: invalid/unreadable/unwritable data never crashes the
dashboard, corrupts previous preferences, or affects monitoring.
Failures are understandable rather than silently implying persistence
succeeded.
Criteria: TPH-10, AC80-2, AC80-3.
"""
import json
import os
from pathlib import Path
from typing import Any, Dict
PREFERENCE_FILE_NAME = "preferences.json"
VALID_THEMES = {"amber", "nord", "high_contrast"}
DEFAULT_THEME = "amber"
DEFAULT_REDUCED_MOTION = False
def get_preference_path() -> Path:
"""Return the user-scoped preference file path.
Uses XDG_CONFIG_HOME/fenris/preferences.json.
Falls back to ~/.config/fenris/preferences.json if unset.
"""
xdg = os.environ.get("XDG_CONFIG_HOME")
if xdg:
base = Path(xdg)
else:
base = Path.home() / ".config"
return base / "fenris" / PREFERENCE_FILE_NAME
def load_preferences() -> Dict[str, Any]:
"""Load user preferences with safe defaults.
Returns a dict with keys:
theme: str (one of VALID_THEMES)
reduced_motion: bool
If the file is missing, corrupt, unreadable, or contains invalid
values, returns safe defaults (Amber theme, normal motion).
"""
path = get_preference_path()
try:
text = path.read_text()
except (OSError, FileNotFoundError):
return _defaults()
try:
data = json.loads(text)
except (json.JSONDecodeError, ValueError):
return _defaults()
if not isinstance(data, dict):
return _defaults()
theme = data.get("theme", DEFAULT_THEME)
if theme not in VALID_THEMES:
theme = DEFAULT_THEME
reduced_motion = data.get("reduced_motion", DEFAULT_REDUCED_MOTION)
if not isinstance(reduced_motion, bool):
reduced_motion = DEFAULT_REDUCED_MOTION
return {"theme": theme, "reduced_motion": reduced_motion}
def save_preferences(theme: str = DEFAULT_THEME,
reduced_motion: bool = DEFAULT_REDUCED_MOTION) -> None:
"""Save user preferences.
Creates the config directory if needed. If the write fails
(read-only filesystem, permissions), the failure is swallowed —
the TUI continues with whatever was loaded, and the user sees
no crash or error.
"""
path = get_preference_path()
try:
path.parent.mkdir(parents=True, exist_ok=True)
payload = json.dumps({"theme": theme, "reduced_motion": reduced_motion}, indent=2)
path.write_text(payload + "\n")
except (OSError, PermissionError):
# Best-effort persistence — failure must not crash the TUI
pass
def _defaults() -> Dict[str, Any]:
return {"theme": DEFAULT_THEME, "reduced_motion": DEFAULT_REDUCED_MOTION}
+11 -51
View File
@@ -73,7 +73,6 @@ class ScenarioRange:
rates: Dict[int, float]
min_days: int
max_days: int
horizon_reasons: Dict[int, str] = field(default_factory=dict)
@dataclass(frozen=True)
@@ -92,7 +91,6 @@ class ProjectionResult:
staleness_fact: Optional[str]
degraded_identity_fact: Optional[str]
zero_rate_fact: Optional[str]
qualifying_days_progress: Optional[str] = None # Issue #77: honest qualifying-day progress
# ---------------------------------------------------------------------------
@@ -236,39 +234,20 @@ def _compute_regime_rate(days, conn, regime_start_day, clock_now):
return regime_bytes / regime_wc, regime_bytes, regime_wc
def _compute_horizon_rate(days, conn, horizon_days, evidence_endpoint):
"""Compute horizon rate anchored at the latest evidence endpoint T.
Uses exact trailing horizon_days × 86400 seconds from T, not clock_now.
Reader refresh alone never moves T or dilutes rates.
Returns (rate, reason) where reason is None on success or a string
describing why the rate is unavailable.
"""
if not days:
return None, "no observation history"
# T is the latest evidence endpoint — the end of the last day aggregate
T = datetime.fromisoformat(days[-1]["day"] + "T23:59:59+00:00")
# Exact trailing start: T minus horizon_days × 86400 seconds
h_start = T - timedelta(days=horizon_days)
cutoff = h_start.strftime("%Y-%m-%d")
def _compute_horizon_rate(days, conn, horizon_days, clock_now):
cutoff = (clock_now - timedelta(days=horizon_days)).strftime("%Y-%m-%d")
# History must span the full horizon — no placeholders
if days[0]["day"] > cutoff:
return None, "%d-day window starts before earliest data" % horizon_days
if not days or days[0]["day"] > cutoff:
return None
h_bytes = sum(d["bytes_written"] for d in days if d["day"] >= cutoff)
covered = sum(1 for d in days if d["day"] >= cutoff)
if covered == 0:
return None, "%d-day window has no data" % horizon_days
h_wc = _wall_clock_in_range(conn, h_start, T)
return None
h_start = datetime.fromisoformat(cutoff + "T00:00:00+00:00")
h_wc = _wall_clock_in_range(conn, h_start, clock_now)
if h_wc <= 0:
return None, "%d-day window has no monitored wall-clock time" % horizon_days
return h_bytes / h_wc, None
return None
return h_bytes / h_wc
# ---------------------------------------------------------------------------
@@ -529,35 +508,17 @@ def compute_projection(conn, clock_now):
scenario = None
horizon_rates = {}
horizon_reasons = {}
for h in HORIZON_DAYS:
hr, reason = _compute_horizon_rate(all_days, conn, h, clock_now)
hr = _compute_horizon_rate(all_days, conn, h, clock_now)
if hr is not None:
horizon_rates[h] = hr
else:
horizon_reasons[h] = reason
if horizon_rates:
scenario = ScenarioRange(
rates=horizon_rates,
min_days=min(horizon_rates),
max_days=max(horizon_rates),
horizon_reasons=horizon_reasons,
)
scenario = ScenarioRange(rates=horizon_rates, min_days=min(horizon_rates), max_days=max(horizon_rates))
total_days_count = len(segment_days)
days_below_coverage = sum(1 for d in segment_days
if d["coverage"] < WARMING_COVERAGE_FLOOR or d["sample_count"] == 0)
qualifying = total_days_count - days_below_coverage
# Issue #77: Show honest qualifying-day progress
if total_days_count >= WARMING_MIN_DAYS:
# After warm-up, show qualifying day details for transparency
qualifying_days_progress = "%d of %d qualifying days" % (qualifying, total_days_count)
if days_below_coverage > 0:
qualifying_days_progress += " (%d below coverage)" % days_below_coverage
else:
qualifying_days_progress = None
if total_days_count < WARMING_MIN_DAYS or days_below_coverage > WARMING_MAX_LOW_COVERAGE:
warming_fact = "warming up: %d of %d qualifying days" % (qualifying, WARMING_MIN_DAYS)
facts.append(warming_fact)
@@ -616,5 +577,4 @@ def compute_projection(conn, clock_now):
staleness_fact=staleness_fact,
degraded_identity_fact=degraded_identity_fact,
zero_rate_fact=zero_rate_fact,
qualifying_days_progress=qualifying_days_progress,
)
+2 -133
View File
@@ -2,7 +2,6 @@
Raw samples are pruned opportunistically to 14 days.
Hour observations and day aggregates are retained indefinitely.
Boundary anchors required for successor evidence are retained.
"""
import sqlite3
from datetime import datetime, timedelta, timezone
@@ -11,117 +10,6 @@ from datetime import datetime, timedelta, timezone
RAW_SAMPLE_RETENTION_DAYS = 14
def needs_boundary_anchor(
conn: sqlite3.Connection,
sample_ts: str,
now: datetime,
) -> bool:
"""Check if a sample is needed as a boundary anchor for derivation.
A sample is a boundary anchor if:
1. It's older than retention_days (strictly before cutoff)
2. It has a next sample that forms an interval spanning the retention boundary
3. The interval hasn't been derived yet
The interval spans the boundary if:
- The sample is before the cutoff, AND
- The next sample is strictly after the cutoff (or within retention)
"""
from .derive import _parse_ts
sample_dt = _parse_ts(sample_ts)
retention_cutoff = now - timedelta(days=RAW_SAMPLE_RETENTION_DAYS)
# If sample is within retention (strictly after cutoff), not an anchor
if sample_dt > retention_cutoff:
return False
# Check if this sample has a next sample
cursor = conn.execute(
"""SELECT ts, segment_id FROM samples WHERE ts > ? ORDER BY ts LIMIT 1""",
(sample_ts,),
)
next_row = cursor.fetchone()
if next_row is None:
# No next sample - this is the last sample
# It's not needed for derivation (no interval to derive)
return False
next_ts_str = next_row[0]
next_segment_id = next_row[1]
next_dt = _parse_ts(next_ts_str)
# Check if the next sample is strictly after the cutoff (i.e., interval spans boundary)
if next_dt > retention_cutoff:
# The interval spans the retention boundary
# Check if the interval needs derivation
# Get current sample's segment_id
cursor = conn.execute(
"SELECT segment_id FROM samples WHERE ts = ?",
(sample_ts,),
)
current_segment_row = cursor.fetchone()
current_segment_id = current_segment_row[0] if current_segment_row else None
# If different segments, no interval to derive
if current_segment_id != next_segment_id:
return False
# Check if the interval [sample_ts, next_ts] needs derivation
# It needs derivation if any hour in the span lacks an observation
current_hour = sample_dt.replace(minute=0, second=0, microsecond=0)
end_hour = next_dt.replace(minute=0, second=0, microsecond=0)
while current_hour <= end_hour:
cursor = conn.execute(
"SELECT id FROM hour_observations WHERE hour = ?",
(current_hour.isoformat(),),
)
if cursor.fetchone() is None:
# This hour lacks an observation - interval needs derivation
return True
current_hour += timedelta(hours=1)
# All hours in the span have observations - interval is derived
return False
else:
# The interval doesn't span the boundary (both samples are old)
# Check if the interval needs derivation
# Get current sample's segment_id
cursor = conn.execute(
"SELECT segment_id FROM samples WHERE ts = ?",
(sample_ts,),
)
current_segment_row = cursor.fetchone()
current_segment_id = current_segment_row[0] if current_segment_row else None
# If different segments, no interval to derive
if current_segment_id != next_segment_id:
return False
# Check if the interval [sample_ts, next_ts] needs derivation
current_hour = sample_dt.replace(minute=0, second=0, microsecond=0)
end_hour = next_dt.replace(minute=0, second=0, microsecond=0)
while current_hour <= end_hour:
cursor = conn.execute(
"SELECT id FROM hour_observations WHERE hour = ?",
(current_hour.isoformat(),),
)
if cursor.fetchone() is None:
# This hour lacks an observation - interval needs derivation
# But only keep if the interval is significant (spans multiple hours)
# or if the next sample is the last sample before a gap
gap = (next_dt - sample_dt).total_seconds()
if gap > 24 * 3600: # Significant gap (> 24 hours)
return True
current_hour += timedelta(hours=1)
# All hours in the span have observations or gap is not significant
return False
def prune_old_samples(
conn: sqlite3.Connection,
now: datetime,
@@ -129,8 +17,6 @@ def prune_old_samples(
) -> int:
"""Remove raw samples older than retention_days.
Retains boundary anchors required for successor evidence.
Args:
conn: Connection to the observation store.
now: Current UTC time.
@@ -140,23 +26,6 @@ def prune_old_samples(
Number of samples removed.
"""
cutoff = (now - timedelta(days=retention_days)).isoformat()
# Get all samples older than cutoff
cursor = conn.execute(
"SELECT id, ts FROM samples WHERE ts < ? ORDER BY ts",
(cutoff,),
)
old_samples = cursor.fetchall()
removed = 0
for sample_id, sample_ts in old_samples:
# Check if this sample is a boundary anchor
if needs_boundary_anchor(conn, sample_ts, now):
continue # Skip - it's a boundary anchor
# Remove the sample
conn.execute("DELETE FROM samples WHERE id = ?", (sample_id,))
removed += 1
cursor = conn.execute("DELETE FROM samples WHERE ts < ?", (cutoff,))
conn.commit()
return removed
return cursor.rowcount
-448
View File
@@ -1,448 +0,0 @@
"""Historical repair and retention (issue #74).
Re-derives hour observations and day aggregates from surviving raw samples,
with idempotent and interruption-safe guarantees. Preserves import markers,
boundary anchors, and valid historical summaries.
Contracts:
- Idempotent: running repair multiple times produces no duplicates
- Interruption-safe: partial repair preserves prior valid history
- Preserves existing valid data: never overwrites valid derived data
- Surfaces failures explicitly for retry
"""
import logging
import sqlite3
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from typing import Optional, List, Tuple
from .derive import find_previous_sample, derive_hours_from_interval, _parse_ts
logger = logging.getLogger(__name__)
@dataclass
class RepairResult:
"""Result of a repair operation."""
ok: bool
hours_created: int = 0
hours_updated: int = 0
days_created: int = 0
days_updated: int = 0
intervals_derived: int = 0
boundary_anchors_retained: int = 0
legacy_summaries_preserved: int = 0
error: Optional[str] = None
@dataclass
class RepairStatus:
"""Current repair status for read-only views."""
last_repair: Optional[str] = None # ISO timestamp of last successful repair
repair_in_progress: bool = False
hours_derived: int = 0
days_derived: int = 0
def _ensure_repair_metadata(conn: sqlite3.Connection) -> None:
"""Ensure metadata table exists for tracking repair state."""
conn.execute("""
CREATE TABLE IF NOT EXISTS store_metadata (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)
""")
def is_repair_in_progress(conn: sqlite3.Connection) -> bool:
"""Check if a repair operation is currently in progress."""
_ensure_repair_metadata(conn)
cursor = conn.execute(
"SELECT value FROM store_metadata WHERE key = 'repair_in_progress'"
)
row = cursor.fetchone()
return row is not None and row[0] == "true"
def _set_repair_in_progress(conn: sqlite3.Connection, in_progress: bool) -> None:
"""Mark repair as in progress or complete."""
_ensure_repair_metadata(conn)
conn.execute(
"INSERT OR REPLACE INTO store_metadata (key, value) VALUES (?, ?)",
("repair_in_progress", "true" if in_progress else "false"),
)
conn.commit()
def _update_repair_status(conn: sqlite3.Connection, result: RepairResult) -> None:
"""Update repair status after successful completion."""
_ensure_repair_metadata(conn)
now = datetime.now(timezone.utc).isoformat()
# Update last repair timestamp
conn.execute(
"INSERT OR REPLACE INTO store_metadata (key, value) VALUES (?, ?)",
("last_repair", now),
)
# Update derived counts
cursor = conn.execute("SELECT COUNT(*) FROM hour_observations")
hours = cursor.fetchone()[0]
cursor = conn.execute("SELECT COUNT(*) FROM day_aggregates")
days = cursor.fetchone()[0]
conn.execute(
"INSERT OR REPLACE INTO store_metadata (key, value) VALUES (?, ?)",
("hours_derived", str(hours)),
)
conn.execute(
"INSERT OR REPLACE INTO store_metadata (key, value) VALUES (?, ?)",
("days_derived", str(days)),
)
conn.commit()
def get_repair_status(conn: sqlite3.Connection) -> RepairStatus:
"""Get current repair status for read-only views."""
_ensure_repair_metadata(conn)
last_repair = None
cursor = conn.execute(
"SELECT value FROM store_metadata WHERE key = 'last_repair'"
)
row = cursor.fetchone()
if row:
last_repair = row[0]
in_progress = is_repair_in_progress(conn)
hours_derived = 0
cursor = conn.execute(
"SELECT value FROM store_metadata WHERE key = 'hours_derived'"
)
row = cursor.fetchone()
if row:
hours_derived = int(row[0])
days_derived = 0
cursor = conn.execute(
"SELECT value FROM store_metadata WHERE key = 'days_derived'"
)
row = cursor.fetchone()
if row:
days_derived = int(row[0])
return RepairStatus(
last_repair=last_repair,
repair_in_progress=in_progress,
hours_derived=hours_derived,
days_derived=days_derived,
)
def needs_boundary_anchor(
conn: sqlite3.Connection,
sample_ts: str,
now: datetime,
) -> bool:
"""Check if a sample is needed as a boundary anchor for derivation.
A sample is a boundary anchor if:
1. It's older than retention_days
2. It has no derived hour observation for its hour
3. It's the last sample before a gap that needs derivation (gap > 24 hours)
"""
from .pruning import RAW_SAMPLE_RETENTION_DAYS
sample_dt = _parse_ts(sample_ts)
retention_cutoff = now - timedelta(days=RAW_SAMPLE_RETENTION_DAYS)
# If sample is within retention, not an anchor (will be kept anyway)
if sample_dt >= retention_cutoff:
return False
# Check if this sample's hour already has a derived observation
hour_start = sample_dt.replace(minute=0, second=0, microsecond=0).isoformat()
hour_end = (sample_dt + timedelta(hours=1)).replace(minute=0, second=0, microsecond=0).isoformat()
cursor = conn.execute(
"""SELECT COUNT(*) FROM hour_observations
WHERE hour >= ? AND hour < ?""",
(hour_start, hour_end),
)
# If there's an hour observation in this sample's hour, it's been derived
if cursor.fetchone()[0] > 0:
return False
# Check if this sample is the last sample before a gap
# (i.e., the next sample is significantly later)
cursor = conn.execute(
"""SELECT ts FROM samples WHERE ts > ? ORDER BY ts LIMIT 1""",
(sample_ts,),
)
next_row = cursor.fetchone()
if next_row is None:
# No next sample - this is the last sample, might be needed
# But if it's old and fully derived, it's not needed
return False
next_ts = _parse_ts(next_row[0])
gap = (next_ts - sample_dt).total_seconds()
# If gap > 24 hours, this sample is a boundary anchor
# (needed to derive the interval spanning the gap)
return gap > 24 * 3600
def _get_unlinked_intervals(conn: sqlite3.Connection) -> List[Tuple[dict, dict]]:
"""Find sample pairs that form intervals but have no hour observations."""
cursor = conn.execute(
"""SELECT id, ts, bytes_written, bytes_read, power_on_hours,
temperature_c, data_units_written, data_units_read, segment_id
FROM samples ORDER BY ts"""
)
all_samples = []
for row in cursor.fetchall():
all_samples.append({
"id": row[0], "ts": row[1], "bytes_written": row[2],
"bytes_read": row[3], "power_on_hours": row[4],
"temperature_c": row[5], "data_units_written": row[6],
"data_units_read": row[7], "segment_id": row[8],
})
intervals = []
for i in range(len(all_samples) - 1):
prev = all_samples[i]
next_s = all_samples[i + 1]
# Skip if different segments
if prev["segment_id"] != next_s["segment_id"]:
continue
# Check if the interval spans hours that need derivation
prev_dt = _parse_ts(prev["ts"])
next_dt = _parse_ts(next_s["ts"])
# Check if any hour in the span lacks an observation
current = prev_dt.replace(minute=0, second=0, microsecond=0)
end = next_dt.replace(minute=0, second=0, microsecond=0)
needs_derivation = False
while current <= end:
cursor2 = conn.execute(
"SELECT id FROM hour_observations WHERE hour = ?",
(current.isoformat(),),
)
if cursor2.fetchone() is None:
needs_derivation = True
break
current += timedelta(hours=1)
if needs_derivation:
intervals.append((prev, next_s))
return intervals
def _derive_day_aggregate_from_hours(
conn: sqlite3.Connection,
day: str,
) -> Optional[dict]:
"""Derive a day aggregate from its hour observations."""
cursor = conn.execute(
"""SELECT SUM(active_seconds), SUM(idle_seconds),
SUM(powered_off_seconds), SUM(unknown_seconds),
SUM(bytes_written_delta), SUM(bytes_read_delta),
SUM(sample_count)
FROM hour_observations WHERE hour LIKE ?""",
(day + "T%",),
)
row = cursor.fetchone()
if row is None or row[0] is None:
return None
return {
"day": day,
"active_seconds": row[0] or 0,
"idle_seconds": row[1] or 0,
"powered_off_seconds": row[2] or 0,
"unknown_seconds": row[3] or 0,
"bytes_written_delta": row[4] or 0,
"bytes_read_delta": row[5] or 0,
"sample_count": row[6] or 0,
}
def _upsert_day_aggregate(conn: sqlite3.Connection, day_data: dict) -> bool:
"""Insert or update a day aggregate. Returns True if created."""
existing = conn.execute(
"SELECT id FROM day_aggregates WHERE day = ?",
(day_data["day"],),
).fetchone()
if existing is None:
# Calculate coverage
total_seconds = (day_data["active_seconds"] + day_data["idle_seconds"] +
day_data["powered_off_seconds"] + day_data["unknown_seconds"])
coverage = (day_data["active_seconds"] + day_data["idle_seconds"] +
day_data["powered_off_seconds"]) / total_seconds if total_seconds > 0 else 0.0
conn.execute(
"""INSERT INTO day_aggregates
(day, active_seconds, idle_seconds, powered_off_seconds, unknown_seconds,
bytes_written_delta, bytes_read_delta, sample_count, coverage)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(day_data["day"], day_data["active_seconds"], day_data["idle_seconds"],
day_data["powered_off_seconds"], day_data["unknown_seconds"],
day_data["bytes_written_delta"], day_data["bytes_read_delta"],
day_data["sample_count"], coverage),
)
return True
else:
# Update existing (but only if new data is more complete)
# This implements the "don't overwrite valid older history" rule
cursor = conn.execute(
"""SELECT bytes_written_delta, sample_count
FROM day_aggregates WHERE day = ?""",
(day_data["day"],),
)
existing_data = cursor.fetchone()
# Only update if new data has more samples or more bytes
if (day_data["sample_count"] > existing_data[1] or
day_data["bytes_written_delta"] > existing_data[0]):
total_seconds = (day_data["active_seconds"] + day_data["idle_seconds"] +
day_data["powered_off_seconds"] + day_data["unknown_seconds"])
coverage = (day_data["active_seconds"] + day_data["idle_seconds"] +
day_data["powered_off_seconds"]) / total_seconds if total_seconds > 0 else 0.0
conn.execute(
"""UPDATE day_aggregates SET
active_seconds = ?, idle_seconds = ?, powered_off_seconds = ?,
unknown_seconds = ?, bytes_written_delta = ?, bytes_read_delta = ?,
sample_count = ?, coverage = ?
WHERE day = ?""",
(day_data["active_seconds"], day_data["idle_seconds"],
day_data["powered_off_seconds"], day_data["unknown_seconds"],
day_data["bytes_written_delta"], day_data["bytes_read_delta"],
day_data["sample_count"], coverage, day_data["day"]),
)
return False # Updated, not created
else:
return False # No update needed
def repair_derivation(
conn: sqlite3.Connection,
clock=None,
) -> RepairResult:
"""Repair hour observations and day aggregates from surviving samples.
This is the main entry point for historical repair. It:
1. Finds sample pairs that need interval derivation
2. Derives hour observations from those intervals
3. Updates day aggregates from the hour observations
4. Preserves existing valid data
5. Is idempotent and interruption-safe
Args:
conn: Connection to the observation store
clock: Injected clock (for testing)
Returns:
RepairResult with operation details
"""
if clock is None:
clock = datetime.now(timezone.utc)
elif hasattr(clock, 'utcnow'):
clock = clock.utcnow()
# Check if repair is already in progress
if is_repair_in_progress(conn):
return RepairResult(
ok=False,
error="Repair already in progress",
)
# Mark repair as in progress
_set_repair_in_progress(conn, True)
result = RepairResult(ok=True)
try:
# Begin transaction
conn.execute("BEGIN IMMEDIATE")
# 1. Find and derive intervals from sample pairs
intervals = _get_unlinked_intervals(conn)
for prev, next_s in intervals:
try:
derived_hours = derive_hours_from_interval(conn, prev, next_s)
result.intervals_derived += 1
result.hours_created += len([h for h in derived_hours if h.get("attributed", True)])
except Exception as e:
logger.warning("Failed to derive interval %s -> %s: %s",
prev["ts"], next_s["ts"], e)
# Continue with other intervals (resilient)
# 2. Update day aggregates from hour observations
cursor = conn.execute(
"SELECT DISTINCT substr(hour, 1, 10) as day FROM hour_observations ORDER BY day"
)
days = [row[0] for row in cursor.fetchall()]
for day in days:
day_data = _derive_day_aggregate_from_hours(conn, day)
if day_data is not None:
created = _upsert_day_aggregate(conn, day_data)
if created:
result.days_created += 1
else:
result.days_updated += 1
# 3. Count boundary anchors retained
now = clock if isinstance(clock, datetime) else datetime.now(timezone.utc)
cursor = conn.execute("SELECT ts FROM samples ORDER BY ts")
anchor_count = 0
for row in cursor.fetchall():
if needs_boundary_anchor(conn, row[0], now):
anchor_count += 1
result.boundary_anchors_retained = anchor_count
# 4. Count preserved legacy summaries
# Legacy summaries are day aggregates without corresponding hour observations
cursor = conn.execute(
"""SELECT COUNT(*) FROM day_aggregates d
WHERE NOT EXISTS (
SELECT 1 FROM hour_observations h
WHERE h.hour LIKE d.day || 'T%'
)"""
)
result.legacy_summaries_preserved = cursor.fetchone()[0]
# Commit transaction
conn.commit()
# Update repair status
_update_repair_status(conn, result)
except Exception as e:
conn.rollback()
logger.error("Repair failed: %s", e)
return RepairResult(
ok=False,
error=str(e),
)
finally:
# Mark repair as complete
_set_repair_in_progress(conn, False)
return result
+8 -115
View File
@@ -331,8 +331,7 @@ def check_retired_flag(flag: str) -> Optional[str]:
def _format_projection(proj, freshness: str, service: Dict[str, Any],
drive_facts: List[str], config_error: Optional[str],
store_fault: Optional[str], newer_schema: Optional[str],
journal_hint: Optional[str],
sample_count: int = 0, day_count: int = 0) -> str:
journal_hint: Optional[str]) -> str:
"""Format the complete status output."""
lines = []
@@ -362,16 +361,6 @@ def _format_projection(proj, freshness: str, service: Dict[str, Any],
_append_service_facts(lines, service)
return "\n".join(lines)
# --- Single sample: awaiting another sample (issue #73 AC3) ---
# Only show awaiting state when there are no day aggregates (e.g., legacy import
# or hand-crafted stores can have 1 sample but sufficient day data for projection)
if sample_count <= 1 and day_count == 0:
lines.append("awaiting another sample")
lines.append("")
lines.append("Collecting usage data — the first projection requires at least two samples.")
_append_service_facts(lines, service)
return "\n".join(lines)
# --- Projection headline ---
headline = _format_headline(proj)
lines.append(headline)
@@ -379,30 +368,21 @@ def _format_projection(proj, freshness: str, service: Dict[str, Any],
# --- Confidence state + contributing facts (§6.7, §6.11) ---
state_label = proj.confidence_state.value
facts_list = list(proj.contributing_facts) if proj.contributing_facts else []
# Issue #77: Show qualifying day progress for honesty
if proj.qualifying_days_progress:
facts_list.insert(0, proj.qualifying_days_progress)
if facts_list:
facts_str = " · ".join(facts_list)
if proj.contributing_facts:
facts_str = " · ".join(proj.contributing_facts)
lines.append("%s evidence · %s" % (state_label, facts_str))
else:
lines.append("%s evidence" % state_label)
lines.append("")
# --- Scenario range (§6.5) with horizon reasons ---
if proj.scenario_range:
# --- Scenario range (§6.5) ---
if proj.scenario_range and proj.scenario_range.rates:
parts = []
for horizon in sorted(proj.scenario_range.rates.keys()):
rate_gb_day = proj.scenario_range.rates[horizon] * 86400 / 1e9
parts.append("%dd: %.2f GB/day" % (horizon, rate_gb_day))
for horizon, reason in sorted(proj.scenario_range.horizon_reasons.items()):
parts.append("%dd: %s" % (horizon, reason))
if parts:
lines.append("scenario range: %s" % " · ".join(parts))
lines.append("")
lines.append("scenario range: %s" % " · ".join(parts))
lines.append("")
# --- PU context line (§6.1) ---
lines.append(proj.pu_context_line)
@@ -618,17 +598,6 @@ def get_status(store_path: Optional[Path] = None, clock_now: Optional[datetime]
freshness = grade_freshness(newest_ts, clock_now)
# --- Sample count for single-sample state (issue #73 AC3) ---
sample_count = 0
day_count = 0
try:
cursor = conn.execute("SELECT COUNT(*) FROM samples")
sample_count = cursor.fetchone()[0]
cursor = conn.execute("SELECT COUNT(*) FROM day_aggregates")
day_count = cursor.fetchone()[0]
except sqlite3.Error:
pass
# Freshness age for the service fact
freshness_age_s = None
if newest_ts:
@@ -668,7 +637,7 @@ def get_status(store_path: Optional[Path] = None, clock_now: Optional[datetime]
# --- Compose output ---
result = _format_projection(
proj, freshness, service, drive_facts, config_error,
None, None, journal_hint, sample_count, day_count,
None, None, journal_hint,
)
conn.close()
@@ -687,79 +656,3 @@ def render_status(store_path: Optional[Path] = None, clock_now: Optional[datetim
parts.append("")
parts.append(format_disclosures())
return "\n\n".join(parts)
# ---------------------------------------------------------------------------
# Shared status composition integration (issue #78)
# ---------------------------------------------------------------------------
def get_status_composition(
store_path: Optional[Path] = None,
clock_now: Optional[datetime] = None,
query_services: bool = True,
collecting: bool = False,
reduced_motion: bool = False,
) -> 'StatusComposition':
"""Get the shared status composition consumed by both TUI and CLI.
This is the new entry point that centralizes the status lattice.
"""
# Lazy import to avoid circular dependency
from .status_composition import (
StatusComposition,
compose_status,
)
if clock_now is None:
clock_now = datetime.now(timezone.utc)
# --- Configuration (§8.3) ---
# Config errors are surfaced through the store fault mechanism
# --- Service state ---
service = {}
if query_services:
try:
service = query_service_state()
except Exception:
service = None
# --- Store open ---
store_fault = None
newer_schema = None
conn = None
if store_path is None:
store_path = Path("/var/lib/fenris/observations.db")
try:
conn = open_store_readonly(store_path)
except StoreFault as e:
store_fault = str(e)
except NewerSchema as e:
newer_schema = str(e)
# --- Use shared composition ---
if conn is not None:
try:
comp = compose_status(
conn, service, clock_now,
store_fault=store_fault,
newer_schema=newer_schema,
collecting=collecting,
reduced_motion=reduced_motion,
)
finally:
conn.close()
else:
# Store fault or newer schema - compose without store data
comp = compose_status(
None, service, clock_now,
store_fault=store_fault,
newer_schema=newer_schema,
collecting=collecting,
reduced_motion=reduced_motion,
)
return comp
-526
View File
@@ -1,526 +0,0 @@
"""Shared status composition for TUI and CLI (issue #78, TPH-2/3).
Centralizes the monitoring status lattice consumed by both surfaces.
States: Monitoring, Collecting, Paused, Waiting, Interrupted, Error, Stale, Unknown.
Precedence: Error > Interrupted > Paused > Stale > Waiting > Monitoring > Unknown.
Collecting overlays every base except store fault.
Freshness grading uses shared constants from status.py.
"""
import enum
import sqlite3
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional
# Status poll interval (AC78-6): lightweight 5s systemctl show poll
STATUS_POLL_INTERVAL_S = 5
from .status import (
FRESH_THRESHOLD_S,
STALENESS_THRESHOLD_S,
grade_freshness,
freshness_age_human,
is_deliberately_paused,
monitoring_continuity,
deliberate_pause_lines,
open_store_readonly,
StoreFault,
NewerSchema,
)
# ---------------------------------------------------------------------------
# Status state enum with glyph and label
# ---------------------------------------------------------------------------
class StatusState(enum.Enum):
"""The eight monitoring status states (TPH-2)."""
MONITORING = "monitoring"
COLLECTING = "collecting"
PAUSED = "paused"
WAITING = "waiting"
INTERRUPTED = "interrupted"
ERROR = "error"
STALE = "stale"
UNKNOWN = "unknown"
@property
def glyph(self) -> str:
_glyphs = {
"monitoring": "●",
"collecting": "◐",
"paused": "‖",
"waiting": "○",
"interrupted": "⊘",
"error": "✖",
"stale": "◌",
"unknown": "?",
}
return _glyphs[self.value]
@property
def label(self) -> str:
return self.value.capitalize()
# Precedence order: higher index = higher precedence
_PRECEDENCE = [
StatusState.UNKNOWN,
StatusState.MONITORING,
StatusState.WAITING,
StatusState.STALE,
StatusState.PAUSED,
StatusState.INTERRUPTED,
StatusState.ERROR,
]
_PRECEDENCE_RANK = {s: i for i, s in enumerate(_PRECEDENCE)}
# ---------------------------------------------------------------------------
# Status composition result
# ---------------------------------------------------------------------------
@dataclass
class StatusComposition:
"""The composed status result shared between TUI and CLI."""
state: StatusState
glyph: str
label: str
explanation: str
# Separate facts (never folded into the status word)
freshness: str = "unknown"
freshness_age_s: Optional[int] = None
last_collect_ok: Optional[bool] = None
last_collect_age_s: Optional[int] = None
last_collect_reason: Optional[str] = None
boot_enabled: Optional[bool] = None
timer_active: Optional[bool] = None
deliberately_paused: bool = False
pause_age_s: Optional[int] = None
external_stop_reason: Optional[str] = None
# Store fault / newer schema (suppress store-dependent views)
store_fault: Optional[str] = None
newer_schema: Optional[str] = None
# Collecting overlay
overlay_base: Optional[StatusState] = None
# Sample counts for waiting explanations
sample_count: int = 0
day_count: int = 0
# Whether the status dot should blink (only Monitoring)
should_blink: bool = False
# Continuity line
continuity: str = ""
# Paused lines (for TUI banner)
paused_lines: List[str] = field(default_factory=list)
# ---------------------------------------------------------------------------
# Status composition logic
# ---------------------------------------------------------------------------
def _determine_base_state(
freshness: str,
sample_count: int,
day_count: int,
boot_enabled: Optional[bool],
timer_active: Optional[bool],
last_collect_ok: Optional[bool],
deliberately_paused: bool,
external_stop_reason: Optional[str],
service_available: bool,
) -> StatusState:
"""Determine the base status state from facts.
Precedence: Error > Interrupted > Paused > Stale > Waiting > Monitoring > Unknown.
"""
# Unknown: service query failed and no store-derived fact places us higher
if not service_available:
return StatusState.UNKNOWN
# Error: last collect failed
if last_collect_ok is False:
return StatusState.ERROR
# Interrupted: external stop (not user_disabled)
if external_stop_reason is not None:
return StatusState.INTERRUPTED
# Paused: deliberate disable
if deliberately_paused:
return StatusState.PAUSED
# Stale: timer active, no failure, but data ≥ 48h old
if freshness == "stale" and timer_active and last_collect_ok is not False:
return StatusState.STALE
# Waiting: empty store, single sample, or fresh data but not yet enough evidence
if sample_count == 0:
return StatusState.WAITING
if sample_count <= 1 and day_count == 0:
return StatusState.WAITING
# Monitoring: everything is fine
return StatusState.MONITORING
def _determine_explanation(
state: StatusState,
freshness: str,
freshness_age_s: Optional[int],
last_collect_ok: Optional[bool],
last_collect_reason: Optional[str],
sample_count: int,
deliberately_paused: bool,
store_fault: Optional[str],
newer_schema: Optional[str],
) -> str:
"""Determine the explanation line for the status state."""
if store_fault:
return "observation store unreadable — see journal"
if newer_schema:
return "observation store written by a newer Fenris — upgrade Fenris"
if state == StatusState.ERROR:
parts = []
if last_collect_ok is False:
parts.append("last run failed")
if last_collect_reason:
parts.append("(%s)" % last_collect_reason)
if freshness_age_s is not None and freshness != "empty":
parts.append("· last good sample %s ago" % freshness_age_human(freshness_age_s))
return " ".join(parts) if parts else "last run failed"
if state == StatusState.INTERRUPTED:
return "collection stopped outside Fenris — monitoring period still open"
if state == StatusState.PAUSED:
return "monitoring paused — paused time excluded from your usage habit"
if state == StatusState.STALE:
if freshness_age_s is not None:
return "last sample %s ago" % freshness_age_human(freshness_age_s)
return "data is stale"
if state == StatusState.WAITING:
if sample_count == 0:
return "awaiting first sample"
if sample_count <= 1:
return "awaiting another sample"
return "waiting for data"
if state == StatusState.MONITORING:
if freshness_age_s is not None:
return "last sample %s ago" % freshness_age_human(freshness_age_s)
return "monitoring active"
if state == StatusState.UNKNOWN:
return "service state unavailable"
return ""
def _determine_collecting_overlay(
base_state: StatusState,
last_collect_ok: Optional[bool],
deliberately_paused: bool,
store_fault: Optional[str],
newer_schema: Optional[str],
) -> str:
"""Determine the explanation line when Collecting overlays a base state."""
if store_fault or newer_schema:
return "run in flight — store fault"
if base_state == StatusState.PAUSED:
return "run in flight — paused"
if base_state == StatusState.INTERRUPTED:
return "run in flight — interrupted"
if base_state == StatusState.ERROR:
return "run in flight — retry"
if base_state == StatusState.STALE:
return "run in flight — stale data"
if base_state == StatusState.WAITING:
return "run in flight"
return "run in flight"
def compose_status(
conn: sqlite3.Connection,
service: Optional[Dict[str, Any]],
clock_now: datetime,
store_fault: Optional[str] = None,
newer_schema: Optional[str] = None,
collecting: bool = False,
reduced_motion: bool = False,
) -> StatusComposition:
"""Compose the shared status from service state and store data.
This is the single entry point consumed by both TUI and CLI.
"""
service_available = service is not None and len(service) > 0
# --- Separate facts from service ---
boot_enabled = service.get("boot_enabled") if service_available else None
timer_active = service.get("timer_active") if service_available else None
last_collect_ok = service.get("last_collect_ok") if service_available else None
last_collect_age_s = service.get("last_collect_age_s") if service_available else None
last_collect_reason = service.get("last_collect_reason") if service_available else None
# --- Freshness from store ---
freshness = "unknown"
freshness_age_s = None
sample_count = 0
day_count = 0
deliberately_paused = False
if store_fault is None and newer_schema is None:
try:
cursor = conn.execute("SELECT ts FROM samples ORDER BY id DESC LIMIT 1")
row = cursor.fetchone()
newest_ts = row[0] if row else None
freshness = grade_freshness(newest_ts, clock_now)
if newest_ts:
try:
ts = datetime.fromisoformat(newest_ts)
if ts.tzinfo is None:
ts = ts.replace(tzinfo=timezone.utc)
else:
ts = ts.astimezone(timezone.utc)
freshness_age_s = int((clock_now - ts).total_seconds())
except (ValueError, TypeError):
pass
except sqlite3.Error:
freshness = "unknown"
try:
cursor = conn.execute("SELECT COUNT(*) FROM samples")
sample_count = cursor.fetchone()[0]
cursor = conn.execute("SELECT COUNT(*) FROM day_aggregates")
day_count = cursor.fetchone()[0]
except sqlite3.Error:
pass
try:
svc_for_pause = service if service_available else {}
deliberately_paused = is_deliberately_paused(conn, svc_for_pause)
except sqlite3.Error:
deliberately_paused = False
# --- External stop detection ---
# External stop = timer inactive + boot disabled + NOT deliberately paused
# + period still open (the timer was stopped but Fenris didn't close the period)
external_stop_reason = None
if not deliberately_paused and timer_active is False and boot_enabled is False:
# Check if there's an open monitoring period (external stop left it open)
try:
open_period = conn.execute(
"SELECT 1 FROM monitoring_periods WHERE ended_at IS NULL LIMIT 1"
).fetchone()
if open_period is not None:
external_stop_reason = "external_stop"
except sqlite3.Error:
pass
# --- Determine base state ---
# Store fault and newer schema always override to ERROR
if store_fault is not None or newer_schema is not None:
base_state = StatusState.ERROR
else:
base_state = _determine_base_state(
freshness=freshness,
sample_count=sample_count,
day_count=day_count,
boot_enabled=boot_enabled,
timer_active=timer_active,
last_collect_ok=last_collect_ok,
deliberately_paused=deliberately_paused,
external_stop_reason=external_stop_reason,
service_available=service_available,
)
# --- Apply Collecting overlay ---
state = base_state
overlay_base = None
explanation = ""
if collecting and store_fault is None and newer_schema is None:
# Collecting overlays every base except store fault
overlay_base = base_state
state = StatusState.COLLECTING
explanation = _determine_collecting_overlay(
base_state, last_collect_ok, deliberately_paused,
store_fault, newer_schema,
)
else:
explanation = _determine_explanation(
base_state, freshness, freshness_age_s,
last_collect_ok, last_collect_reason,
sample_count, deliberately_paused,
store_fault, newer_schema,
)
# --- Continuity line ---
continuity = ""
if service_available:
continuity = monitoring_continuity(service)
# --- Paused lines ---
paused_lines = []
if deliberately_paused:
paused_lines = deliberate_pause_lines()
# Determine blink flag: only Monitoring dot blinks, never text or other states
should_blink = (state == StatusState.MONITORING and not reduced_motion)
return StatusComposition(
state=state,
glyph=state.glyph,
label=state.label,
explanation=explanation,
should_blink=should_blink,
freshness=freshness,
freshness_age_s=freshness_age_s,
last_collect_ok=last_collect_ok,
last_collect_age_s=last_collect_age_s,
last_collect_reason=last_collect_reason,
boot_enabled=boot_enabled,
timer_active=timer_active,
deliberately_paused=deliberately_paused,
external_stop_reason=external_stop_reason,
store_fault=store_fault,
newer_schema=newer_schema,
overlay_base=overlay_base,
sample_count=sample_count,
day_count=day_count,
continuity=continuity,
paused_lines=paused_lines,
)
# ---------------------------------------------------------------------------
# CLI rendering (static, no styling)
# ---------------------------------------------------------------------------
def render_status_cli(comp: StatusComposition) -> str:
"""Render the status composition as static CLI text."""
lines = []
# Status line
lines.append("%s %s" % (comp.glyph, comp.label))
# Explanation
if comp.explanation:
lines.append(comp.explanation)
lines.append("")
# Separate facts
facts = []
if comp.freshness != "unknown":
facts.append("freshness: %s" % comp.freshness)
if comp.freshness_age_s is not None:
facts[-1] += " (%s)" % freshness_age_human(comp.freshness_age_s) if facts else "freshness: %s" % freshness_age_human(comp.freshness_age_s)
if comp.last_collect_ok is True:
facts.append("last collect: ok")
elif comp.last_collect_ok is False:
collect_str = "last collect: FAILED"
if comp.last_collect_reason:
collect_str += " (%s)" % comp.last_collect_reason
facts.append(collect_str)
else:
facts.append("last collect: unknown")
if comp.boot_enabled is not None:
facts.append("boot: %s" % ("enabled" if comp.boot_enabled else "disabled"))
if comp.timer_active is not None:
facts.append("timer: %s" % ("active" if comp.timer_active else "inactive"))
if facts:
lines.append(" · ".join(facts))
# Continuity
if comp.continuity:
lines.append("")
lines.append("CONTINUITY: %s" % comp.continuity)
# Deliberate pause
if comp.paused_lines:
for pl in comp.paused_lines:
lines.append(pl)
return "\n".join(lines)
# ---------------------------------------------------------------------------
# TUI rendering (with styling tokens)
# ---------------------------------------------------------------------------
def render_status_tui(comp: StatusComposition) -> str:
"""Render the status composition as TUI text with Textual markup."""
lines = []
# Status line with color
color = {
StatusState.MONITORING: "green",
StatusState.COLLECTING: "green",
StatusState.PAUSED: "yellow",
StatusState.WAITING: "yellow",
StatusState.INTERRUPTED: "red",
StatusState.ERROR: "red",
StatusState.STALE: "red",
StatusState.UNKNOWN: "dim",
}[comp.state]
lines.append("[%s]%s %s[/%s]" % (color, comp.glyph, comp.label, color))
# Explanation
if comp.explanation:
lines.append(comp.explanation)
lines.append("")
# Separate facts
facts = []
if comp.freshness != "unknown":
facts.append("freshness: %s" % comp.freshness)
if comp.freshness_age_s is not None and facts:
facts[-1] += " (%s)" % freshness_age_human(comp.freshness_age_s)
if comp.last_collect_ok is True:
facts.append("last collect: ok")
elif comp.last_collect_ok is False:
collect_str = "last collect: FAILED"
if comp.last_collect_reason:
collect_str += " (%s)" % comp.last_collect_reason
facts.append(collect_str)
else:
facts.append("last collect: unknown")
if comp.boot_enabled is not None:
facts.append("boot: %s" % ("enabled" if comp.boot_enabled else "disabled"))
if comp.timer_active is not None:
facts.append("timer: %s" % ("active" if comp.timer_active else "inactive"))
if facts:
lines.append(" · ".join(facts))
# Continuity
if comp.continuity:
lines.append("")
lines.append("[bold]CONTINUITY[/bold] %s" % comp.continuity)
# Deliberate pause
if comp.paused_lines:
for pl in comp.paused_lines:
lines.append(pl)
return "\n".join(lines)
+8 -33
View File
@@ -12,7 +12,7 @@ from typing import Optional
# Schema version - increment on each migration
SCHEMA_VERSION = 2
SCHEMA_VERSION = 1
# Packaged default placement (spec §8.3). The config may override it, but a
@@ -106,8 +106,7 @@ def _create_schema(conn: sqlite3.Connection):
data_units_read INTEGER,
bytes_written INTEGER,
bytes_read INTEGER,
critical_warning INTEGER,
segment_id INTEGER
critical_warning INTEGER
)
""")
@@ -142,9 +141,7 @@ def _create_schema(conn: sqlite3.Connection):
bytes_written_delta INTEGER DEFAULT 0,
bytes_read_delta INTEGER DEFAULT 0,
sample_count INTEGER DEFAULT 0,
coverage REAL DEFAULT 0.0,
unattributed_bytes_written INTEGER DEFAULT 0,
unattributed_bytes_read INTEGER DEFAULT 0
coverage REAL DEFAULT 0.0
)
""")
@@ -210,25 +207,11 @@ def _apply_migrations(conn: sqlite3.Connection, current_version: int):
Spec: §3.6, §10.2
"""
# Migration 1→2: add segment_id provenance to samples,
# unattributed byte tracking to day_aggregates (issue #73)
if current_version < 2:
# Defensive: only ALTER if table exists (handles minimal v1 stores)
tables = {row[0] for row in conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
).fetchall()}
if "samples" in tables:
# Check if column already exists (idempotent)
cols = {row[1] for row in conn.execute("PRAGMA table_info(samples)").fetchall()}
if "segment_id" not in cols:
conn.execute("ALTER TABLE samples ADD COLUMN segment_id INTEGER")
if "day_aggregates" in tables:
cols = {row[1] for row in conn.execute("PRAGMA table_info(day_aggregates)").fetchall()}
if "unattributed_bytes_written" not in cols:
conn.execute("ALTER TABLE day_aggregates ADD COLUMN unattributed_bytes_written INTEGER DEFAULT 0")
if "unattributed_bytes_read" not in cols:
conn.execute("ALTER TABLE day_aggregates ADD COLUMN unattributed_bytes_read INTEGER DEFAULT 0")
current_version = 2
# Migration 1→2: example placeholder
# if current_version < 2:
# conn.execute("ALTER TABLE ...")
# current_version = 2
pass
def migrate_to_latest(store_path: Path) -> int:
@@ -256,14 +239,6 @@ def migrate_to_latest(store_path: Path) -> int:
conn.close()
return 0 # Already up to date
# Version 0 means no schema — create fresh (issue #73)
if current_version == 0:
_create_schema(conn)
conn.execute(f"PRAGMA user_version={SCHEMA_VERSION}")
conn.commit()
conn.close()
return SCHEMA_VERSION
steps = SCHEMA_VERSION - current_version
_apply_migrations(conn, current_version)
conn.execute(f"PRAGMA user_version={SCHEMA_VERSION}")
-161
View File
@@ -1,161 +0,0 @@
"""Fenris theme presets (issue #80).
Three accessible colour presets: Amber (default), Nord, and High Contrast.
Themes style chrome, borders, accents, muted text, and graph roles;
status semantic colours/glyphs/text always win.
Theme roles for graph rendering expose distinct colours per preset so
the bar graph can reflect the user's visual preference without depending
on graph-ticket completion.
Criteria: TPH-10, AC80-1, AC80-5.
"""
from typing import Dict
from textual.theme import Theme
# ---------------------------------------------------------------------------
# Status semantic colours — always win, never themed (AC80-1)
# ---------------------------------------------------------------------------
STATUS_COLORS = {
"monitoring": "green",
"collecting": "green",
"paused": "yellow",
"waiting": "yellow",
"interrupted": "red",
"error": "red",
"stale": "red",
"unknown": "dim",
}
# ---------------------------------------------------------------------------
# Amber theme — warm golden tones (default, amber graph role)
# ---------------------------------------------------------------------------
_AMBER = Theme(
name="fenris-amber",
primary="#d4a017", # warm amber
secondary="#c49b0a", # darker amber
accent="#ffd54f", # light amber highlight
warning="#e6a817", # amber warning
error="#e74c3c", # red error
success="#27ae60", # green success
foreground="#e8e0d0", # warm light
background="#1a1510", # warm dark
surface="#241f16", # warm surface
panel="#2a2318", # warm panel
boost="#332a1c", # warm boost
dark=True,
variables={
"graph-allocated": "#d4a017",
"graph-unallocated": "#8b6914",
"graph-gap": "#554422",
"graph-zero": "#665533",
"graph-partial": "#aa8822",
"graph-selection": "#ffd54f",
"border-default": "#554422",
"muted-text": "#887755",
},
)
# ---------------------------------------------------------------------------
# Nord theme — cool blue-gray polar night palette
# ---------------------------------------------------------------------------
_NORD = Theme(
name="fenris-nord",
primary="#88c0d0", # nord8 frost
secondary="#81a1c1", # nord9
accent="#8fbcbb", # nord7
warning="#ebcb8b", # nord13
error="#bf616a", # nord11
success="#a3be8c", # nord14
foreground="#eceff4", # nord6
background="#2e3440", # nord0
surface="#3b4252", # nord1
panel="#434c5e", # nord2
boost="#4c566a", # nord3
dark=True,
variables={
"graph-allocated": "#88c0d0",
"graph-unallocated": "#5e81ac",
"graph-gap": "#4c566a",
"graph-zero": "#616e88",
"graph-partial": "#81a1c1",
"graph-selection": "#8fbcbb",
"border-default": "#4c566a",
"muted-text": "#7b88a1",
},
)
# ---------------------------------------------------------------------------
# High Contrast — maximum readability, pure black and white
# ---------------------------------------------------------------------------
_HIGH_CONTRAST = Theme(
name="fenris-high-contrast",
primary="#ffffff", # pure white
secondary="#dddddd", # light gray
accent="#ffff00", # bright yellow
warning="#ff8800", # bright orange
error="#ff0000", # pure red
success="#00ff00", # pure green
foreground="#ffffff", # pure white
background="#000000", # pure black
surface="#111111", # near-black surface
panel="#1a1a1a", # near-black panel
boost="#222222", # near-black boost
dark=True,
variables={
"graph-allocated": "#ffffff",
"graph-unallocated": "#aaaaaa",
"graph-gap": "#555555",
"graph-zero": "#666666",
"graph-partial": "#cccccc",
"graph-selection": "#ffff00",
"border-default": "#ffffff",
"muted-text": "#aaaaaa",
},
)
# ---------------------------------------------------------------------------
# Theme registry
# ---------------------------------------------------------------------------
THEMES = {
"amber": _AMBER,
"nord": _NORD,
"high_contrast": _HIGH_CONTRAST,
}
THEME_NAMES = set(THEMES.keys())
def get_theme(name: str) -> Theme:
"""Return a registered theme by preset name.
Unknown names fall back to Amber.
"""
return THEMES.get(name, _AMBER)
def get_graph_colors(theme_name: str) -> Dict[str, str]:
"""Return the graph colour roles for a theme preset.
Returns a dict with keys: allocated, unallocated, gap, zero, partial,
selection. Falls back to Amber for unknown names.
"""
theme = get_theme(theme_name)
variables = theme.variables or {}
return {
"allocated": variables.get("graph-allocated", "#d4a017"),
"unallocated": variables.get("graph-unallocated", "#8b6914"),
"gap": variables.get("graph-gap", "#554422"),
"zero": variables.get("graph-zero", "#665533"),
"partial": variables.get("graph-partial", "#aa8822"),
"selection": variables.get("graph-selection", "#ffd54f"),
}
+55 -886
View File
File diff suppressed because it is too large Load Diff
-712
View File
@@ -1,712 +0,0 @@
"""Collector history tracer tests (issue #73).
Tests the end-to-end history pipeline: sample acquisition → interval
derivation → hour observation → day aggregate, with concurrent-read
safety, cross-hour handling, and display states.
Seams:
- write side: run_collection() → observation store
- read side: get_status(), compute_projection() → observation store
"""
import os
import sqlite3
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Dict
import pytest
import sys
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from fenris.collector import run_collection, normalize_identity
from fenris.store import init_store, get_store_path, SCHEMA_VERSION
from fenris.monitoring_periods import ensure_period_open, close_period, get_open_period
from fenris.day_aggregate import derive_day, derive_all_days
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def smartctl_fixture() -> Dict[str, Any]:
"""Minimal smartctl -a -j output with required fields."""
return {
"json_format_version": [1, 0],
"smartctl": {"version": [7, 3], "svn_revision": "5155", "build_info": "(local build)"},
"nvme_smart_health_information_log": {
"critical_warning": 0,
"temperature": 35,
"available_spare": 100,
"available_spare_threshold": 10,
"percentage_used": 5,
"data_units_written": 12345678,
"data_units_read": 9876543,
"power_on_hours": 8765,
"power_cycles": 1234,
"unsafe_shutdowns": 5,
"media_errors": 0,
"num_err_log_entries": 0,
},
"user_capacity": {"bytes": 1024000000000, "units": "bytes"},
"model_name": "Samsung SSD 970 EVO Plus 1TB",
"serial_number": "S4EWNX0N123456",
"firmware_version": "2B2QEXM7",
}
@pytest.fixture
def sysfs_fixture_tree(tmp_path: Path) -> Path:
"""Create a minimal sysfs fixture tree with controller identity."""
ctrl_dir = tmp_path / "sys" / "class" / "nvme" / "nvme0"
ctrl_dir.mkdir(parents=True)
(ctrl_dir / "subsysnqn").write_text("nqn.2014-08.org.nvmexpress:uuid:12345678-1234-1234-1234-123456789abc\n")
(ctrl_dir / "model").write_text("Samsung SSD 970 EVO Plus 1TB\n")
(ctrl_dir / "serial").write_text("S4EWNX0N123456\n")
(ctrl_dir / "firmware_rev").write_text("2B2QEXM7\n")
transport_dir = ctrl_dir / "transport"
transport_dir.mkdir()
(transport_dir / "address").write_text("0000:03:00.0")
(transport_dir / "trstring").write_text("pcie")
return tmp_path
@pytest.fixture
def config_fixture(tmp_path: Path) -> Dict[str, Any]:
"""Configuration fixture naming the device."""
return {
"device": "/dev/nvme0",
"store_path": str(tmp_path / "observations.db"),
}
class FakeClock:
"""Injected clock returning controlled time."""
def __init__(self, initial: datetime):
self.now = initial
def utcnow(self):
return self.now
def advance(self, **kwargs):
self.now = self.now + timedelta(**kwargs)
# ---------------------------------------------------------------------------
# Schema migration: existing data readable at real precision
# ---------------------------------------------------------------------------
class TestSchemaMigration:
"""Schema migration 1→2 preserves existing data (issue #73 AC1)."""
def test_migration_bumps_version(self, tmp_path):
"""Migration from v1 to v2 succeeds."""
from fenris.store import migrate_to_latest, SCHEMA_VERSION
# Create a v1 store directly (simulating pre-migration state)
db = tmp_path / "test.db"
conn = sqlite3.connect(str(db))
conn.execute("PRAGMA journal_mode=WAL")
# Create v1 schema manually
conn.execute("""
CREATE TABLE samples (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts TEXT NOT NULL,
device TEXT NOT NULL,
subnqn TEXT, sn TEXT, mn TEXT, fr TEXT,
capacity_bytes INTEGER, percentage_used INTEGER,
available_spare INTEGER, media_errors INTEGER,
power_on_hours INTEGER, power_cycles INTEGER,
unsafe_shutdowns INTEGER, temperature_c INTEGER,
data_units_written INTEGER, data_units_read INTEGER,
bytes_written INTEGER, bytes_read INTEGER,
critical_warning INTEGER
)
""")
conn.execute("""
CREATE TABLE hour_observations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
hour TEXT NOT NULL UNIQUE,
active_seconds INTEGER DEFAULT 0, idle_seconds INTEGER DEFAULT 0,
powered_off_seconds INTEGER DEFAULT 0, unknown_seconds INTEGER DEFAULT 0,
bytes_written_delta INTEGER DEFAULT 0, bytes_read_delta INTEGER DEFAULT 0,
temperature_min INTEGER, temperature_avg REAL, temperature_max INTEGER,
sample_count INTEGER DEFAULT 0, coverage REAL DEFAULT 0.0
)
""")
conn.execute("""
CREATE TABLE day_aggregates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
day TEXT NOT NULL UNIQUE,
active_seconds INTEGER DEFAULT 0, idle_seconds INTEGER DEFAULT 0,
powered_off_seconds INTEGER DEFAULT 0, unknown_seconds INTEGER DEFAULT 0,
bytes_written_delta INTEGER DEFAULT 0, bytes_read_delta INTEGER DEFAULT 0,
sample_count INTEGER DEFAULT 0, coverage REAL DEFAULT 0.0
)
""")
conn.execute("CREATE TABLE monitoring_periods (id INTEGER PRIMARY KEY AUTOINCREMENT, started_at TEXT NOT NULL, ended_at TEXT, end_cause TEXT)")
conn.execute("CREATE TABLE controller_segments (id INTEGER PRIMARY KEY AUTOINCREMENT, opened_at TEXT NOT NULL, identity_key TEXT, identity_degraded BOOLEAN DEFAULT 0, subnqn TEXT, sn TEXT, mn TEXT, fr TEXT, vid TEXT, ssvid TEXT, transport TEXT)")
conn.execute("CREATE TABLE endurance_baseline (id INTEGER PRIMARY KEY AUTOINCREMENT, tbw_terabytes REAL NOT NULL, source_url TEXT, document_revision TEXT, entry_date TEXT, model_string TEXT, nominal_capacity_bytes INTEGER, validated_by TEXT, verified BOOLEAN DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)")
conn.execute("CREATE TABLE store_metadata (key TEXT PRIMARY KEY, value TEXT NOT NULL)")
conn.execute("PRAGMA user_version=1")
conn.execute("INSERT INTO samples (ts, device, mn, sn, fr, capacity_bytes, percentage_used, available_spare, media_errors, power_on_hours, power_cycles, unsafe_shutdowns, temperature_c, data_units_written, data_units_read, bytes_written, bytes_read, critical_warning) VALUES ('2026-09-01T12:00:00+00:00', '/dev/nvme0', 'Test', 'SN', 'FR', 1000000000000, 5, 100, 0, 1000, 100, 0, 35, 1000000, 500000, 512000000000, 256000000000, 0)")
conn.commit()
conn.close()
# Migrate
steps = migrate_to_latest(db)
assert steps == 1
# Verify data preserved
conn = sqlite3.connect(str(db))
row = conn.execute("SELECT ts, mn FROM samples").fetchone()
version = conn.execute("PRAGMA user_version").fetchone()[0]
conn.close()
assert row[0] == "2026-09-01T12:00:00+00:00"
assert row[1] == "Test"
assert version == SCHEMA_VERSION
def test_newer_schema_refused(self, tmp_path):
"""Store with user_version > SCHEMA_VERSION is refused."""
db = tmp_path / "test.db"
conn = sqlite3.connect(str(db))
conn.execute("PRAGMA user_version=%d" % (SCHEMA_VERSION + 1))
conn.commit()
conn.close()
with pytest.raises(ValueError, match="newer Fenris"):
init_store(db)
def test_existing_data_preserved_after_migration(self, config_fixture,
smartctl_fixture,
sysfs_fixture_tree):
"""Existing sample data is not lost or modified by migration."""
clock = FakeClock(datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc))
# Write first sample
run_collection(smartctl_fixture, sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
config_fixture, clock)
conn = sqlite3.connect(config_fixture["store_path"])
row = conn.execute("SELECT ts, device, bytes_written FROM samples").fetchone()
conn.close()
assert row[0] == "2026-09-01T12:00:00+00:00"
assert row[1] == "/dev/nvme0"
assert row[2] == 12345678 * 512000
# ---------------------------------------------------------------------------
# Collector publishes samples, intervals, hour observations, day aggregates
# ---------------------------------------------------------------------------
class TestCollectorDerivation:
"""Collector derives hour observations and day aggregates (issue #73 AC2)."""
def _make_sample(self, duw_units: int, ts: str) -> Dict[str, Any]:
"""Build a smartctl fixture with specific DUW."""
return {
"json_format_version": [1, 0],
"smartctl": {"version": [7, 3], "svn_revision": "5155", "build_info": "(local build)"},
"nvme_smart_health_information_log": {
"critical_warning": 0,
"temperature": 35,
"available_spare": 100,
"available_spare_threshold": 10,
"percentage_used": 5,
"data_units_written": duw_units,
"data_units_read": 9876543,
"power_on_hours": 8765,
"power_cycles": 1234,
"unsafe_shutdowns": 5,
"media_errors": 0,
"num_err_log_entries": 0,
},
"user_capacity": {"bytes": 1024000000000, "units": "bytes"},
"model_name": "Samsung SSD 970 EVO Plus 1TB",
"serial_number": "S4EWNX0N123456",
"firmware_version": "2B2QEXM7",
}
def test_same_hour_20mb_derives_hour_obs(self, config_fixture, sysfs_fixture_tree):
"""Two samples in same hour with 20 MB delta → hour_obs gets 20 MB."""
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
# DUW units: 12345678 * 512000 = ~6.3 TB; 20 MB = 20*1024*1024 / 512000 ≈ 40 units
duw1 = 12345678
duw2 = duw1 + 40 # ~20 MB more
clock1 = FakeClock(t1)
s1 = self._make_sample(duw1, t1.isoformat())
r1 = run_collection(s1, sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
config_fixture, clock1)
assert r1["ok"]
clock2 = FakeClock(t2)
s2 = self._make_sample(duw2, t2.isoformat())
r2 = run_collection(s2, sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
config_fixture, clock2)
assert r2["ok"]
# Check hour_observation was derived
conn = sqlite3.connect(config_fixture["store_path"])
hour = conn.execute(
"SELECT bytes_written_delta, sample_count FROM hour_observations WHERE hour LIKE '2026-09-01T12%'"
).fetchone()
conn.close()
assert hour is not None, "Hour observation should exist for 12:00"
assert hour[1] >= 2 # at least 2 samples contributed
# bytes_written_delta should be the 20 MB delta (40 * 512000 = 20480000)
assert hour[0] == 40 * 512000
def test_zero_delta_derives_hour_obs(self, config_fixture, sysfs_fixture_tree):
"""Two samples in same hour with no DUW change → 0 B written."""
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
duw = 12345678 # same for both
clock1 = FakeClock(t1)
s1 = self._make_sample(duw, t1.isoformat())
run_collection(s1, sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
config_fixture, clock1)
clock2 = FakeClock(t2)
s2 = self._make_sample(duw, t2.isoformat())
run_collection(s2, sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
config_fixture, clock2)
conn = sqlite3.connect(config_fixture["store_path"])
hour = conn.execute(
"SELECT bytes_written_delta FROM hour_observations WHERE hour LIKE '2026-09-01T12%'"
).fetchone()
conn.close()
assert hour is not None
assert hour[0] == 0
def test_cross_hour_100mb_unattributed(self, config_fixture, sysfs_fixture_tree):
"""Samples in different hours → delta is unattributed to any hour."""
t1 = datetime(2026, 9, 1, 11, 55, 0, tzinfo=timezone.utc)
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
duw1 = 12345678
duw2 = duw1 + 200 # ~100 MB
clock1 = FakeClock(t1)
s1 = self._make_sample(duw1, t1.isoformat())
run_collection(s1, sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
config_fixture, clock1)
clock2 = FakeClock(t2)
s2 = self._make_sample(duw2, t2.isoformat())
run_collection(s2, sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
config_fixture, clock2)
conn = sqlite3.connect(config_fixture["store_path"])
# Hour observations should NOT contain the cross-hour delta
hour11 = conn.execute(
"SELECT bytes_written_delta FROM hour_observations WHERE hour LIKE '2026-09-01T11%'"
).fetchone()
hour12 = conn.execute(
"SELECT bytes_written_delta FROM hour_observations WHERE hour LIKE '2026-09-01T12%'"
).fetchone()
conn.close()
# Neither hour should have the full 100 MB delta attributed
# (they may have 0 or partial, but not 200*512000)
full_delta = 200 * 512000
if hour11 is not None:
assert hour11[0] != full_delta, "Hour 11 should not have full cross-hour delta"
if hour12 is not None:
assert hour12[0] != full_delta, "Hour 12 should not have full cross-hour delta"
def test_readonly_sees_consistent_snapshot(self, config_fixture, sysfs_fixture_tree):
"""A read-only reader sees valid pre- or post-publication snapshot."""
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
clock1 = FakeClock(t1)
run_collection(self._make_sample(12345678, t1.isoformat()),
sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
config_fixture, clock1)
# Open read-only
ro_conn = sqlite3.connect(
"file:%s?mode=ro" % config_fixture["store_path"], uri=True
)
count_before = ro_conn.execute("SELECT COUNT(*) FROM samples").fetchone()[0]
ro_conn.close()
assert count_before == 1
# Write second sample
clock2 = FakeClock(t2)
run_collection(self._make_sample(12345718, t2.isoformat()),
sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
config_fixture, clock2)
# Read-only reader sees 2 samples now
ro_conn2 = sqlite3.connect(
"file:%s?mode=ro" % config_fixture["store_path"], uri=True
)
count_after = ro_conn2.execute("SELECT COUNT(*) FROM samples").fetchone()[0]
ro_conn2.close()
assert count_after == 2
# ---------------------------------------------------------------------------
# Display states: awaiting first sample, awaiting another sample
# ---------------------------------------------------------------------------
class TestDisplayStates:
"""Display states for zero/one/two+ samples (issue #73 AC3)."""
def test_zero_samples_awaiting_first(self, tmp_path):
"""Zero samples → 'awaiting first sample' state."""
from fenris.status import get_status
db = tmp_path / "test.db"
init_store(db)
now = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
from unittest.mock import patch
with patch("fenris.status.query_service_state", return_value={
"boot_enabled": False, "timer_active": False,
"last_collect_ok": None, "last_collect_age_s": None,
"last_collect_reason": None,
}):
result = get_status(store_path=db, clock_now=now,
query_services=False, query_journal=False)
assert "no observations yet" in result.lower() or "awaiting" in result.lower()
def test_one_sample_awaiting_another(self, tmp_path):
"""One sample → 'awaiting another sample' state."""
from fenris.status import get_status
db = tmp_path / "test.db"
conn = init_store(db)
conn.execute(
"INSERT INTO samples (ts, device, mn, sn, fr, capacity_bytes, "
"percentage_used, available_spare, media_errors, power_on_hours, "
"power_cycles, unsafe_shutdowns, temperature_c, "
"data_units_written, data_units_read, bytes_written, bytes_read, "
"critical_warning) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
("2026-09-01T12:00:00+00:00", "/dev/nvme0", "Test", "SN", "FR",
1000000000000, 5, 100, 0, 1000, 100, 0, 35,
1000000, 500000, 512000000000, 256000000000, 0),
)
conn.commit()
conn.close()
now = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
from unittest.mock import patch
with patch("fenris.status.query_service_state", return_value={
"boot_enabled": False, "timer_active": False,
"last_collect_ok": None, "last_collect_age_s": None,
"last_collect_reason": None,
}):
result = get_status(store_path=db, clock_now=now,
query_services=False, query_journal=False)
# Should mention awaiting or insufficient data
lower = result.lower()
assert "awaiting" in lower or "another sample" in lower or "no projection" in lower
def test_one_sample_awaiting_another_in_tui(self, tmp_path):
"""One sample → TUI shows awaiting state."""
from fenris.tui import FenrisTuiApp, _query_service_facts
db = tmp_path / "test.db"
conn = init_store(db)
conn.execute(
"INSERT INTO samples (ts, device, mn, sn, fr, capacity_bytes, "
"percentage_used, available_spare, media_errors, power_on_hours, "
"power_cycles, unsafe_shutdowns, temperature_c, "
"data_units_written, data_units_read, bytes_written, bytes_read, "
"critical_warning) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
("2026-09-01T12:00:00+00:00", "/dev/nvme0", "Test", "SN", "FR",
1000000000000, 5, 100, 0, 1000, 100, 0, 35,
1000000, 500000, 512000000000, 256000000000, 0),
)
conn.commit()
conn.close()
now = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
# Test the projection handles single sample
from fenris.projection import compute_projection, ConfidenceState
conn = sqlite3.connect(db)
proj = compute_projection(conn, now)
conn.close()
# With only one sample, projection should be unavailable
assert proj.confidence_state == ConfidenceState.UNSUPPORTED
def test_two_same_hour_samples_show_measured_usage(self, config_fixture, sysfs_fixture_tree):
"""Two compatible same-hour samples show measured usage."""
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
clock1 = FakeClock(t1)
run_collection(self._make_sample_helper(12345678), sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
config_fixture, clock1)
clock2 = FakeClock(t2)
run_collection(self._make_sample_helper(12345718), sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
config_fixture, clock2)
from fenris.status import get_status
from unittest.mock import patch
now = datetime(2026, 9, 1, 12, 10, 0, tzinfo=timezone.utc)
with patch("fenris.status.query_service_state", return_value={
"boot_enabled": False, "timer_active": False,
"last_collect_ok": None, "last_collect_age_s": None,
"last_collect_reason": None,
}):
result = get_status(store_path=Path(config_fixture["store_path"]),
clock_now=now, query_services=False, query_journal=False)
# Should not say "no observations" or "awaiting"
lower = result.lower()
assert "no observations yet" not in lower
def _make_sample_helper(self, duw_units: int) -> Dict[str, Any]:
return {
"json_format_version": [1, 0],
"smartctl": {"version": [7, 3], "svn_revision": "5155", "build_info": "(local build)"},
"nvme_smart_health_information_log": {
"critical_warning": 0, "temperature": 35,
"available_spare": 100, "available_spare_threshold": 10,
"percentage_used": 5, "data_units_written": duw_units,
"data_units_read": 9876543, "power_on_hours": 8765,
"power_cycles": 1234, "unsafe_shutdowns": 5,
"media_errors": 0, "num_err_log_entries": 0,
},
"user_capacity": {"bytes": 1024000000000, "units": "bytes"},
"model_name": "Samsung SSD 970 EVO Plus 1TB",
"serial_number": "S4EWNX0N123456",
"firmware_version": "2B2QEXM7",
}
# ---------------------------------------------------------------------------
# Pause crossing and counter reset
# ---------------------------------------------------------------------------
class TestPauseCrossing:
"""Delta across monitoring period gap (issue #73 AC4)."""
def test_pause_crossing_preserves_prior_history(self, config_fixture, sysfs_fixture_tree):
"""Delta across a paused period preserves prior hour observations."""
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
t_pause = datetime(2026, 9, 1, 13, 0, 0, tzinfo=timezone.utc)
t_resume = datetime(2026, 9, 1, 14, 0, 0, tzinfo=timezone.utc)
t2 = datetime(2026, 9, 1, 14, 5, 0, tzinfo=timezone.utc)
duw1 = 12345678
duw2 = duw1 + 100
# First sample (opens period)
clock1 = FakeClock(t1)
run_collection(self._make_sample_for_pause(duw1), sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
config_fixture, clock1)
# Pause
conn = sqlite3.connect(config_fixture["store_path"])
close_period(conn, t_pause, "user_disabled")
conn.close()
# Resume with new sample
clock_resume = FakeClock(t_resume)
run_collection(self._make_sample_for_pause(duw1), sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
config_fixture, clock_resume)
# Second sample after resume
clock2 = FakeClock(t2)
run_collection(self._make_sample_for_pause(duw2), sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
config_fixture, clock2)
# Verify prior hour observations are intact
conn = sqlite3.connect(config_fixture["store_path"])
hour_12 = conn.execute(
"SELECT bytes_written_delta FROM hour_observations WHERE hour LIKE '2026-09-01T12%'"
).fetchone()
conn.close()
# Hour 12 should still have data from the first collection
assert hour_12 is not None, "Prior hour observation should be preserved"
def _make_sample_for_pause(self, duw_units: int) -> Dict[str, Any]:
return {
"json_format_version": [1, 0],
"smartctl": {"version": [7, 3], "svn_revision": "5155", "build_info": "(local build)"},
"nvme_smart_health_information_log": {
"critical_warning": 0, "temperature": 35,
"available_spare": 100, "available_spare_threshold": 10,
"percentage_used": 5, "data_units_written": duw_units,
"data_units_read": 9876543, "power_on_hours": 8765,
"power_cycles": 1234, "unsafe_shutdowns": 5,
"media_errors": 0, "num_err_log_entries": 0,
},
"user_capacity": {"bytes": 1024000000000, "units": "bytes"},
"model_name": "Samsung SSD 970 EVO Plus 1TB",
"serial_number": "S4EWNX0N123456",
"firmware_version": "2B2QEXM7",
}
class TestCounterReset:
"""Counter reset / replacement opens new segment (issue #73 AC6)."""
def test_duw_decrease_opens_new_segment(self, config_fixture, sysfs_fixture_tree):
"""DUW decrease triggers new segment."""
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
duw1 = 12345678
duw2 = duw1 - 100 # decrease = reset
clock1 = FakeClock(t1)
run_collection(self._make_sample_for_reset(duw1), sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
config_fixture, clock1)
clock2 = FakeClock(t2)
run_collection(self._make_sample_for_reset(duw2), sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
config_fixture, clock2)
conn = sqlite3.connect(config_fixture["store_path"])
segments = conn.execute("SELECT COUNT(*) FROM controller_segments").fetchone()[0]
samples = conn.execute("SELECT COUNT(*) FROM samples").fetchone()[0]
conn.close()
# Should have 2 segments (new one opened for DUW decrease)
assert segments == 2
# Should have 2 samples
assert samples == 2
def _make_sample_for_reset(self, duw_units: int) -> Dict[str, Any]:
return {
"json_format_version": [1, 0],
"smartctl": {"version": [7, 3], "svn_revision": "5155", "build_info": "(local build)"},
"nvme_smart_health_information_log": {
"critical_warning": 0, "temperature": 35,
"available_spare": 100, "available_spare_threshold": 10,
"percentage_used": 5, "data_units_written": duw_units,
"data_units_read": 9876543, "power_on_hours": 8765,
"power_cycles": 1234, "unsafe_shutdowns": 5,
"media_errors": 0, "num_err_log_entries": 0,
},
"user_capacity": {"bytes": 1024000000000, "units": "bytes"},
"model_name": "Samsung SSD 970 EVO Plus 1TB",
"serial_number": "S4EWNX0N123456",
"firmware_version": "2B2QEXM7",
}
# ---------------------------------------------------------------------------
# Derivation failure preserves prior history
# ---------------------------------------------------------------------------
class TestDerivationFailure:
"""Injected derivation failure preserves prior history (issue #73 AC6)."""
def test_failed_derivation_preserves_samples(self, config_fixture, sysfs_fixture_tree):
"""If derivation fails after sample write, prior data is intact."""
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
clock1 = FakeClock(t1)
run_collection(self._make_sample_for_failure(12345678),
sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
config_fixture, clock1)
# Verify first sample exists
conn = sqlite3.connect(config_fixture["store_path"])
count = conn.execute("SELECT COUNT(*) FROM samples").fetchone()[0]
conn.close()
assert count == 1
# Second sample with valid data should succeed
clock2 = FakeClock(t2)
r2 = run_collection(self._make_sample_for_failure(12345718),
sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
config_fixture, clock2)
assert r2["ok"]
# Both samples should exist
conn = sqlite3.connect(config_fixture["store_path"])
count = conn.execute("SELECT COUNT(*) FROM samples").fetchone()[0]
conn.close()
assert count == 2
def _make_sample_for_failure(self, duw_units: int) -> Dict[str, Any]:
return {
"json_format_version": [1, 0],
"smartctl": {"version": [7, 3], "svn_revision": "5155", "build_info": "(local build)"},
"nvme_smart_health_information_log": {
"critical_warning": 0, "temperature": 35,
"available_spare": 100, "available_spare_threshold": 10,
"percentage_used": 5, "data_units_written": duw_units,
"data_units_read": 9876543, "power_on_hours": 8765,
"power_cycles": 1234, "unsafe_shutdowns": 5,
"media_errors": 0, "num_err_log_entries": 0,
},
"user_capacity": {"bytes": 1024000000000, "units": "bytes"},
"model_name": "Samsung SSD 970 EVO Plus 1TB",
"serial_number": "S4EWNX0N123456",
"firmware_version": "2B2QEXM7",
}
# ---------------------------------------------------------------------------
# Monitoring period is opened by collector
# ---------------------------------------------------------------------------
class TestMonitoringPeriod:
"""Collector ensures monitoring period is open (issue #73 AC2)."""
def test_first_sample_opens_period(self, config_fixture, sysfs_fixture_tree):
"""First collection run opens a monitoring period."""
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
clock1 = FakeClock(t1)
run_collection(self._make_sample_simple(), sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
config_fixture, clock1)
conn = sqlite3.connect(config_fixture["store_path"])
period = get_open_period(conn)
conn.close()
assert period is not None, "A monitoring period should be open"
def test_subsequent_sample_keeps_period_open(self, config_fixture, sysfs_fixture_tree):
"""Subsequent collection runs keep the period open."""
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
clock1 = FakeClock(t1)
run_collection(self._make_sample_simple(), sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
config_fixture, clock1)
clock2 = FakeClock(t2)
run_collection(self._make_sample_simple(), sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
config_fixture, clock2)
conn = sqlite3.connect(config_fixture["store_path"])
period = get_open_period(conn)
periods_count = conn.execute("SELECT COUNT(*) FROM monitoring_periods").fetchone()[0]
conn.close()
assert period is not None
assert periods_count == 1 # Still only one period
def _make_sample_simple(self) -> Dict[str, Any]:
return {
"json_format_version": [1, 0],
"smartctl": {"version": [7, 3], "svn_revision": "5155", "build_info": "(local build)"},
"nvme_smart_health_information_log": {
"critical_warning": 0, "temperature": 35,
"available_spare": 100, "available_spare_threshold": 10,
"percentage_used": 5, "data_units_written": 12345678,
"data_units_read": 9876543, "power_on_hours": 8765,
"power_cycles": 1234, "unsafe_shutdowns": 5,
"media_errors": 0, "num_err_log_entries": 0,
},
"user_capacity": {"bytes": 1024000000000, "units": "bytes"},
"model_name": "Samsung SSD 970 EVO Plus 1TB",
"serial_number": "S4EWNX0N123456",
"firmware_version": "2B2QEXM7",
}
-258
View File
@@ -1,258 +0,0 @@
"""Tests for issue #77: Show honest qualifying-day progress and confidence.
These tests verify that:
1. Warming progress shows detailed qualifying day breakdown
2. Confidence state accurately reflects qualifying day progress
3. Edge cases like zero-delta intervals and unknown daily shares are handled
4. qualifying_days_progress shows honest qualifying day count
"""
import sqlite3
from datetime import datetime, timedelta, timezone
from pathlib import Path
import pytest
import sys
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from fenris.store import init_store
from fenris.monitoring_periods import ensure_period_open, close_period
from fenris.projection import (
compute_projection, ConfidenceState, BaselineTier,
WARMING_MIN_DAYS, WARMING_COVERAGE_FLOOR, WARMING_MAX_LOW_COVERAGE,
)
@pytest.fixture
def store(tmp_path):
conn = init_store(tmp_path / "test.db")
yield conn
conn.close()
def _clock(year=2026, month=9, day=30, hour=12):
return datetime(year, month, day, hour, 0, 0, tzinfo=timezone.utc)
def _insert_baseline(conn, tbw_tb=1.0, verified=True, model="Samsung SSD 970 EVO Plus 1TB"):
conn.execute(
"INSERT INTO endurance_baseline "
"(tbw_terabytes, source_url, document_revision, entry_date, model_string, "
" nominal_capacity_bytes, validated_by, verified, created_at, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(tbw_tb, "https://example.com/spec", "v1.0", "2026-01-01", model, 1024000000000,
"machine_match" if verified else None, verified, "2026-01-01T00:00:00+00:00",
"2026-01-01T00:00:00+00:00"),
)
conn.commit()
def _insert_segment(conn, opened_at="2026-09-01T00:00:00+00:00",
identity_key="nqn.test", degraded=False,
mn="Samsung SSD 970 EVO Plus 1TB"):
conn.execute(
"INSERT INTO controller_segments "
"(opened_at, identity_key, identity_degraded, subnqn, sn, mn, fr, vid, ssvid, transport) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(opened_at, identity_key, degraded, "nqn.test", "SN123", mn, "FW1", "0x144d", "0x144d", "pcie"),
)
conn.commit()
def _insert_day(conn, day, bw=1024*1024*100, coverage=0.95, samples=24):
conn.execute(
"INSERT INTO day_aggregates (day, active_seconds, idle_seconds, powered_off_seconds, "
"unknown_seconds, bytes_written_delta, bytes_read_delta, sample_count, coverage) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(day, 3600, 0, 0, 0, bw, 0, samples, coverage),
)
conn.commit()
def _insert_sample(conn, ts, pu=5):
conn.execute(
"INSERT INTO samples (ts, device, data_units_written, data_units_read, "
"percentage_used, bytes_written, bytes_read, power_on_hours) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(ts, "/dev/nvme0n1", 1000000, 500000, pu, 512000000000, 256000000000, 8765),
)
conn.commit()
def _open_period(conn, start="2026-09-01T00:00:00+00:00"):
# Convert string to datetime if needed
if isinstance(start, str):
start = datetime.fromisoformat(start)
ensure_period_open(conn, start)
conn.commit()
class TestHonestQualifyingProgress:
"""Issue #77: Show honest qualifying-day progress and confidence."""
def test_warming_shows_qualifying_vs_nonqualifying(self, store):
"""Warming progress shows both qualifying and non-qualifying days."""
_insert_baseline(store, tbw_tb=10.0, verified=True)
_insert_segment(store, opened_at="2026-09-20T00:00:00+00:00")
_open_period(store, start="2026-09-20T00:00:00+00:00")
bw = 100 * 1024 * 1024
# 12 qualifying days + 2 non-qualifying (low coverage)
for i in range(14):
d = (datetime(2026, 9, 20) + timedelta(days=i)).strftime("%Y-%m-%d")
cov = 0.30 if i < 2 else 0.95 # First 2 days have low coverage
_insert_day(store, d, bw=bw, coverage=cov)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
result = compute_projection(store, _clock())
# After warming, qualifying_days_progress shows honest count
assert result.warming_fact is None # Not warming (14 total, 2 below <= WARMING_MAX_LOW_COVERAGE)
assert result.qualifying_days_progress is not None
assert "12 of 14 qualifying days" in result.qualifying_days_progress
assert "2 below coverage" in result.qualifying_days_progress
def test_warming_progress_includes_nonqualifying_reason(self, store):
"""Warming progress indicates why days don't qualify."""
_insert_baseline(store, tbw_tb=10.0, verified=True)
_insert_segment(store, opened_at="2026-09-20T00:00:00+00:00")
_open_period(store, start="2026-09-20T00:00:00+00:00")
bw = 100 * 1024 * 1024
# 13 qualifying days + 1 non-qualifying (zero samples)
for i in range(14):
d = (datetime(2026, 9, 20) + timedelta(days=i)).strftime("%Y-%m-%d")
samples = 0 if i == 0 else 24 # First day has no samples
_insert_day(store, d, bw=bw, samples=samples)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
result = compute_projection(store, _clock())
# After warming, qualifying_days_progress shows honest count
assert result.warming_fact is None # Not warming (14 total, 1 below <= WARMING_MAX_LOW_COVERAGE)
assert result.qualifying_days_progress is not None
assert "13 of 14 qualifying days" in result.qualifying_days_progress
assert "1 below coverage" in result.qualifying_days_progress
def test_confidence_updates_as_qualifying_days_increase(self, store):
"""Confidence state reflects actual qualifying day count."""
_insert_baseline(store, tbw_tb=10.0, verified=True)
_insert_segment(store, opened_at="2026-09-20T00:00:00+00:00")
_open_period(store, start="2026-09-20T00:00:00+00:00")
bw = 100 * 1024 * 1024
# Start with 10 days (below minimum)
for i in range(10):
d = (datetime(2026, 9, 20) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, d, bw=bw, coverage=0.95)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
result = compute_projection(store, _clock())
# Should be warming
assert result.warming_fact is not None
assert result.confidence_state == ConfidenceState.LIMITED
def test_zero_rate_with_qualifying_days(self, store):
"""Zero rate with qualifying days shows honest state."""
_insert_baseline(store, tbw_tb=10.0, verified=True)
_insert_segment(store, opened_at="2026-09-20T00:00:00+00:00")
_open_period(store, start="2026-09-20T00:00:00+00:00")
# 14 days with zero bytes written
for i in range(14):
d = (datetime(2026, 9, 20) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, d, bw=0, coverage=0.95)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
result = compute_projection(store, _clock())
# Zero rate should be UNSUPPORTED
assert result.confidence_state == ConfidenceState.UNSUPPORTED
assert result.zero_rate_fact is not None
assert "no finite projection" in result.zero_rate_fact
def test_qualifying_days_excludes_low_coverage(self, store):
"""Days below 50% coverage don't count as qualifying."""
_insert_baseline(store, tbw_tb=10.0, verified=True)
_insert_segment(store, opened_at="2026-09-20T00:00:00+00:00")
_open_period(store, start="2026-09-20T00:00:00+00:00")
bw = 100 * 1024 * 1024
# 14 days total, but 3 have low coverage
for i in range(14):
d = (datetime(2026, 9, 20) + timedelta(days=i)).strftime("%Y-%m-%d")
cov = 0.30 if i < 3 else 0.95
_insert_day(store, d, bw=bw, coverage=cov)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
result = compute_projection(store, _clock())
# Should still be warming (3 days below coverage > WARMING_MAX_LOW_COVERAGE=2)
assert result.warming_fact is not None
assert "warming up" in result.warming_fact.lower()
def test_qualifying_days_excludes_zero_samples(self, store):
"""Days with zero samples don't count as qualifying."""
_insert_baseline(store, tbw_tb=10.0, verified=True)
_insert_segment(store, opened_at="2026-09-20T00:00:00+00:00")
_open_period(store, start="2026-09-20T00:00:00+00:00")
bw = 100 * 1024 * 1024
# 14 days total, but 3 have zero samples
for i in range(14):
d = (datetime(2026, 9, 20) + timedelta(days=i)).strftime("%Y-%m-%d")
samples = 0 if i < 3 else 24
_insert_day(store, d, bw=bw, samples=samples)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
result = compute_projection(store, _clock())
# Should still be warming
assert result.warming_fact is not None
assert "warming up" in result.warming_fact.lower()
def test_qualifying_days_progress_after_warming(self, store):
"""After warming, qualifying_days_progress shows honest count."""
_insert_baseline(store, tbw_tb=10.0, verified=True)
_insert_segment(store, opened_at="2026-09-17T00:00:00+00:00")
_open_period(store, start="2026-09-17T00:00:00+00:00")
bw = 100 * 1024 * 1024
# 14 total days, 12 qualifying (2 below coverage)
for i in range(14):
d = (datetime(2026, 9, 17) + timedelta(days=i)).strftime("%Y-%m-%d")
cov = 0.30 if i < 2 else 0.95 # First 2 days have low coverage
_insert_day(store, d, bw=bw, coverage=cov)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
result = compute_projection(store, _clock())
# Should not be warming (14 total, 2 below coverage <= WARMING_MAX_LOW_COVERAGE)
assert result.warming_fact is None
# But qualifying_days_progress should show the honest count
assert result.qualifying_days_progress is not None
assert "12 of 14 qualifying days" in result.qualifying_days_progress
assert "2 below coverage" in result.qualifying_days_progress
def test_qualifying_days_progress_all_qualifying(self, store):
"""When all days qualify, qualifying_days_progress shows 100%."""
_insert_baseline(store, tbw_tb=10.0, verified=True)
_insert_segment(store, opened_at="2026-09-17T00:00:00+00:00")
_open_period(store, start="2026-09-17T00:00:00+00:00")
bw = 100 * 1024 * 1024
# 14 days, all qualifying
for i in range(14):
d = (datetime(2026, 9, 17) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, d, bw=bw, coverage=0.95)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
result = compute_projection(store, _clock())
# Should not be warming
assert result.warming_fact is None
# qualifying_days_progress should show all qualifying
assert result.qualifying_days_progress is not None
assert "14 of 14 qualifying days" in result.qualifying_days_progress
# Should not show "below coverage" since all qualify
assert "below coverage" not in result.qualifying_days_progress
-318
View File
@@ -1,318 +0,0 @@
"""Tests for issue #79: Apply Fenris identity and Drive health grouping.
Covers:
- TPH-1: Titlebox shows Fenris identity with wolf fallback
- TPH-11: Single maker-credit placement, vendor wear under Drive health
- Preserve: continuity, pause block, quit rail, auth banner
"""
import sqlite3
from datetime import datetime, timedelta, timezone
from pathlib import Path
from unittest.mock import patch
import pytest
import sys
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from fenris.store import init_store
from fenris.monitoring_periods import ensure_period_open
from fenris.tui import FenrisTuiApp
# ---------------------------------------------------------------------------
# Helpers (reuse from test_tui.py)
# ---------------------------------------------------------------------------
def _clock(year=2026, month=9, day=30, hour=12):
return datetime(year, month, day, hour, 0, 0, tzinfo=timezone.utc)
def _insert_baseline(conn, tbw_tb=1.0, verified=True,
model="Samsung SSD 970 EVO Plus 1TB"):
conn.execute(
"INSERT INTO endurance_baseline "
"(tbw_terabytes, source_url, document_revision, entry_date, model_string, "
" nominal_capacity_bytes, validated_by, verified, created_at, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(tbw_tb, "https://example.com/spec", "v1.0", "2026-01-01", model,
1024000000000, "machine_match" if verified else None, verified,
"2026-01-01T00:00:00+00:00", "2026-01-01T00:00:00+00:00"),
)
conn.commit()
def _insert_segment(conn, opened_at="2026-09-01T00:00:00+00:00",
identity_key="nqn.test", degraded=False,
mn="Samsung SSD 970 EVO Plus 1TB"):
conn.execute(
"INSERT INTO controller_segments "
"(opened_at, identity_key, identity_degraded, subnqn, sn, mn, fr, vid, ssvid, transport) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(opened_at, identity_key, degraded, "nqn.test", "SN123", mn, "FW1",
"0x144d", "0x144d", "pcie"),
)
conn.commit()
def _insert_day(conn, day, bw=1024*1024*100, coverage=0.95, samples=24):
conn.execute(
"INSERT INTO day_aggregates (day, active_seconds, idle_seconds, powered_off_seconds, "
"unknown_seconds, bytes_written_delta, bytes_read_delta, sample_count, coverage) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(day, 3600, 0, 0, 0, bw, 0, samples, coverage),
)
conn.commit()
def _insert_sample(conn, ts, pu=5, device="/dev/nvme0n1"):
conn.execute(
"INSERT INTO samples (ts, device, data_units_written, data_units_read, "
"percentage_used, bytes_written, bytes_read, power_on_hours) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(ts, device, 1000000, 500000, pu, 512000000000, 256000000000, 8765),
)
conn.commit()
def _open_period(conn, start="2026-09-01T00:00:00+00:00"):
ensure_period_open(conn, datetime.fromisoformat(start))
# ---------------------------------------------------------------------------
# Issue #79: Fenris identity and Drive health grouping
# ---------------------------------------------------------------------------
class TestFenrisIdentity:
"""TPH-1: Titlebox shows Fenris identity with wolf fallback."""
@pytest.mark.asyncio
async def test_titlebox_shows_wolf_identity(self, tmp_path):
"""Top titlebox reads 🐺 Fenris by Bongbetic."""
app = FenrisTuiApp(
store_path=tmp_path / "nonexistent.db",
refresh_interval_s=0.2,
)
async with app.run_test(size=(120, 24)) as pilot:
headline = str(app.query_one("#headline-band").render())
assert "🐺 Fenris by Bongbetic" in headline
@pytest.mark.asyncio
async def test_titlebox_fallback_without_wolf(self, tmp_path):
"""Fallback to 'Fenris by Bongbetic' when wolf is unsupported."""
app = FenrisTuiApp(
store_path=tmp_path / "nonexistent.db",
refresh_interval_s=0.2,
)
# Simulate narrow terminal that can't render wolf
async with app.run_test(size=(60, 24)) as pilot:
headline = str(app.query_one("#headline-band").render())
# Either shows wolf or fallback - both are acceptable
assert "Fenris by Bongbetic" in headline
@pytest.mark.asyncio
async def test_wolf_never_shows_tofu(self, tmp_path):
"""Wolf glyph is never rendered as tofu (unsupported character)."""
app = FenrisTuiApp(
store_path=tmp_path / "nonexistent.db",
refresh_interval_s=0.2,
)
async with app.run_test(size=(120, 24)) as pilot:
headline = str(app.query_one("#headline-band").render())
# No replacement character (U+FFFD) should appear
assert "\ufffd" not in headline.lower()
assert "?" not in headline or "Fenris" in headline
@pytest.mark.asyncio
async def test_lifespan_headline_is_data_surface(self, tmp_path):
"""Lifespan headline remains a data surface, not app title."""
conn = init_store(tmp_path / "test.db")
_insert_baseline(conn, tbw_tb=1.0, verified=True)
_insert_segment(conn)
_open_period(conn)
for i in range(30):
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100, coverage=0.95, samples=24)
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(120, 24)) as pilot:
headline = str(app.query_one("#headline-band").render())
# Identity appears once, lifespan is separate data
assert "🐺 Fenris by Bongbetic" in headline
assert "remaining" in headline.lower() or "projection" in headline.lower()
class TestSingleMakerCredit:
"""TPH-1: Single maker-credit placement in title only."""
@pytest.mark.asyncio
async def test_maker_credit_not_in_service_strip(self, tmp_path):
"""Remove duplicate maker credit from service facts."""
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
_open_period(conn)
for i in range(20):
d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100)
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(120, 24)) as pilot:
strip = str(app.query_one("#service-strip").render())
# "by Bongbetic" should NOT appear in service strip
assert "by Bongbetic" not in strip
@pytest.mark.asyncio
async def test_maker_credit_in_titlebox(self, tmp_path):
"""Maker credit appears in the titlebox identity."""
app = FenrisTuiApp(
store_path=tmp_path / "nonexistent.db",
refresh_interval_s=0.2,
)
async with app.run_test(size=(120, 24)) as pilot:
headline = str(app.query_one("#headline-band").render())
assert "Fenris by Bongbetic" in headline
@pytest.mark.asyncio
async def test_empty_store_no_maker_credit_in_strip(self, tmp_path):
"""Empty store: no maker credit in service strip."""
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app.run_test(size=(120, 24)) as pilot:
strip = str(app.query_one("#service-strip").render())
assert "by Bongbetic" not in strip
@pytest.mark.asyncio
async def test_store_fault_no_maker_credit_in_strip(self, tmp_path):
"""Store fault: no maker credit in service strip."""
# Create a corrupt store
db = tmp_path / "test.db"
db.write_bytes(b"not a database")
app = FenrisTuiApp(store_path=db)
async with app.run_test(size=(120, 24)) as pilot:
strip = str(app.query_one("#service-strip").render())
assert "by Bongbetic" not in strip
class TestDriveHealthVendorWear:
"""TPH-1: Vendor wear renders under Drive health context."""
@pytest.mark.asyncio
async def test_vendor_wear_in_drive_health(self, tmp_path):
"""Vendor wear shows with health context, not as Settings."""
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
_open_period(conn)
for i in range(20):
d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100)
_insert_sample(conn, "2026-09-30T10:00:00+00:00", pu=10)
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(120, 24)) as pilot:
health = str(app.query_one("#drive-health").render())
# Vendor wear should be present with health context
assert "vendor wear" in health.lower()
# Should show thermal, spare, errors, etc.
assert "temperature" in health.lower()
assert "spare" in health.lower()
assert "media errors" in health.lower()
# Settings section should NOT be a separate heading
assert "[bold]Settings[/bold]" not in health
@pytest.mark.asyncio
async def test_missing_wear_values_honest(self, tmp_path):
"""Missing values remain honest unavailable facts."""
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
_open_period(conn)
for i in range(20):
d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100)
# Insert sample with zero wear values
conn.execute(
"INSERT INTO samples (ts, device, data_units_written, data_units_read, "
"percentage_used, bytes_written, bytes_read, power_on_hours) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
("2026-09-30T10:00:00+00:00", "/dev/nvme0n1", 0, 0, 0, 0, 0, 0),
)
conn.commit()
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(120, 24)) as pilot:
health = str(app.query_one("#drive-health").render())
# Should show 0% used or honest zero, not crash
assert "vendor wear" in health.lower()
class TestPreservedBehavior:
"""Preserve existing continuity, pause, quit, auth behavior."""
@pytest.mark.asyncio
async def test_continuity_preserved(self, tmp_path):
"""Continuity wording preserved in service strip."""
conn = init_store(tmp_path / "test.db")
_open_period(conn)
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db")
with patch("fenris.tui.query_service_state", return_value={
"boot_enabled": True, "timer_active": True,
"last_collect_ok": True, "last_collect_age_s": 60,
"last_collect_reason": None,
}):
async with app.run_test(size=(120, 24)) as pilot:
strip = str(app.query_one("#service-strip").render())
assert "continuity" in strip.lower()
assert "monitoring" in strip.lower()
@pytest.mark.asyncio
async def test_quit_rail_preserved(self, tmp_path):
"""Separate q QUIT TUI rail preserved."""
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app.run_test(size=(120, 24)) as pilot:
rail = str(app.query_one("#quit-rail").render())
assert "q QUIT TUI" in rail
@pytest.mark.asyncio
async def test_auth_banner_preserved(self, tmp_path):
"""Polkit authentication banner lifecycle preserved."""
app = FenrisTuiApp(
store_path=tmp_path / "nonexistent.db",
refresh_interval_s=0.2,
)
async with app.run_test(size=(120, 24)) as pilot:
headline = str(app.query_one("#headline-band").render())
assert "polkit" in headline.lower()
# Should clear after first tick
await pilot.pause(0.25)
headline_after = str(app.query_one("#headline-band").render())
assert "polkit" not in headline_after.lower()
@pytest.mark.asyncio
async def test_deliberate_pause_block_preserved(self, tmp_path):
"""Deliberate pause banner preserved."""
db = tmp_path / "test.db"
conn = init_store(db)
conn.execute(
"INSERT INTO monitoring_periods (started_at, ended_at, end_cause) "
"VALUES (?, ?, ?)",
("2026-09-01T09:00:00+00:00", "2026-09-01T10:00:00+00:00", "user_disabled"),
)
conn.commit()
conn.close()
app = FenrisTuiApp(store_path=db)
with patch("fenris.tui.query_service_state", return_value={
"boot_enabled": False, "timer_active": False,
"last_collect_ok": None, "last_collect_age_s": None,
"last_collect_reason": None,
}):
async with app.run_test(size=(120, 24)) as pilot:
banner = str(app.query_one("#paused-banner").render())
assert "paused" in banner.lower()
assert "deliberate" in banner.lower()
-482
View File
@@ -1,482 +0,0 @@
"""Integration tests for issue #80: Persist accessible colour and motion preferences.
Covers:
- AC80-1: Amber/Nord/High Contrast presets with Amber default
- AC80-2: t preset and m motion controls with clickable equivalents
- AC80-3: Persistent user-scoped XDG TUI preferences
- AC80-4: Reduced motion makes Monitoring steady
- AC80-5: Theme roles for graph rendering
- AC80-6: Headless interaction tests with temporary user config
"""
import json
import os
from datetime import datetime, timedelta, timezone
from pathlib import Path
from unittest.mock import patch
import pytest
import sys
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from fenris.store import init_store
from fenris.preferences import (
load_preferences,
save_preferences,
get_preference_path,
)
from fenris.themes import get_theme, get_graph_colors, THEME_NAMES
from fenris.status_composition import (
StatusState,
compose_status,
render_status_tui,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _clock(year=2026, month=9, day=30, hour=12):
return datetime(year, month, day, hour, 0, 0, tzinfo=timezone.utc)
def _insert_segment(conn, opened_at="2026-09-01T00:00:00+00:00",
identity_key="nqn.test", degraded=False,
mn="Samsung SSD 970 EVO Plus 1TB"):
conn.execute(
"INSERT INTO controller_segments "
"(opened_at, identity_key, identity_degraded, subnqn, sn, mn, fr, vid, ssvid, transport) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(opened_at, identity_key, degraded, "nqn.test", "SN123", mn, "FW1",
"0x144d", "0x144d", "pcie"),
)
conn.commit()
def _insert_day(conn, day, bw=1024*1024*100, coverage=0.95, samples=24):
conn.execute(
"INSERT INTO day_aggregates (day, active_seconds, idle_seconds, powered_off_seconds, "
"unknown_seconds, bytes_written_delta, bytes_read_delta, sample_count, coverage) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(day, 3600, 0, 0, 0, bw, 0, samples, coverage),
)
conn.commit()
def _insert_sample(conn, ts, device="/dev/nvme0n1"):
conn.execute(
"INSERT INTO samples (ts, device, data_units_written, data_units_read, "
"percentage_used, bytes_written, bytes_read, power_on_hours) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(ts, device, 1000000, 500000, 5, 512000000000, 256000000000, 8765),
)
conn.commit()
def _open_period(conn, start="2026-09-01T00:00:00+00:00"):
from fenris.monitoring_periods import ensure_period_open
ensure_period_open(conn, datetime.fromisoformat(start))
def _make_prefs_dir(tmp_path: Path) -> Path:
config_home = tmp_path / ".config"
config_home.mkdir(parents=True, exist_ok=True)
return config_home
# ---------------------------------------------------------------------------
# AC80-1: Preset loading and application
# ---------------------------------------------------------------------------
class TestPresetLoading:
"""Themes load from preferences and apply to the TUI."""
@pytest.mark.asyncio
async def test_tui_applies_amber_theme_by_default(self, tmp_path):
"""TUI starts with the Amber theme when no preferences exist."""
from fenris.tui import FenrisTuiApp
config_home = _make_prefs_dir(tmp_path)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app.run_test() as pilot:
# Theme should be fenris-amber
assert app.theme == "fenris-amber"
@pytest.mark.asyncio
async def test_tui_applies_nord_theme_from_prefs(self, tmp_path):
"""TUI applies Nord theme from saved preferences."""
from fenris.tui import FenrisTuiApp
config_home = _make_prefs_dir(tmp_path)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
save_preferences(theme="nord", reduced_motion=False)
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app.run_test() as pilot:
assert app.theme == "fenris-nord"
@pytest.mark.asyncio
async def test_tui_applies_high_contrast_from_prefs(self, tmp_path):
"""TUI applies High Contrast theme from saved preferences."""
from fenris.tui import FenrisTuiApp
config_home = _make_prefs_dir(tmp_path)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
save_preferences(theme="high_contrast", reduced_motion=False)
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app.run_test() as pilot:
assert app.theme == "fenris-high-contrast"
@pytest.mark.asyncio
async def test_tui_applies_reduced_motion_from_prefs(self, tmp_path):
"""TUI respects reduced_motion preference."""
from fenris.tui import FenrisTuiApp
from fenris.status_composition import compose_status
config_home = _make_prefs_dir(tmp_path)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
save_preferences(theme="amber", reduced_motion=True)
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app.run_test() as pilot:
# The app should have reduced_motion set
assert app._reduced_motion is True
# ---------------------------------------------------------------------------
# AC80-2: t and m key bindings
# ---------------------------------------------------------------------------
class TestKeyBindings:
"""t cycles presets and m toggles reduced motion."""
@pytest.mark.asyncio
async def test_t_binding_exists(self, tmp_path):
"""The TUI has a 't' binding for theme cycling."""
from fenris.tui import FenrisTuiApp
config_home = _make_prefs_dir(tmp_path)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
binding_keys = {b.key for b in app.BINDINGS}
assert "t" in binding_keys
@pytest.mark.asyncio
async def test_m_binding_exists(self, tmp_path):
"""The TUI has an 'm' binding for motion toggle."""
from fenris.tui import FenrisTuiApp
config_home = _make_prefs_dir(tmp_path)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
binding_keys = {b.key for b in app.BINDINGS}
assert "m" in binding_keys
@pytest.mark.asyncio
async def test_t_cycles_through_themes(self, tmp_path):
"""Pressing t cycles through the three presets."""
from fenris.tui import FenrisTuiApp
config_home = _make_prefs_dir(tmp_path)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app.run_test() as pilot:
# Start at amber
assert app.theme == "fenris-amber"
# Press t to cycle
await pilot.press("t")
await pilot.pause()
# Should be nord or high_contrast now
assert app.theme in ("fenris-nord", "fenris-high-contrast")
@pytest.mark.asyncio
async def test_m_toggles_reduced_motion(self, tmp_path):
"""Pressing m toggles reduced motion."""
from fenris.tui import FenrisTuiApp
config_home = _make_prefs_dir(tmp_path)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app.run_test() as pilot:
# Start with normal motion
assert app._reduced_motion is False
# Press m to toggle
await pilot.press("m")
await pilot.pause()
# Should be reduced motion now
assert app._reduced_motion is True
# Press m again to toggle back
await pilot.press("m")
await pilot.pause()
assert app._reduced_motion is False
# ---------------------------------------------------------------------------
# AC80-3: Persistence across restart
# ---------------------------------------------------------------------------
class TestPersistence:
"""Preferences survive app restart."""
@pytest.mark.asyncio
async def test_theme_survives_restart(self, tmp_path):
"""Theme preference persists across TUI restart."""
from fenris.tui import FenrisTuiApp
config_home = _make_prefs_dir(tmp_path)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
# First run: change theme
app1 = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app1.run_test() as pilot:
await pilot.press("t")
await pilot.pause()
theme_after_t = app1.theme
assert theme_after_t != "fenris-amber"
# Second run: theme should persist
app2 = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app2.run_test() as pilot:
assert app2.theme == theme_after_t
@pytest.mark.asyncio
async def test_reduced_motion_survives_restart(self, tmp_path):
"""Reduced motion preference persists across TUI restart."""
from fenris.tui import FenrisTuiApp
config_home = _make_prefs_dir(tmp_path)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
# First run: toggle motion
app1 = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app1.run_test() as pilot:
await pilot.press("m")
await pilot.pause()
assert app1._reduced_motion is True
# Second run: motion should persist
app2 = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app2.run_test() as pilot:
assert app2._reduced_motion is True
# ---------------------------------------------------------------------------
# AC80-4: Reduced motion makes Monitoring steady
# ---------------------------------------------------------------------------
class TestReducedMotion:
"""Reduced motion disables the Monitoring dot blink."""
def test_reduced_motion_disables_blink(self, tmp_path):
"""compose_status with reduced_motion=True → should_blink=False."""
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
_open_period(conn)
now = _clock()
ts = (now - timedelta(minutes=2)).isoformat()
_insert_sample(conn, ts)
for i in range(20):
d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100)
conn.close()
conn = init_store(tmp_path / "test.db")
svc = {
"boot_enabled": True, "timer_active": True,
"last_collect_ok": True, "last_collect_age_s": 120,
"last_collect_reason": None,
}
comp = compose_status(conn, svc, now, store_fault=None, newer_schema=None,
reduced_motion=True)
assert comp.state == StatusState.MONITORING
assert comp.should_blink is False
conn.close()
def test_normal_motion_allows_blink(self, tmp_path):
"""compose_status with reduced_motion=False → should_blink=True for Monitoring."""
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
_open_period(conn)
now = _clock()
ts = (now - timedelta(minutes=2)).isoformat()
_insert_sample(conn, ts)
for i in range(20):
d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100)
conn.close()
conn = init_store(tmp_path / "test.db")
svc = {
"boot_enabled": True, "timer_active": True,
"last_collect_ok": True, "last_collect_age_s": 120,
"last_collect_reason": None,
}
comp = compose_status(conn, svc, now, store_fault=None, newer_schema=None,
reduced_motion=False)
assert comp.state == StatusState.MONITORING
assert comp.should_blink is True
conn.close()
@pytest.mark.asyncio
async def test_framework_reduced_motion_honoured(self, tmp_path):
"""TUI honours Textual's reduced_motion signal."""
from fenris.tui import FenrisTuiApp
config_home = _make_prefs_dir(tmp_path)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app.run_test() as pilot:
# The app should check for reduced motion on mount
assert hasattr(app, '_reduced_motion')
# ---------------------------------------------------------------------------
# AC80-5: Theme roles for graph rendering
# ---------------------------------------------------------------------------
class TestGraphThemeRoles:
"""Graph uses theme-derived colours for each bar role."""
def test_graph_colors_available_for_all_themes(self):
"""Every theme provides all required graph colour roles."""
required_roles = {"allocated", "unallocated", "gap", "zero", "partial"}
for name in THEME_NAMES:
colors = get_graph_colors(name)
assert required_roles.issubset(set(colors.keys())), "Theme %s missing roles: %s" % (name, required_roles - set(colors.keys()))
def test_graph_colors_are_strings(self):
"""All graph colour values are strings (Textual CSS colour values)."""
for name in THEME_NAMES:
colors = get_graph_colors(name)
for role, color in colors.items():
assert isinstance(color, str), "Theme %s role %s has non-string color: %s" % (name, role, color)
# ---------------------------------------------------------------------------
# AC80-6: Safe persistence — failure modes
# ---------------------------------------------------------------------------
class TestSafePersistence:
"""Preference failures never crash the dashboard."""
@pytest.mark.asyncio
async def test_corrupt_prefs_does_not_crash_tui(self, tmp_path):
"""Corrupt preference file does not prevent TUI from starting."""
from fenris.tui import FenrisTuiApp
config_home = _make_prefs_dir(tmp_path)
fenris_dir = config_home / "fenris"
fenris_dir.mkdir(parents=True, exist_ok=True)
(fenris_dir / "preferences.json").write_text("{bad json!!!")
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app.run_test() as pilot:
# TUI should start with default theme
assert app.theme == "fenris-amber"
# Dashboard should be functional
headline = str(app.query_one("#headline-band").render())
assert headline is not None
@pytest.mark.asyncio
async def test_readonly_config_dir_does_not_crash(self, tmp_path):
"""Read-only config directory does not prevent TUI from starting."""
from fenris.tui import FenrisTuiApp
config_home = _make_prefs_dir(tmp_path)
fenris_dir = config_home / "fenris"
fenris_dir.mkdir(parents=True, exist_ok=True)
fenris_dir.chmod(0o555)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app.run_test() as pilot:
# TUI should start without crash
assert app.theme in ("fenris-amber", "fenris-nord", "fenris-high-contrast")
# ---------------------------------------------------------------------------
# CLI isolation (preferences do not affect CLI)
# ---------------------------------------------------------------------------
class TestCLIIsolation:
"""Preferences are TUI-only — CLI status is independent."""
def test_cli_status_unchanged_by_theme(self, tmp_path):
"""fenris status output does not change based on theme preference."""
from fenris.status import get_status
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
_open_period(conn)
now = _clock()
ts = (now - timedelta(minutes=2)).isoformat()
_insert_sample(conn, ts)
for i in range(20):
d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100)
conn.close()
config_home = _make_prefs_dir(tmp_path)
# With amber theme
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
save_preferences(theme="amber", reduced_motion=False)
status_amber = get_status(
store_path=tmp_path / "test.db",
clock_now=now,
query_services=False,
query_journal=False,
)
# With high contrast theme
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
save_preferences(theme="high_contrast", reduced_motion=True)
status_hc = get_status(
store_path=tmp_path / "test.db",
clock_now=now,
query_services=False,
query_journal=False,
)
# Status output should be identical
assert status_amber == status_hc
def test_cli_status_unchanged_by_motion(self, tmp_path):
"""fenris status output does not change based on reduced_motion preference."""
from fenris.status import get_status
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
_open_period(conn)
now = _clock()
ts = (now - timedelta(minutes=2)).isoformat()
_insert_sample(conn, ts)
for i in range(20):
d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100)
conn.close()
config_home = _make_prefs_dir(tmp_path)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
save_preferences(theme="amber", reduced_motion=False)
status_normal = get_status(
store_path=tmp_path / "test.db",
clock_now=now,
query_services=False,
query_journal=False,
)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
save_preferences(theme="amber", reduced_motion=True)
status_reduced = get_status(
store_path=tmp_path / "test.db",
clock_now=now,
query_services=False,
query_journal=False,
)
assert status_normal == status_reduced
+88 -115
View File
@@ -32,52 +32,24 @@ def _get_version() -> str:
return get_version()
def _get_container_runtime() -> str:
"""Get available container runtime (podman or docker)."""
for cmd in ["podman", "docker"]:
try:
r = subprocess.run([cmd, "info"], capture_output=True, timeout=10)
if r.returncode == 0:
return cmd
except (FileNotFoundError, subprocess.TimeoutExpired):
continue
return "docker" # fallback
def _get_container_runtime() -> str:
"""Get available container runtime (podman or docker)."""
for cmd in ["podman", "docker"]:
try:
r = subprocess.run([cmd, "info"], capture_output=True, timeout=10)
if r.returncode == 0:
return cmd
except (FileNotFoundError, subprocess.TimeoutExpired):
continue
return "docker" # fallback
def _docker_available() -> bool:
"""Check if Docker or Podman daemon is reachable."""
return _get_container_runtime() != ""
"""Check if Docker daemon is reachable."""
try:
r = subprocess.run(
["docker", "info"], capture_output=True, timeout=10
)
return r.returncode == 0
except (FileNotFoundError, subprocess.TimeoutExpired):
return False
def _container_exec(container: str, cmd: str) -> tuple[int, str]:
"""Execute a command inside a running container."""
runtime = _get_container_runtime()
try:
r = subprocess.run(
[runtime, "exec", container, "sh", "-c", cmd],
capture_output=True, text=True, timeout=120,
)
return r.returncode, r.stdout + r.stderr
except (FileNotFoundError, subprocess.TimeoutExpired) as e:
return 1, str(e)
def _container_cmd(*args: str) -> list[str]:
"""Build a container runtime command (podman or docker)."""
return [_get_container_runtime()] + list(args)
r = subprocess.run(
["docker", "exec", container, "sh", "-c", cmd],
capture_output=True, text=True, timeout=120,
)
return r.returncode, r.stdout + r.stderr
def _find_package(fmt: str) -> Path:
@@ -237,7 +209,7 @@ def _assert_dormant_layout(container: str, fmt: str, version: str) -> None:
rc, out = _container_exec(container, "stat -c '%a %U %G' /var/lib/fenris")
assert rc == 0, "Observation store directory not created"
parts = out.strip().split()
assert parts[0] == "2770", f"Store dir mode: expected 2770, got {parts[0]}"
assert parts[0] == "2750", f"Store dir mode: expected 2750, got {parts[0]}"
assert parts[1] == "root", f"Store dir owner: expected root, got {parts[1]}"
assert parts[2] == "fenris", f"Store dir group: expected fenris, got {parts[2]}"
@@ -382,12 +354,12 @@ def _assert_upgrade_semantics(container: str, fmt: str, pkg_name: str, version:
)
assert rc == 0, "Observation store missing after upgrade"
# Store directory was not rebuilt (same inode, mode 2770)
# Store directory was not rebuilt (same inode, mode 2750)
rc, out = _container_exec(
container, "stat -c '%a' /var/lib/fenris"
)
assert rc == 0, "Store directory missing after upgrade"
assert out.strip() == "2770", (
assert out.strip() == "2750", (
f"Store directory mode changed during upgrade (rebuilt?): {out.strip()}"
)
@@ -459,13 +431,13 @@ def _setup_store_and_config(container: str) -> None:
_container_exec(
container,
"mkdir -p /var/lib/fenris && "
"python3 -c '"
"python3 -c \""
"import sqlite3; "
"c = sqlite3.connect(\"/var/lib/fenris/observations.db\"); "
"c.execute(\"PRAGMA user_version=1\"); "
"c.commit(); c.close()' && "
"echo 'wal' > /var/lib/fenris/observations.db-wal && "
"echo 'shm' > /var/lib/fenris/observations.db-shm && "
"c = sqlite3.connect('/var/lib/fenris/observations.db'); "
"c.execute('PRAGMA user_version=1'); "
"c.commit(); c.close()\" && "
"touch /var/lib/fenris/observations.db-wal && "
"touch /var/lib/fenris/observations.db-shm && "
"cp /var/lib/fenris/observations.db /var/lib/fenris/observations.db.bak",
)
_container_exec(
@@ -500,21 +472,14 @@ def _assert_deb_remove_preserves(container: str) -> None:
rc, _ = _container_exec(container, "test -d /var/lib/fenris")
assert rc == 0, "Store directory should survive deb remove"
# Store DB and backup survive (WAL/SHM are ephemeral SQLite files
# cleaned by dpkg from package-owned directories)
for name in ("observations.db", "observations.db.bak"):
# Store DB, WAL sidecars, and backup survive
for name in ("observations.db", "observations.db-wal",
"observations.db-shm", "observations.db.bak"):
rc, _ = _container_exec(
container, f"test -f /var/lib/fenris/{name}"
)
assert rc == 0, f"Store file {name} should survive deb remove"
# WAL/SHM are gone (dpkg cleans them from package-owned dirs)
for name in ("observations.db-wal", "observations.db-shm"):
rc, _ = _container_exec(
container, f"test -f /var/lib/fenris/{name}"
)
assert rc != 0, f"Ephemeral file {name} should not survive deb remove"
# Service group survives
rc, _ = _container_exec(container, "getent group fenris")
assert rc == 0, "Group should survive deb remove"
@@ -602,16 +567,18 @@ def test_python_floor(skip_no_docker, version):
tag = "fenris-floor-ubuntu-2204"
subprocess.run(
_container_cmd("build", "-t", tag, str(build_dir)),
["docker", "build", "-t", tag, str(build_dir)],
check=True,
capture_output=True,
timeout=300,
)
container = f"fenris-floor-{os.getpid()}"
subprocess.run(
_container_cmd("run", "-d", "--name", container,
[
"docker", "run", "-d", "--name", container,
"--tmpfs", "/tmp:exec,size=64m",
tag, "sleep", "infinity"),
tag, "sleep", "infinity",
],
check=True,
capture_output=True,
)
@@ -640,7 +607,7 @@ def test_python_floor(skip_no_docker, version):
assert rc == 0, "fenris not on PATH after floor-met install"
finally:
subprocess.run(
_container_cmd("rm", "-f", container),
["docker", "rm", "-f", container],
capture_output=True,
)
@@ -663,16 +630,18 @@ def test_python_floor(skip_no_docker, version):
tag_floor = "fenris-below-floor-debian11"
subprocess.run(
_container_cmd("build", "-t", tag_floor, str(build_dir_floor)),
["docker", "build", "-t", tag_floor, str(build_dir_floor)],
check=True,
capture_output=True,
timeout=300,
)
container_floor = f"fenris-below-floor-{os.getpid()}"
subprocess.run(
_container_cmd("run", "-d", "--name", container_floor,
[
"docker", "run", "-d", "--name", container_floor,
"--tmpfs", "/tmp:exec,size=64m",
tag_floor, "sleep", "infinity"),
tag_floor, "sleep", "infinity",
],
check=True,
capture_output=True,
)
@@ -707,7 +676,7 @@ def test_python_floor(skip_no_docker, version):
f"Package should not be configured below Python floor: {out}"
finally:
subprocess.run(
_container_cmd("rm", "-f", container_floor),
["docker", "rm", "-f", container_floor],
capture_output=True,
)
@@ -742,7 +711,7 @@ def test_dormant_install(skip_no_docker, image, fmt, version):
# Build image
tag = f"fenris-test-{image.replace(':', '-').replace('/', '-')}"
subprocess.run(
_container_cmd("build", "-t", tag, str(build_dir)),
["docker", "build", "-t", tag, str(build_dir)],
check=True,
capture_output=True,
timeout=300,
@@ -751,9 +720,11 @@ def test_dormant_install(skip_no_docker, image, fmt, version):
# Run container
container = f"fenris-test-{os.getpid()}"
subprocess.run(
_container_cmd("run", "-d", "--name", container,
[
"docker", "run", "-d", "--name", container,
"--tmpfs", "/tmp:exec,size=64m",
tag, "sleep", "infinity"),
tag, "sleep", "infinity",
],
check=True,
capture_output=True,
)
@@ -762,7 +733,7 @@ def test_dormant_install(skip_no_docker, image, fmt, version):
_assert_dormant_layout(container, fmt, version)
finally:
subprocess.run(
_container_cmd("rm", "-f", container),
["docker", "rm", "-f", container],
capture_output=True,
)
@@ -798,13 +769,11 @@ def test_migration_guard(skip_no_docker, image, fmt, version):
RUN mkdir -p /var/lib/fenris && echo '# manifest' > /var/lib/fenris/manifest.txt
""")
else:
if image.startswith("opensuse/"):
install_command = "zypper --non-interactive install --no-recommends python3 smartmontools systemd dbus-1 && zypper clean --all"
else:
install_command = "dnf install -y --setopt=install_weak_deps=False python3 smartmontools systemd dbus && dnf clean all"
dockerfile = textwrap.dedent(f"""\
FROM {image}
RUN {install_command}
RUN dnf install -y --setopt=install_weak_deps=False \
python3 smartmontools systemd dbus && \
dnf clean all
COPY dist/{pkg_name} /pkg/{pkg_name}
RUN mkdir -p /etc/systemd/system && \\
echo '[Unit]' > /etc/systemd/system/fenris-collect.timer
@@ -813,7 +782,7 @@ def test_migration_guard(skip_no_docker, image, fmt, version):
tag = f"fenris-guard-{image.replace(':', '-').replace('/', '-')}"
subprocess.run(
_container_cmd("build", "-t", tag, str(build_dir)),
["docker", "build", "-t", tag, str(build_dir)],
check=True,
capture_output=True,
timeout=300,
@@ -821,9 +790,11 @@ def test_migration_guard(skip_no_docker, image, fmt, version):
container = f"fenris-guard-{os.getpid()}"
subprocess.run(
_container_cmd("run", "-d", "--name", container,
[
"docker", "run", "-d", "--name", container,
"--tmpfs", "/tmp:exec,size=64m",
tag, "sleep", "infinity"),
tag, "sleep", "infinity",
],
check=True,
capture_output=True,
)
@@ -832,7 +803,7 @@ def test_migration_guard(skip_no_docker, image, fmt, version):
_assert_migration_guard(container, fmt, pkg_name)
finally:
subprocess.run(
_container_cmd("rm", "-f", container),
["docker", "rm", "-f", container],
capture_output=True,
)
@@ -864,7 +835,7 @@ def test_upgrade_semantics(skip_no_docker, image, fmt, version):
tag = f"fenris-upgrade-{image.replace(':', '-').replace('/', '-')}"
subprocess.run(
_container_cmd("build", "-t", tag, str(build_dir)),
["docker", "build", "-t", tag, str(build_dir)],
check=True,
capture_output=True,
timeout=300,
@@ -872,9 +843,11 @@ def test_upgrade_semantics(skip_no_docker, image, fmt, version):
container = f"fenris-upgrade-{os.getpid()}"
subprocess.run(
_container_cmd("run", "-d", "--name", container,
[
"docker", "run", "-d", "--name", container,
"--tmpfs", "/tmp:exec,size=64m",
tag, "sleep", "infinity"),
tag, "sleep", "infinity",
],
check=True,
capture_output=True,
)
@@ -887,7 +860,7 @@ def test_upgrade_semantics(skip_no_docker, image, fmt, version):
_assert_rpm_upgrade_full(container, pkg_name)
finally:
subprocess.run(
_container_cmd("rm", "-f", container),
["docker", "rm", "-f", container],
capture_output=True,
)
@@ -916,14 +889,14 @@ def test_deb_remove_preserves_config_and_store(skip_no_docker, image, version):
tag = f"fenris-deb-remove-{image.replace(':', '-')}"
subprocess.run(
_container_cmd("build", "-t", tag, str(build_dir)),
["docker", "build", "-t", tag, str(build_dir)],
check=True, capture_output=True, timeout=300,
)
container = f"fenris-deb-remove-{os.getpid()}"
subprocess.run(
_container_cmd("run", "-d", "--name", container,
"--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"),
["docker", "run", "-d", "--name", container,
"--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"],
check=True, capture_output=True,
)
@@ -938,7 +911,7 @@ def test_deb_remove_preserves_config_and_store(skip_no_docker, image, version):
# test_upgrade_semantics (criterion 4: removal scripts are no-ops).
finally:
subprocess.run(
_container_cmd("rm", "-f", container), capture_output=True,
["docker", "rm", "-f", container], capture_output=True,
)
@@ -962,14 +935,14 @@ def test_deb_purge_removes_everything(skip_no_docker, image, version):
tag = f"fenris-deb-purge-{image.replace(':', '-')}"
subprocess.run(
_container_cmd("build", "-t", tag, str(build_dir)),
["docker", "build", "-t", tag, str(build_dir)],
check=True, capture_output=True, timeout=300,
)
container = f"fenris-deb-purge-{os.getpid()}"
subprocess.run(
_container_cmd("run", "-d", "--name", container,
"--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"),
["docker", "run", "-d", "--name", container,
"--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"],
check=True, capture_output=True,
)
@@ -981,12 +954,12 @@ def test_deb_purge_removes_everything(skip_no_docker, image, version):
_assert_package_files_removed(container)
finally:
subprocess.run(
_container_cmd("rm", "-f", container), capture_output=True,
["docker", "rm", "-f", container], capture_output=True,
)
@pytest.mark.slow
@pytest.mark.parametrize("image,fmt", ALL_TARGETS,
@pytest.mark.parametrize("image", [t[0] for t in ALL_TARGETS],
ids=[t[0] for t in ALL_TARGETS])
def test_sanctioned_disable_skipped_on_upgrade(skip_no_docker, image, fmt,
version):
@@ -1010,14 +983,14 @@ def test_sanctioned_disable_skipped_on_upgrade(skip_no_docker, image, fmt,
tag = f"fenris-upgrade-no-disable-{image.replace(':', '-').replace('/', '-')}"
subprocess.run(
_container_cmd("build", "-t", tag, str(build_dir)),
["docker", "build", "-t", tag, str(build_dir)],
check=True, capture_output=True, timeout=300,
)
container = f"fenris-upgrade-no-disable-{os.getpid()}"
subprocess.run(
_container_cmd("run", "-d", "--name", container,
"--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"),
["docker", "run", "-d", "--name", container,
"--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"],
check=True, capture_output=True,
)
@@ -1077,7 +1050,7 @@ def test_sanctioned_disable_skipped_on_upgrade(skip_no_docker, image, fmt,
assert rc == 0, "Helper removed during upgrade"
finally:
subprocess.run(
_container_cmd("rm", "-f", container), capture_output=True,
["docker", "rm", "-f", container], capture_output=True,
)
@@ -1100,14 +1073,14 @@ def test_rpm_erase_modified_config_preserved(skip_no_docker, version):
tag = "fenris-rpm-erase-mod"
subprocess.run(
_container_cmd("build", "-t", tag, str(build_dir)),
["docker", "build", "-t", tag, str(build_dir)],
check=True, capture_output=True, timeout=300,
)
container = f"fenris-rpm-erase-{os.getpid()}"
subprocess.run(
_container_cmd("run", "-d", "--name", container,
"--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"),
["docker", "run", "-d", "--name", container,
"--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"],
check=True, capture_output=True,
)
@@ -1133,7 +1106,7 @@ def test_rpm_erase_modified_config_preserved(skip_no_docker, version):
# test_upgrade_semantics (criterion 4: removal scripts are no-ops).
finally:
subprocess.run(
_container_cmd("rm", "-f", container), capture_output=True,
["docker", "rm", "-f", container], capture_output=True,
)
@@ -1156,14 +1129,14 @@ def test_rpm_erase_unmodified_config_removed(skip_no_docker, version):
tag = "fenris-rpm-erase-unmod"
subprocess.run(
_container_cmd("build", "-t", tag, str(build_dir)),
["docker", "build", "-t", tag, str(build_dir)],
check=True, capture_output=True, timeout=300,
)
container = f"fenris-rpm-erase-unmod-{os.getpid()}"
subprocess.run(
_container_cmd("run", "-d", "--name", container,
"--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"),
["docker", "run", "-d", "--name", container,
"--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"],
check=True, capture_output=True,
)
@@ -1181,7 +1154,7 @@ def test_rpm_erase_unmodified_config_removed(skip_no_docker, version):
_assert_package_files_removed(container)
finally:
subprocess.run(
_container_cmd("rm", "-f", container), capture_output=True,
["docker", "rm", "-f", container], capture_output=True,
)
@@ -1193,7 +1166,7 @@ def _seed_make_install_state(container: str, *, include_manifest: bool = True) -
"""Seed a container with make-install-shaped state."""
cmds = [
"groupadd -f fenris",
"install -d -o root -g fenris -m 2770 /var/lib/fenris",
"install -d -o root -g fenris -m 2750 /var/lib/fenris",
"echo 'devices = /dev/nvme0n1' > /etc/fenris/fenris.conf",
"python3 -c \"import sqlite3; c=sqlite3.connect('/var/lib/fenris/observations.db'); "
"c.execute('PRAGMA user_version=1'); c.commit(); c.close()\"",
@@ -1312,14 +1285,14 @@ def test_no_move_continuity_deb(skip_no_docker, image, version):
tag = f"fenris-no-move-deb-{image.replace(':', '-')}"
subprocess.run(
_container_cmd("build", "-t", tag, str(build_dir)),
["docker", "build", "-t", tag, str(build_dir)],
check=True, capture_output=True, timeout=300,
)
container = f"fenris-no-move-deb-{os.getpid()}"
subprocess.run(
_container_cmd("run", "-d", "--name", container,
"--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"),
["docker", "run", "-d", "--name", container,
"--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"],
check=True, capture_output=True,
)
@@ -1360,7 +1333,7 @@ def test_no_move_continuity_deb(skip_no_docker, image, version):
)
finally:
subprocess.run(
_container_cmd("rm", "-f", container), capture_output=True,
["docker", "rm", "-f", container], capture_output=True,
)
@@ -1387,14 +1360,14 @@ def test_no_move_continuity_rpm(skip_no_docker, version):
tag = "fenris-no-move-rpm"
subprocess.run(
_container_cmd("build", "-t", tag, str(build_dir)),
["docker", "build", "-t", tag, str(build_dir)],
check=True, capture_output=True, timeout=300,
)
container = f"fenris-no-move-rpm-{os.getpid()}"
subprocess.run(
_container_cmd("run", "-d", "--name", container,
"--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"),
["docker", "run", "-d", "--name", container,
"--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"],
check=True, capture_output=True,
)
@@ -1434,5 +1407,5 @@ def test_no_move_continuity_rpm(skip_no_docker, version):
)
finally:
subprocess.run(
_container_cmd("rm", "-f", container), capture_output=True,
["docker", "rm", "-f", container], capture_output=True,
)
-301
View File
@@ -1,301 +0,0 @@
"""Tests for user-scoped TUI preferences (issue #80).
Covers:
- Preference load/save with safe defaults
- XDG_CONFIG_HOME user-scoped persistence
- Amber default theme, normal-motion default
- Invalid/unreadable/unwritable preference data does not crash
- Theme presets: Amber, Nord, High Contrast
- Reduced motion preference persistence
- CLI status and collector behaviour unchanged by preferences
"""
import json
import os
from pathlib import Path
from unittest.mock import patch
import pytest
import sys
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from fenris.preferences import (
load_preferences,
save_preferences,
get_preference_path,
PREFERENCE_FILE_NAME,
VALID_THEMES,
DEFAULT_THEME,
DEFAULT_REDUCED_MOTION,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_prefs_dir(tmp_path: Path) -> Path:
"""Create a fake XDG_CONFIG_HOME with fenris subdir."""
config_home = tmp_path / ".config"
config_home.mkdir(parents=True, exist_ok=True)
return config_home
# ---------------------------------------------------------------------------
# Preference path tests
# ---------------------------------------------------------------------------
class TestPreferencePath:
"""Preference file lives at XDG_CONFIG_HOME/fenris/preferences.json."""
def test_uses_xdg_config_home(self, tmp_path):
"""Path respects XDG_CONFIG_HOME environment variable."""
config_home = _make_prefs_dir(tmp_path)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
path = get_preference_path()
assert path == config_home / "fenris" / PREFERENCE_FILE_NAME
def test_default_path_fallback(self):
"""When XDG_CONFIG_HOME is unset, falls back to ~/.config."""
with patch.dict(os.environ, {}, clear=True):
# Remove XDG_CONFIG_HOME if present
os.environ.pop("XDG_CONFIG_HOME", None)
path = get_preference_path()
assert "fenris" in str(path)
assert PREFERENCE_FILE_NAME in str(path)
# ---------------------------------------------------------------------------
# Default preferences tests
# ---------------------------------------------------------------------------
class TestDefaults:
"""When no preference file exists, defaults are returned."""
def test_default_theme_is_amber(self):
"""Amber is the default theme preset."""
assert DEFAULT_THEME == "amber"
def test_default_reduced_motion_is_false(self):
"""Normal motion is the default."""
assert DEFAULT_REDUCED_MOTION is False
def test_valid_themes_are_all_presets(self):
"""Three valid presets exist."""
assert VALID_THEMES == {"amber", "nord", "high_contrast"}
def test_load_returns_defaults_when_no_file(self, tmp_path):
"""Missing preference file returns safe defaults."""
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(tmp_path)}):
prefs = load_preferences()
assert prefs["theme"] == "amber"
assert prefs["reduced_motion"] is False
# ---------------------------------------------------------------------------
# Save and load round-trip tests
# ---------------------------------------------------------------------------
class TestRoundTrip:
"""Preferences survive save → load."""
def test_save_and_load_basic(self, tmp_path):
"""Basic theme and motion save/load."""
config_home = _make_prefs_dir(tmp_path)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
save_preferences(theme="nord", reduced_motion=True)
prefs = load_preferences()
assert prefs["theme"] == "nord"
assert prefs["reduced_motion"] is True
def test_save_creates_directory(self, tmp_path):
"""Save creates the fenris config directory if missing."""
config_home = tmp_path / ".config"
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
save_preferences(theme="amber", reduced_motion=False)
assert (config_home / "fenris" / PREFERENCE_FILE_NAME).exists()
def test_overwrites_existing(self, tmp_path):
"""Second save overwrites the first."""
config_home = _make_prefs_dir(tmp_path)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
save_preferences(theme="amber", reduced_motion=False)
save_preferences(theme="high_contrast", reduced_motion=True)
prefs = load_preferences()
assert prefs["theme"] == "high_contrast"
assert prefs["reduced_motion"] is True
def test_all_themes_round_trip(self, tmp_path):
"""Every valid theme saves and loads correctly."""
config_home = _make_prefs_dir(tmp_path)
for theme in VALID_THEMES:
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
save_preferences(theme=theme, reduced_motion=False)
prefs = load_preferences()
assert prefs["theme"] == theme
# ---------------------------------------------------------------------------
# Safe failure tests — invalid/unreadable/unwritable
# ---------------------------------------------------------------------------
class TestSafeFailures:
"""Invalid data never crashes the dashboard."""
def test_corrupt_json_returns_defaults(self, tmp_path):
"""Malformed JSON returns safe defaults."""
config_home = _make_prefs_dir(tmp_path)
fenris_dir = config_home / "fenris"
fenris_dir.mkdir(parents=True, exist_ok=True)
(fenris_dir / PREFERENCE_FILE_NAME).write_text("{corrupt json!!")
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
prefs = load_preferences()
assert prefs["theme"] == "amber"
assert prefs["reduced_motion"] is False
def test_unknown_theme_returns_default(self, tmp_path):
"""Unrecognized theme value falls back to amber."""
config_home = _make_prefs_dir(tmp_path)
fenris_dir = config_home / "fenris"
fenris_dir.mkdir(parents=True, exist_ok=True)
(fenris_dir / PREFERENCE_FILE_NAME).write_text(
json.dumps({"theme": "neon-pink", "reduced_motion": False})
)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
prefs = load_preferences()
assert prefs["theme"] == "amber"
def test_missing_keys_get_defaults(self, tmp_path):
"""Partial preference file fills in missing keys."""
config_home = _make_prefs_dir(tmp_path)
fenris_dir = config_home / "fenris"
fenris_dir.mkdir(parents=True, exist_ok=True)
(fenris_dir / PREFERENCE_FILE_NAME).write_text(
json.dumps({"theme": "nord"})
)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
prefs = load_preferences()
assert prefs["theme"] == "nord"
assert prefs["reduced_motion"] is False
def test_unreadable_file_returns_defaults(self, tmp_path):
"""Permission denied on preference file returns defaults."""
config_home = _make_prefs_dir(tmp_path)
fenris_dir = config_home / "fenris"
fenris_dir.mkdir(parents=True, exist_ok=True)
pref_file = fenris_dir / PREFERENCE_FILE_NAME
pref_file.write_text(json.dumps({"theme": "nord"}))
pref_file.chmod(0o000)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
prefs = load_preferences()
# Should fall back to defaults without crashing
assert prefs["theme"] in VALID_THEMES
def test_unwritable_location_returns_defaults(self, tmp_path):
"""Read-only config directory returns defaults without crashing."""
config_home = _make_prefs_dir(tmp_path)
fenris_dir = config_home / "fenris"
fenris_dir.mkdir(parents=True, exist_ok=True)
fenris_dir.chmod(0o555)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
# Should not raise
save_preferences(theme="nord", reduced_motion=True)
prefs = load_preferences()
# Either saved successfully or fell back — either way, no crash
assert prefs["theme"] in VALID_THEMES
def test_non_json_file_returns_defaults(self, tmp_path):
"""A file that isn't JSON returns defaults."""
config_home = _make_prefs_dir(tmp_path)
fenris_dir = config_home / "fenris"
fenris_dir.mkdir(parents=True, exist_ok=True)
(fenris_dir / PREFERENCE_FILE_NAME).write_text("this is not json")
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
prefs = load_preferences()
assert prefs["theme"] == "amber"
def test_wrong_type_for_reduced_motion(self, tmp_path):
"""Non-boolean reduced_motion falls back to default."""
config_home = _make_prefs_dir(tmp_path)
fenris_dir = config_home / "fenris"
fenris_dir.mkdir(parents=True, exist_ok=True)
(fenris_dir / PREFERENCE_FILE_NAME).write_text(
json.dumps({"theme": "amber", "reduced_motion": "yes"})
)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
prefs = load_preferences()
assert prefs["reduced_motion"] is False
# ---------------------------------------------------------------------------
# CLI/collector isolation tests
# ---------------------------------------------------------------------------
class TestCLIIsolation:
"""TUI display preferences do not affect CLI status or collector."""
def test_cli_status_ignores_preferences(self, tmp_path):
"""fenris status output is independent of TUI preferences."""
from fenris.status import get_status
# Create a valid store with data
from fenris.store import init_store
conn = init_store(tmp_path / "test.db")
conn.execute(
"INSERT INTO samples (ts, device, data_units_written, data_units_read, "
"percentage_used, bytes_written, bytes_read, power_on_hours) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
("2026-09-30T10:00:00+00:00", "/dev/nvme0n1", 1000000, 500000,
5, 512000000000, 256000000000, 8765),
)
conn.commit()
conn.close()
# Save non-default preferences
config_home = _make_prefs_dir(tmp_path)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
save_preferences(theme="high_contrast", reduced_motion=True)
status_text = get_status(
store_path=tmp_path / "test.db",
clock_now=datetime(2026, 9, 30, 12, 0, 0, tzinfo=timezone.utc),
query_services=False,
query_journal=False,
)
# Status output should NOT contain theme names or motion settings
assert "high_contrast" not in status_text.lower()
assert "reduced_motion" not in status_text.lower()
assert "amber" not in status_text.lower()
def test_preferences_do_not_alter_store(self, tmp_path):
"""Saving preferences never writes to the observation store."""
import sqlite3
config_home = _make_prefs_dir(tmp_path)
store_path = tmp_path / "observations.db"
from fenris.store import init_store
conn = init_store(store_path)
# Record store state before preference save
cursor = conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
tables_before = sorted(r[0] for r in cursor.fetchall())
conn.close()
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
save_preferences(theme="nord", reduced_motion=True)
# Store schema should be unchanged
conn = sqlite3.connect(store_path)
cursor = conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
tables_after = sorted(r[0] for r in cursor.fetchall())
conn.close()
assert tables_before == tables_after
# Need datetime for CLI isolation test
from datetime import datetime, timezone
+1 -124
View File
@@ -22,7 +22,7 @@ from fenris.monitoring_periods import ensure_period_open, close_period
from fenris.projection import (
compute_projection, ConfidenceState, BaselineTier, ScenarioRange,
TBW_TO_BYTES, HORIZON_DAYS, WARMING_MIN_DAYS, STALENESS_HOURS,
YOUNG_REGIME_DAYS, DISCLOSURES, _compute_horizon_rate,
YOUNG_REGIME_DAYS, DISCLOSURES,
)
@@ -899,126 +899,3 @@ class TestIdentityChangeBlankKeys:
# All 25 days in same segment (equal blanks continue)
assert result.regime_days is not None
assert result.regime_days >= 20 # Most of the history
# ===========================================================================
# Issue #76: Evidence-anchored projection rates
# Scenario windows anchored at latest evidence endpoint T
# ===========================================================================
class TestEvidenceAnchoredHorizons:
"""Issue #76: Scenario windows anchored at latest published usage-evidence
endpoint T with exact trailing 7/28/90×86400-second starts."""
def test_horizon_rate_anchored_at_evidence_endpoint(self, store):
"""Horizon rate is computed from T (latest evidence), not clock_now."""
_insert_baseline(store, tbw_tb=10.0, verified=True)
_insert_segment(store, opened_at="2026-09-01T00:00:00+00:00")
_open_period(store, start="2026-09-01T00:00:00+00:00")
bw = 100 * 1024 * 1024
# 30 days of data ending Sep 29
for i in range(30):
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, d, bw=bw)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
# Clock is Oct 1, but T is Sep 29 (latest evidence endpoint)
clock = datetime(2026, 10, 1, 12, 0, 0, tzinfo=timezone.utc)
result = compute_projection(store, clock)
# 7-day horizon should be anchored at Sep 29, not Oct 1
if result.scenario_range and 7 in result.scenario_range.rates:
# Rate should be based on Sep 23-29, not Sep 25-Oct 1
assert result.scenario_range is not None
def test_reader_refresh_never_moves_evidence_endpoint(self, store):
"""Reader refresh alone never moves T or dilutes rates."""
_insert_baseline(store, tbw_tb=10.0, verified=True)
_insert_segment(store, opened_at="2026-09-01T00:00:00+00:00")
_open_period(store, start="2026-09-01T00:00:00+00:00")
bw = 100 * 1024 * 1024
for i in range(30):
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, d, bw=bw)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
# Two reads at different clock times
clock1 = datetime(2026, 9, 30, 12, 0, 0, tzinfo=timezone.utc)
clock2 = datetime(2026, 10, 1, 12, 0, 0, tzinfo=timezone.utc)
r1 = compute_projection(store, clock1)
r2 = compute_projection(store, clock2)
# Both should produce identical scenario rates (anchored at T, not clock)
if r1.scenario_range and r2.scenario_range:
assert r1.scenario_range.rates == r2.scenario_range.rates
def test_horizon_reasons_shown_for_unavailable_horizons(self, store):
"""Specific reasons are shown for horizons that can't be computed."""
_insert_baseline(store, tbw_tb=10.0, verified=True)
_insert_segment(store, opened_at="2026-09-20T00:00:00+00:00")
_open_period(store, start="2026-09-20T00:00:00+00:00")
bw = 100 * 1024 * 1024
# Only 10 days of data
for i in range(10):
d = (datetime(2026, 9, 20) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, d, bw=bw)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
result = compute_projection(store, _clock())
# 7-day horizon should be available, 28 and 90 should have reasons
if result.scenario_range:
assert 7 in result.scenario_range.rates
if 28 in result.scenario_range.horizon_reasons:
assert "starts before earliest data" in result.scenario_range.horizon_reasons[28]
if 90 in result.scenario_range.horizon_reasons:
assert "starts before earliest data" in result.scenario_range.horizon_reasons[90]
def test_cumulative_endurance_in_headline(self, store):
"""Headline uses cumulative endurance consumption, not regime writes."""
_insert_baseline(store, tbw_tb=10.0, verified=True)
_insert_segment(store, opened_at="2026-09-01T00:00:00+00:00")
_open_period(store, start="2026-09-01T00:00:00+00:00")
bw = 100 * 1024 * 1024
for i in range(30):
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, d, bw=bw)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
result = compute_projection(store, _clock())
# Headline should be computed with cumulative bytes
if result.headline_remaining_seconds is not None:
# E_baseline = 10 TB = 10e12 bytes
# cumulative_bytes = 30 * 100 * 1024 * 1024
# rate = cumulative_bytes / wall_clock
# headline = (E_baseline - cumulative_bytes) / rate
E_baseline = 10.0 * TBW_TO_BYTES
cumulative_bytes = 30 * bw
assert result.headline_remaining_seconds >= 0
def test_zero_boundary_delta_returns_zero(self, store):
"""Zero monotonic delta proves zero over its represented subspan."""
_insert_baseline(store, tbw_tb=10.0, verified=True)
_insert_segment(store, opened_at="2026-09-01T00:00:00+00:00")
_open_period(store, start="2026-09-01T00:00:00+00:00")
# Days with zero bytes written
for i in range(30):
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, d, bw=0)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
result = compute_projection(store, _clock())
# Zero rate should result in UNSUPPORTED
assert result.confidence_state == ConfidenceState.UNSUPPORTED
assert result.headline_remaining_seconds is None
def test_noon_endpoint_same_as_midnight(self, store):
"""Noon endpoint produces same rates as midnight endpoint."""
_insert_baseline(store, tbw_tb=10.0, verified=True)
_insert_segment(store, opened_at="2026-09-01T00:00:00+00:00")
_open_period(store, start="2026-09-01T00:00:00+00:00")
bw = 100 * 1024 * 1024
for i in range(30):
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, d, bw=bw)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
# Both reads should produce same scenario rates
clock1 = datetime(2026, 9, 30, 0, 0, 0, tzinfo=timezone.utc)
clock2 = datetime(2026, 9, 30, 12, 0, 0, tzinfo=timezone.utc)
r1 = compute_projection(store, clock1)
r2 = compute_projection(store, clock2)
if r1.scenario_range and r2.scenario_range:
assert r1.scenario_range.rates == r2.scenario_range.rates
+3 -54
View File
@@ -1,9 +1,7 @@
"""Raw sample pruning tests.
Spec §3.4, ST-5: Raw samples pruned to 14 days; hour observations and
day aggregates are retained indefinitely.
Issue #74: Boundary anchors required for successor evidence are retained.
day aggregates retained indefinitely.
"""
import sqlite3
import sys
@@ -53,9 +51,6 @@ class TestPruneOldSamples:
def test_removes_old_samples(self, store_conn):
now = datetime(2026, 9, 15, 12, 0, 0, tzinfo=timezone.utc)
# Insert samples at 10, 14, and 15 days ago
# All three are before the cutoff (2026-09-01T12:00:00)
# The 15-day-old sample is not a boundary anchor because
# the next sample (14 days ago) is also before the cutoff
for days_ago in [10, 14, 15]:
ts = (now - timedelta(days=days_ago)).isoformat()
_insert_sample(store_conn, ts)
@@ -87,7 +82,6 @@ class TestPruneOldSamples:
"""Hour observations are retained indefinitely."""
now = datetime(2026, 9, 15, 12, 0, 0, tzinfo=timezone.utc)
# Insert an old sample and a recent sample
# The old sample is a boundary anchor (needed for derivation)
_insert_sample(store_conn, (now - timedelta(days=20)).isoformat())
_insert_sample(store_conn, (now - timedelta(days=1)).isoformat())
@@ -101,55 +95,10 @@ class TestPruneOldSamples:
prune_old_samples(store_conn, now, retention_days=14)
# Old sample is retained as boundary anchor (needed for derivation)
# Sample removed
cursor = store_conn.execute("SELECT COUNT(*) FROM samples")
assert cursor.fetchone()[0] == 2
assert cursor.fetchone()[0] == 1
# Hour observation retained
cursor = store_conn.execute("SELECT COUNT(*) FROM hour_observations")
assert cursor.fetchone()[0] == 1
def test_boundary_anchor_retained(self, store_conn):
"""Boundary anchors required for derivation are retained."""
now = datetime(2026, 9, 30, 12, 0, 0, tzinfo=timezone.utc)
# Old sample before boundary (2026-09-14T23:55:00)
# is 15 days and 0.75 hours old (before cutoff at 2026-09-16T12:00:00)
_insert_sample(store_conn, "2026-09-14T23:55:00+00:00", 1000000)
# Sample after boundary (2026-09-16T12:30:00)
# is 13 days and 23.5 hours old (within retention)
_insert_sample(store_conn, "2026-09-16T12:30:00+00:00", 2000000)
# Run pruning
pruned = prune_old_samples(store_conn, now, retention_days=14)
# The boundary anchor should be retained
cursor = store_conn.execute(
"SELECT COUNT(*) FROM samples WHERE ts = '2026-09-14T23:55:00+00:00'"
)
assert cursor.fetchone()[0] == 1
def test_old_sample_with_derived_interval_removed(self, store_conn):
"""Old samples with fully derived intervals are removed."""
now = datetime(2026, 9, 30, 12, 0, 0, tzinfo=timezone.utc)
# Old sample with derived interval
_insert_sample(store_conn, "2026-09-10T10:00:00+00:00", 1000000)
_insert_sample(store_conn, "2026-09-10T10:30:00+00:00", 2000000)
# Hour observation exists for the interval
store_conn.execute(
"INSERT INTO hour_observations (hour, active_seconds, bytes_written_delta, bytes_read_delta, sample_count, coverage) "
"VALUES ('2026-09-10T10:00:00+00:00', 3600, 1000000, 0, 1, 1.0)",
)
store_conn.commit()
# Run pruning
pruned = prune_old_samples(store_conn, now, retention_days=14)
# Old sample should be removed (interval is derived)
cursor = store_conn.execute(
"SELECT COUNT(*) FROM samples WHERE ts = '2026-09-10T10:00:00+00:00'"
)
assert cursor.fetchone()[0] == 0
-398
View File
@@ -1,398 +0,0 @@
"""Repair and retention tests (issue #74).
Tests the idempotent, safe repair of hour observations and day aggregates
from surviving raw samples, boundary anchor retention, and legacy summary
handling at actual precision.
"""
import sqlite3
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from fenris.store import init_store
from fenris.repair import (
repair_derivation,
is_repair_in_progress,
get_repair_status,
)
from fenris.pruning import prune_old_samples, needs_boundary_anchor
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def store_conn(tmp_path: Path):
"""Create a fresh store for each test."""
db_path = tmp_path / "test.db"
conn = init_store(db_path)
yield conn
conn.close()
def _insert_sample(conn, ts_iso, bytes_written, bytes_read=0, power_on_hours=100,
device="/dev/nvme0", segment_id=None):
"""Insert a raw sample."""
conn.execute(
"""INSERT INTO samples
(ts, device, bytes_written, bytes_read, power_on_hours,
data_units_written, data_units_read, segment_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(ts_iso, device, bytes_written, bytes_read, power_on_hours,
bytes_written // 512000, bytes_read // 512000, segment_id),
)
conn.commit()
def _insert_hour(conn, hour_iso, bytes_written_delta=0, active_seconds=3600,
idle_seconds=0, powered_off_seconds=0, unknown_seconds=0,
sample_count=1, coverage=1.0):
"""Insert an hour observation."""
conn.execute(
"""INSERT INTO hour_observations
(hour, active_seconds, idle_seconds, powered_off_seconds, unknown_seconds,
bytes_written_delta, bytes_read_delta, sample_count, coverage)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(hour_iso, active_seconds, idle_seconds, powered_off_seconds, unknown_seconds,
bytes_written_delta, 0, sample_count, coverage),
)
conn.commit()
def _insert_day_aggregate(conn, day, bytes_written_delta=0, coverage=1.0):
"""Insert a day aggregate."""
conn.execute(
"""INSERT INTO day_aggregates
(day, active_seconds, bytes_written_delta, coverage, sample_count)
VALUES (?, 3600, ?, ?, 1)""",
(day, bytes_written_delta, coverage),
)
conn.commit()
def _open_period(conn, start_iso, end_iso=None, end_cause=None):
"""Insert a monitoring period."""
conn.execute(
"""INSERT INTO monitoring_periods (started_at, ended_at, end_cause)
VALUES (?, ?, ?)""",
(start_iso, end_iso, end_cause),
)
conn.commit()
# ---------------------------------------------------------------------------
# AC1: Normal collection, legacy import and recovery use same evidence rules
# ---------------------------------------------------------------------------
class TestRepairUsesSameEvidenceRules:
"""AC1: Repair derives only surviving supported evidence, transactionally
and idempotently."""
def test_repair_idempotent_on_empty_store(self, store_conn):
"""Repair on empty store succeeds and does nothing."""
result = repair_derivation(store_conn)
assert result.ok is True
assert result.hours_created == 0
assert result.days_created == 0
def test_repair_idempotent_on_fully_derived(self, store_conn):
"""Repair on store with existing derived data does not duplicate."""
now = datetime(2026, 9, 15, 12, 0, 0, tzinfo=timezone.utc)
_open_period(store_conn, "2026-09-14T00:00:00+00:00")
# Insert samples that span an hour
_insert_sample(store_conn, "2026-09-14T10:00:00+00:00", 1000000)
_insert_sample(store_conn, "2026-09-14T10:30:00+00:00", 2000000)
# Pre-existing hour observation for the 10:00 hour
# This hour observation captures the interval [10:00, 10:30]
_insert_hour(store_conn, "2026-09-14T10:00:00+00:00",
bytes_written_delta=1000000)
_insert_day_aggregate(store_conn, "2026-09-14",
bytes_written_delta=1000000)
# Run repair
result = repair_derivation(store_conn)
# Should not create new observations (already exist)
assert result.hours_created == 0
assert result.days_created == 0
# Verify no duplicates
cursor = store_conn.execute(
"SELECT COUNT(*) FROM hour_observations WHERE hour = '2026-09-14T10:00:00+00:00'"
)
assert cursor.fetchone()[0] == 1
def test_repair_preserves_import_markers(self, store_conn):
"""Repair does not remove legacy import markers."""
# Set legacy import marker
store_conn.execute(
"INSERT INTO store_metadata (key, value) VALUES ('legacy_imported', 'true')"
)
store_conn.commit()
# Run repair
result = repair_derivation(store_conn)
# Verify marker preserved
cursor = store_conn.execute(
"SELECT value FROM store_metadata WHERE key = 'legacy_imported'"
)
assert cursor.fetchone()[0] == "true"
# ---------------------------------------------------------------------------
# AC2: Rerunning or interrupting repair produces no duplicates
# ---------------------------------------------------------------------------
class TestRepairIdempotency:
"""AC2: No duplicated intervals or totals from repeated repair."""
def test_repair_no_duplicate_hours(self, store_conn):
"""Running repair twice produces no duplicate hour observations."""
_open_period(store_conn, "2026-09-14T00:00:00+00:00")
_insert_sample(store_conn, "2026-09-14T10:00:00+00:00", 1000000)
_insert_sample(store_conn, "2026-09-14T10:30:00+00:00", 2000000)
# First repair
result1 = repair_derivation(store_conn)
assert result1.hours_created == 1
# Second repair
result2 = repair_derivation(store_conn)
assert result2.hours_created == 0 # No new hours
# Verify only one hour observation
cursor = store_conn.execute("SELECT COUNT(*) FROM hour_observations")
assert cursor.fetchone()[0] == 1
def test_repair_no_duplicate_days(self, store_conn):
"""Running repair twice produces no duplicate day aggregates."""
_open_period(store_conn, "2026-09-14T00:00:00+00:00")
_insert_sample(store_conn, "2026-09-14T10:00:00+00:00", 1000000)
_insert_sample(store_conn, "2026-09-14T10:30:00+00:00", 2000000)
# First repair
result1 = repair_derivation(store_conn)
assert result1.days_created == 1
# Second repair
result2 = repair_derivation(store_conn)
assert result2.days_created == 0 # No new days
# Verify only one day aggregate
cursor = store_conn.execute("SELECT COUNT(*) FROM day_aggregates")
assert cursor.fetchone()[0] == 1
def test_interrupted_repair_preserves_evidence(self, store_conn):
"""If repair fails, prior valid history is preserved."""
_open_period(store_conn, "2026-09-14T00:00:00+00:00")
# Insert valid existing data
_insert_hour(store_conn, "2026-09-14T10:00:00+00:00",
bytes_written_delta=500000)
_insert_day_aggregate(store_conn, "2026-09-14",
bytes_written_delta=500000)
# Run repair (should succeed but not modify existing valid data)
result = repair_derivation(store_conn)
assert result.ok is True
# Verify existing data preserved
cursor = store_conn.execute(
"SELECT bytes_written_delta FROM hour_observations "
"WHERE hour = '2026-09-14T10:00:00+00:00'"
)
assert cursor.fetchone()[0] == 500000
# ---------------------------------------------------------------------------
# AC3: Boundary anchor retention
# ---------------------------------------------------------------------------
class TestBoundaryAnchorRetention:
"""AC3: Prune samples only after durable derivation; retain anchors."""
def test_needs_boundary_anchor_sample(self, store_conn):
"""Sample before 14-day boundary is needed for derivation."""
now = datetime(2026, 9, 30, 12, 0, 0, tzinfo=timezone.utc)
# Sample just before 14-day boundary (2026-09-15T23:55:00)
# is 14 days and 0.75 hours old (before cutoff at 2026-09-16T12:00:00)
_insert_sample(store_conn, "2026-09-15T23:55:00+00:00", 1000000)
# Sample just after boundary (2026-09-16T12:30:00)
# is 13 days and 23.5 hours old (within retention)
_insert_sample(store_conn, "2026-09-16T12:30:00+00:00", 2000000)
# The sample at 2026-09-15 is a boundary anchor because
# the interval spans the retention boundary
assert needs_boundary_anchor(store_conn, "2026-09-15T23:55:00+00:00", now)
def test_not_boundary_anchor_if_fully_derived(self, store_conn):
"""Sample that's fully derived is not a boundary anchor."""
now = datetime(2026, 9, 30, 12, 0, 0, tzinfo=timezone.utc)
# Insert sample and fully derive its interval
_insert_sample(store_conn, "2026-09-14T10:00:00+00:00", 1000000)
_insert_sample(store_conn, "2026-09-14T10:30:00+00:00", 2000000)
# Hour observation already exists for this interval
_insert_hour(store_conn, "2026-09-14T10:00:00+00:00",
bytes_written_delta=1000000)
# Not a boundary anchor
assert not needs_boundary_anchor(store_conn, "2026-09-14T10:00:00+00:00", now)
def test_pruning_retains_boundary_anchors(self, store_conn):
"""Pruning keeps samples needed as boundary anchors."""
now = datetime(2026, 9, 30, 12, 0, 0, tzinfo=timezone.utc)
# Old sample before boundary (2026-09-14T23:55:00)
# is 15 days and 0.75 hours old (before cutoff at 2026-09-16T12:00:00)
_insert_sample(store_conn, "2026-09-14T23:55:00+00:00", 1000000)
# Sample after boundary (2026-09-16T12:30:00)
# is 13 days and 23.5 hours old (within retention)
_insert_sample(store_conn, "2026-09-16T12:30:00+00:00", 2000000)
# Recent sample
_insert_sample(store_conn, "2026-09-29T12:00:00+00:00", 3000000)
# Run pruning
pruned = prune_old_samples(store_conn, now, retention_days=14)
# The boundary anchor should be retained
cursor = store_conn.execute(
"SELECT COUNT(*) FROM samples WHERE ts = '2026-09-14T23:55:00+00:00'"
)
assert cursor.fetchone()[0] == 1
def test_pruning_removes_old_sample_with_derived_interval(self, store_conn):
"""Pruning removes old samples when interval is fully derived."""
now = datetime(2026, 9, 30, 12, 0, 0, tzinfo=timezone.utc)
# Old sample with derived interval
_insert_sample(store_conn, "2026-09-10T10:00:00+00:00", 1000000)
_insert_sample(store_conn, "2026-09-10T10:30:00+00:00", 2000000)
# Hour observation exists for the interval
_insert_hour(store_conn, "2026-09-10T10:00:00+00:00",
bytes_written_delta=1000000)
# Run pruning
pruned = prune_old_samples(store_conn, now, retention_days=14)
# Old sample should be removed (interval is derived)
cursor = store_conn.execute(
"SELECT COUNT(*) FROM samples WHERE ts = '2026-09-10T10:00:00+00:00'"
)
assert cursor.fetchone()[0] == 0
# ---------------------------------------------------------------------------
# AC4: Legacy day-only summaries retain actual precision
# ---------------------------------------------------------------------------
class TestLegacySummaryPrecision:
"""AC4: Legacy summaries at actual precision, no interpolation."""
def test_legacy_summary_not_reconstructed(self, store_conn):
"""Legacy day-only summaries are not interpolated to hour detail."""
# Insert a legacy-style day aggregate without hour observations
_insert_day_aggregate(store_conn, "2026-08-01",
bytes_written_delta=5000000)
# Run repair
result = repair_derivation(store_conn)
# Should not create hour observations for legacy day
cursor = store_conn.execute(
"SELECT COUNT(*) FROM hour_observations WHERE hour LIKE '2026-08-01%'"
)
assert cursor.fetchone()[0] == 0
def test_legacy_summary_no_double_counting(self, store_conn):
"""Legacy summaries and derived intervals don't double-count."""
# Insert legacy day aggregate
_insert_day_aggregate(store_conn, "2026-08-01",
bytes_written_delta=5000000)
# Run repair
result = repair_derivation(store_conn)
# Day aggregate should not be modified
cursor = store_conn.execute(
"SELECT bytes_written_delta FROM day_aggregates WHERE day = '2026-08-01'"
)
assert cursor.fetchone()[0] == 5000000
# ---------------------------------------------------------------------------
# AC5: Status distinguishes evidence states
# ---------------------------------------------------------------------------
class TestStatusEvidenceDistingushing:
"""AC5: Read-only views distinguish evidence states."""
def test_repair_status_available(self, store_conn):
"""Repair status is available for read-only views."""
status = get_repair_status(store_conn)
assert hasattr(status, 'last_repair')
assert hasattr(status, 'repair_in_progress')
assert hasattr(status, 'hours_derived')
assert hasattr(status, 'days_derived')
def test_repair_in_progress_flag(self, store_conn):
"""Repair in progress flag is trackable."""
assert not is_repair_in_progress(store_conn)
# ---------------------------------------------------------------------------
# AC6: Migration then collection then reader consumption
# ---------------------------------------------------------------------------
class TestMigrationCollectionReader:
"""AC6: End-to-end migration, collection, and reader consumption."""
def test_store_with_raw_evidence_and_summaries(self, store_conn):
"""Store with raw evidence and old summaries works correctly."""
# Set up store with mixed data
_open_period(store_conn, "2026-08-01T00:00:00+00:00")
# Old day-only summary (legacy)
_insert_day_aggregate(store_conn, "2026-08-01",
bytes_written_delta=5000000)
# Recent raw samples
_insert_sample(store_conn, "2026-09-14T10:00:00+00:00", 1000000)
_insert_sample(store_conn, "2026-09-14T10:30:00+00:00", 2000000)
# Run repair
result = repair_derivation(store_conn)
assert result.ok is True
# Verify legacy summary preserved
cursor = store_conn.execute(
"SELECT bytes_written_delta FROM day_aggregates WHERE day = '2026-08-01'"
)
assert cursor.fetchone()[0] == 5000000
# Verify new hour observation created
cursor = store_conn.execute(
"SELECT COUNT(*) FROM hour_observations WHERE hour LIKE '2026-09-14%'"
)
assert cursor.fetchone()[0] == 1
def test_concurrent_reader_consistency(self, store_conn):
"""Reader sees consistent snapshot during repair."""
# This is more of a documentation test - SQLite WAL mode handles this
# We verify the store is in WAL mode
cursor = store_conn.execute("PRAGMA journal_mode")
assert cursor.fetchone()[0] == "wal"
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -118,12 +118,12 @@ class TestMigrateToLatest:
assert migrate_to_latest(db) == 0
def test_migrates_intermediate_version(self, tmp_path):
"""Store at version SCHEMA_VERSION-1 → 1 step to current."""
from fenris.store import SCHEMA_VERSION
"""Store at version 1 with SCHEMA_VERSION=1 → 0 steps (current)."""
db = tmp_path / "observations.db"
_make_store(db, version=SCHEMA_VERSION - 1)
_make_store(db, version=1)
# SCHEMA_VERSION is 1, so version 1 is current
steps = migrate_to_latest(db)
assert steps == 1
assert steps == 0
# ---------------------------------------------------------------------------
-186
View File
@@ -1,186 +0,0 @@
"""Tests for Fenris theme presets (issue #80).
Covers:
- Three valid presets: Amber, Nord, High Contrast
- Amber is the default with amber graph role
- Theme roles for graph rendering (allocated, unallocated, gap, zero, partial)
- Status semantic colours/glyphs/text always win over theme
- Global action reachability and focus contrast in every preset
"""
import pytest
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from fenris.themes import (
THEMES,
THEME_NAMES,
get_theme,
get_graph_colors,
STATUS_COLORS,
)
# ---------------------------------------------------------------------------
# Theme registry tests
# ---------------------------------------------------------------------------
class TestThemeRegistry:
"""All three presets are registered with correct names."""
def test_three_themes_registered(self):
"""Exactly three themes exist."""
assert len(THEMES) == 3
def test_theme_names(self):
"""Theme names are amber, nord, high_contrast."""
assert THEME_NAMES == {"amber", "nord", "high_contrast"}
def test_get_theme_valid(self):
"""get_theme returns a Theme for each valid name."""
from textual.theme import Theme
for name in THEME_NAMES:
theme = get_theme(name)
assert isinstance(theme, Theme)
def test_get_theme_invalid_returns_amber(self):
"""Unknown theme name returns the amber theme."""
theme = get_theme("nonexistent")
assert theme.name == "fenris-amber"
# ---------------------------------------------------------------------------
# Amber theme tests (default, amber graph role)
# ---------------------------------------------------------------------------
class TestAmberTheme:
"""Amber is the default theme with warm tones."""
def test_amber_is_dark(self):
"""Amber is a dark theme."""
theme = get_theme("amber")
assert theme.dark is True
def test_amber_primary_is_amber(self):
"""Primary colour is warm amber."""
theme = get_theme("amber")
assert "d4a017" in theme.primary.lower() or "amber" in theme.primary.lower()
def test_amber_has_graph_colors(self):
"""Amber defines all required graph role variables."""
colors = get_graph_colors("amber")
assert "allocated" in colors
assert "unallocated" in colors
assert "gap" in colors
assert "zero" in colors
assert "partial" in colors
# ---------------------------------------------------------------------------
# Nord theme tests
# ---------------------------------------------------------------------------
class TestNordTheme:
"""Nord uses the polar night palette."""
def test_nord_is_dark(self):
"""Nord is a dark theme."""
theme = get_theme("nord")
assert theme.dark is True
def test_nord_has_graph_colors(self):
"""Nord defines all required graph role variables."""
colors = get_graph_colors("nord")
assert "allocated" in colors
assert "unallocated" in colors
assert "gap" in colors
assert "zero" in colors
assert "partial" in colors
# ---------------------------------------------------------------------------
# High Contrast theme tests
# ---------------------------------------------------------------------------
class TestHighContrastTheme:
"""High Contrast for maximum readability."""
def test_high_contrast_is_dark(self):
"""High contrast is a dark theme."""
theme = get_theme("high_contrast")
assert theme.dark is True
def test_high_contrast_foreground_is_white(self):
"""Foreground is pure white for maximum contrast."""
theme = get_theme("high_contract") if False else get_theme("high_contrast")
assert theme.foreground is not None
def test_high_contrast_has_graph_colors(self):
"""High contrast defines all required graph role variables."""
colors = get_graph_colors("high_contrast")
assert "allocated" in colors
assert "unallocated" in colors
assert "gap" in colors
assert "zero" in colors
assert "partial" in colors
# ---------------------------------------------------------------------------
# Status semantic colours override theme (AC80-1)
# ---------------------------------------------------------------------------
class TestStatusSemanticOverride:
"""Status semantic colours/glyphs/text always win over theme styling."""
def test_status_colors_defined(self):
"""STATUS_COLORS maps each StatusState to a fixed colour."""
from fenris.status_composition import StatusState
for state in StatusState:
assert state.value in STATUS_COLORS
def test_status_colors_are_not_theme_dependent(self):
"""Status colours are the same regardless of theme."""
from fenris.status_composition import StatusState
# These are the canonical status colours — they must not change with theme
assert STATUS_COLORS[StatusState.MONITORING.value] == "green"
assert STATUS_COLORS[StatusState.ERROR.value] == "red"
assert STATUS_COLORS[StatusState.PAUSED.value] == "yellow"
assert STATUS_COLORS[StatusState.INTERRUPTED.value] == "red"
assert STATUS_COLORS[StatusState.STALE.value] == "red"
assert STATUS_COLORS[StatusState.WAITING.value] == "yellow"
assert STATUS_COLORS[StatusState.UNKNOWN.value] == "dim"
assert STATUS_COLORS[StatusState.COLLECTING.value] == "green"
# ---------------------------------------------------------------------------
# Graph colour role tests (AC80-5)
# ---------------------------------------------------------------------------
class TestGraphColorRoles:
"""Theme roles for graph rendering expose distinct colours per preset."""
def test_all_themes_define_same_roles(self):
"""Every theme has the same set of graph colour roles."""
roles = None
for name in THEME_NAMES:
colors = get_graph_colors(name)
if roles is None:
roles = set(colors.keys())
else:
assert set(colors.keys()) == roles
def test_graph_roles_are_distinct_across_presets(self):
"""Different themes produce different graph colour values."""
amber = get_graph_colors("amber")
nord = get_graph_colors("nord")
hc = get_graph_colors("high_contrast")
# At least one role should differ between themes
assert amber["allocated"] != nord["allocated"] or amber["allocated"] != hc["allocated"]
def test_zero_role_is_dim(self):
"""Zero-usage bars use a dim/subtle colour in all themes."""
for name in THEME_NAMES:
colors = get_graph_colors(name)
# Zero should be distinct from allocated
assert colors["zero"] != colors["allocated"]
+6 -703
View File
@@ -40,25 +40,12 @@ from fenris.status import (
)
from fenris.tui import (
FenrisTuiApp,
DailyBarGraph,
_format_remaining,
_sparkline,
_habit_bar,
_query_usage_history,
_query_drive_health,
_query_service_facts,
_query_daily_graph_data,
_query_hourly_graph_data,
_RANGE_OPTIONS,
_RANGE_DEFAULT,
_BAR_HEIGHT,
_GLYPH_ALLOCATED,
_GLYPH_UNALLOCATED,
_GLYPH_GAP,
_GLYPH_ZERO,
_GLYPH_PARTIAL,
_MIN_WIDTH,
_MIN_HEIGHT,
)
@@ -381,10 +368,7 @@ class TestDenseScreen:
@pytest.mark.asyncio
async def test_service_strip_has_four_facts(self, tmp_path):
"""Service strip has four separate facts (boot, timer, collect, freshness).
Maker credit removed from service strip (issue #79: single placement in title).
"""
"""Service strip has four separate facts (boot, timer, collect, freshness)."""
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
_open_period(conn)
@@ -401,8 +385,7 @@ class TestDenseScreen:
assert "timer:" in strip
assert "last collect:" in strip
assert "freshness:" in strip
# Maker credit now in titlebox only (issue #79)
assert "by Bongbetic" not in strip
assert "by Bongbetic" in strip
@pytest.mark.asyncio
async def test_service_strip_shows_continuity_and_separate_quit_rail(self, tmp_path):
@@ -425,7 +408,7 @@ class TestDenseScreen:
quit_rail = app.query_one("#quit-rail")
assert "continuity" in strip
assert "monitoring: active in background · persists across reboots" in strip
assert "p pause · r resume · c collect · t theme · m motion · d disclosures" in strip
assert "p pause · r resume · c collect · d disclosures" in strip
assert "q quit" not in strip
assert rail == "q QUIT TUI"
assert usage.region.y < service.region.y < quit_rail.region.y
@@ -531,10 +514,7 @@ class TestDenseScreen:
@pytest.mark.asyncio
async def test_branding_and_one_time_auth_banner(self, tmp_path):
"""Identity is visible at launch; auth notice clears once per session.
Maker credit in titlebox only (issue #79: Fenris by Bongbetic).
"""
"""Identity is visible at launch; auth notice clears once per session."""
app = FenrisTuiApp(
store_path=tmp_path / "nonexistent.db",
refresh_interval_s=0.2,
@@ -543,11 +523,9 @@ class TestDenseScreen:
async with app.run_test() as pilot:
headline = str(app.query_one("#headline-band").render())
# Identity now shows Fenris by Bongbetic (issue #79)
assert "Fenris by Bongbetic" in headline
assert "Fenris — NVMe endurance monitor" in headline
assert auth_notice in headline
# Maker credit removed from service strip (issue #79)
assert "by Bongbetic" not in str(app.query_one("#service-strip").render())
assert "by Bongbetic" in str(app.query_one("#service-strip").render())
await pilot.pause(0.25)
assert auth_notice not in str(app.query_one("#headline-band").render())
@@ -701,678 +679,3 @@ class TestStateMatrixCombinations:
# Incomplete provenance → UNVERIFIED tier
assert proj.baseline_tier.value == "unverified_override"
conn.close()
# ---------------------------------------------------------------------------
# Hour observation helper for graph tests
# ---------------------------------------------------------------------------
def _insert_hour(conn, hour, bw=0, active=3600, idle=0, powered_off=0,
unknown=0, coverage=1.0, samples=1):
"""Insert an hour observation row."""
conn.execute(
"INSERT INTO hour_observations "
"(hour, active_seconds, idle_seconds, powered_off_seconds, unknown_seconds, "
" bytes_written_delta, bytes_read_delta, sample_count, coverage) "
"VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?)",
(hour, active, idle, powered_off, unknown, bw, samples, coverage),
)
conn.commit()
def _insert_day_with_unattributed(conn, day, bw=0, unattributed=0,
coverage=0.95, samples=24):
"""Insert a day aggregate with explicit unattributed bytes."""
conn.execute(
"INSERT INTO day_aggregates "
"(day, active_seconds, idle_seconds, powered_off_seconds, unknown_seconds, "
" bytes_written_delta, bytes_read_delta, sample_count, coverage, "
" unattributed_bytes_written, unattributed_bytes_read) "
"VALUES (?, 3600, 0, 0, 0, ?, 0, ?, ?, ?, 0)",
(day, bw, samples, coverage, unattributed),
)
conn.commit()
# ---------------------------------------------------------------------------
# Graph data query tests (issue #75)
# ---------------------------------------------------------------------------
class TestQueryDailyGraphData:
def test_empty_store(self, tmp_path):
conn = init_store(tmp_path / "test.db")
result = _query_daily_graph_data(conn)
assert result == []
conn.close()
def test_with_days(self, tmp_path):
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
_open_period(conn)
for i in range(14):
d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100)
result = _query_daily_graph_data(conn)
assert len(result) == 14
assert result[0]["total_bytes"] == 1024*1024*100
assert result[0]["allocated_bytes"] == 1024*1024*100
assert result[0]["unallocated_bytes"] == 0
assert result[0]["is_zero"] is False
conn.close()
def test_zero_day(self, tmp_path):
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
_open_period(conn)
_insert_day(conn, "2026-09-20", bw=0, coverage=0.95, samples=24)
result = _query_daily_graph_data(conn)
assert len(result) == 1
assert result[0]["is_zero"] is True
assert result[0]["total_bytes"] == 0
conn.close()
def test_unattributed_bytes(self, tmp_path):
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
_open_period(conn)
_insert_day_with_unattributed(
conn, "2026-09-20", bw=500, unattributed=300
)
result = _query_daily_graph_data(conn)
assert len(result) == 1
assert result[0]["allocated_bytes"] == 500
assert result[0]["unallocated_bytes"] == 300
assert result[0]["total_bytes"] == 800
conn.close()
def test_gap_day(self, tmp_path):
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
_open_period(conn)
# Day with no hours but unknown seconds (gap in monitoring period)
conn.execute(
"INSERT INTO day_aggregates "
"(day, active_seconds, idle_seconds, powered_off_seconds, unknown_seconds, "
" bytes_written_delta, sample_count, coverage) "
"VALUES (?, 0, 0, 0, 86400, 0, 0, 0.0)",
("2026-09-20",),
)
conn.commit()
result = _query_daily_graph_data(conn)
assert len(result) == 1
assert result[0]["is_gap"] is True
conn.close()
class TestQueryHourlyGraphData:
def test_empty_day(self, tmp_path):
conn = init_store(tmp_path / "test.db")
result = _query_hourly_graph_data(conn, "2026-09-20")
assert result == []
conn.close()
def test_with_hours(self, tmp_path):
conn = init_store(tmp_path / "test.db")
for h in range(24):
hour = "2026-09-20T%02d:00:00+00:00" % h
bw = 1024*1024*100 if h in (10, 14) else 0
_insert_hour(conn, hour, bw=bw)
result = _query_hourly_graph_data(conn, "2026-09-20")
assert len(result) == 24
assert result[10]["bytes_written"] == 1024*1024*100
assert result[10]["is_zero"] is False
assert result[0]["is_zero"] is True
conn.close()
def test_hour_labels(self, tmp_path):
conn = init_store(tmp_path / "test.db")
_insert_hour(conn, "2026-09-20T12:00:00+00:00", bw=1000)
result = _query_hourly_graph_data(conn, "2026-09-20")
assert len(result) == 1
assert result[0]["local_label"] == "12"
conn.close()
# ---------------------------------------------------------------------------
# DailyBarGraph widget unit tests (issue #75)
# ---------------------------------------------------------------------------
class TestDailyBarGraph:
def test_empty_data(self, tmp_path):
"""Empty data shows awaiting message."""
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
# We test the widget directly via the app's compose
graph = DailyBarGraph()
# Simulate setting empty data
from textual.app import App as TextualApp
class _TestApp(TextualApp):
def compose(self):
yield graph
# We can't easily test widget lifecycle outside an app, so test the data
assert graph._all_day_data == []
assert graph.range_days == _RANGE_DEFAULT
def test_range_default(self):
"""Default range is 14 days."""
graph = DailyBarGraph()
assert graph.range_days == _RANGE_DEFAULT
assert graph.selected_index == -1
assert graph.view_mode == "daily"
def test_trim_to_range(self):
"""Data is trimmed to the selected range."""
graph = DailyBarGraph()
data = [{"day": "2026-09-%02d" % d, "total_bytes": d * 100}
for d in range(1, 31)]
graph._all_day_data = data
graph.range_days = 7
graph._trim_to_range()
assert len(graph._day_data) == 7
assert graph._day_data[0]["day"] == "2026-09-24"
def test_range_all_fits(self):
"""When data fits within range, all days are shown."""
graph = DailyBarGraph()
data = [{"day": "2026-09-%02d" % d, "total_bytes": d * 100}
for d in range(1, 8)]
graph._all_day_data = data
graph.range_days = 14
graph._trim_to_range()
assert len(graph._day_data) == 7
# ---------------------------------------------------------------------------
# Bar graph headless TUI tests (issue #75)
# ---------------------------------------------------------------------------
class TestBarGraphTUI:
"""Headless tests for the interactive bar graph."""
@pytest.mark.asyncio
async def test_graph_renders_with_data(self, tmp_path):
"""Graph widget renders with day data."""
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
_open_period(conn)
for i in range(14):
d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100)
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(80, 24)) as pilot:
graph = app.query_one("#usage-history")
assert isinstance(graph, DailyBarGraph)
assert len(graph._day_data) > 0
# Legend should be visible
legend = str(app.query_one("#bar-legend").render())
assert "alloc" in legend
assert "zero" in legend
@pytest.mark.asyncio
async def test_arrow_selection(self, tmp_path):
"""Left/right arrows move the selection."""
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
_open_period(conn)
for i in range(14):
d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100)
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(80, 24)) as pilot:
graph = app.query_one("#usage-history")
app.set_focus(graph)
await pilot.pause()
await pilot.pause()
# Right arrow selects first bar
await pilot.press("right")
await pilot.pause()
assert graph.selected_index == 0
# Right again moves to second
await pilot.press("right")
await pilot.pause()
assert graph.selected_index == 1
# Left moves back
await pilot.press("left")
await pilot.pause()
assert graph.selected_index == 0
@pytest.mark.asyncio
async def test_range_switching(self, tmp_path):
"""1-4 keys switch the visible range."""
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
_open_period(conn)
for i in range(30):
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100)
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(80, 24)) as pilot:
graph = app.query_one("#usage-history")
app.set_focus(graph)
await pilot.pause()
await pilot.pause()
# Default is 14
assert graph.range_days == 14
assert len(graph._day_data) == 14
# Press 1 for 7-day range
await pilot.press("1")
await pilot.pause()
assert graph.range_days == 7
assert len(graph._day_data) == 7
# Press 3 for 28-day range
await pilot.press("3")
await pilot.pause()
assert graph.range_days == 28
assert len(graph._day_data) == 28
# Press 4 for 90-day range (only 30 days available)
await pilot.press("4")
await pilot.pause()
assert graph.range_days == 90
assert len(graph._day_data) == 30
@pytest.mark.asyncio
async def test_readout_updates_on_selection(self, tmp_path):
"""Readout shows selected day info."""
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
_open_period(conn)
for i in range(14):
d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100)
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(80, 24)) as pilot:
graph = app.query_one("#usage-history")
app.set_focus(graph)
await pilot.pause()
await pilot.pause()
# No selection initially
readout = str(app.query_one("#bar-readout").render())
assert "select" in readout.lower()
# Select first bar
await pilot.press("right")
await pilot.pause()
readout = str(app.query_one("#bar-readout").render())
assert "2026-09" in readout
assert "GB" in readout
@pytest.mark.asyncio
async def test_hourly_drill_down_and_back(self, tmp_path):
"""Enter drills into hourly view, Esc returns to daily."""
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
_open_period(conn)
for i in range(14):
d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100)
# Insert hours for each day
for h in range(24):
hour = "%sT%02d:00:00+00:00" % (d, h)
bw_h = 1024*1024*10 if h == 12 else 0
_insert_hour(conn, hour, bw=bw_h)
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(80, 24)) as pilot:
graph = app.query_one("#usage-history")
app.set_focus(graph)
await pilot.pause()
await pilot.pause()
# Select a day
await pilot.press("right")
await pilot.pause()
assert graph.selected_index == 0
assert graph.view_mode == "daily"
# Enter drill-down
await pilot.press("enter")
await pilot.pause()
assert graph.view_mode == "hourly"
assert graph.drill_day is not None
assert len(graph._hour_data) == 24
# Esc returns to daily
await pilot.press("escape")
await pilot.pause()
assert graph.view_mode == "daily"
assert graph.drill_day is None
@pytest.mark.asyncio
async def test_empty_store_graph(self, tmp_path):
"""Empty store shows awaiting message in graph."""
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app.run_test(size=(80, 24)) as pilot:
graph = app.query_one("#usage-history")
assert isinstance(graph, DailyBarGraph)
render = str(app.query_one("#bar-render").render())
assert "awaiting" in render.lower()
@pytest.mark.asyncio
async def test_graph_border_title(self, tmp_path):
"""Graph pane has a border title."""
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app.run_test(size=(80, 24)) as pilot:
graph = app.query_one("#usage-history")
assert graph.border_title == "usage history"
@pytest.mark.asyncio
async def test_glyphs_in_legend(self, tmp_path):
"""Legend shows all required glyphs."""
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
_open_period(conn)
for i in range(7):
d = (datetime(2026, 9, 20) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100)
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(80, 24)) as pilot:
await pilot.pause()
legend = str(app.query_one("#bar-legend").render())
assert _GLYPH_ALLOCATED in legend
assert _GLYPH_UNALLOCATED in legend
assert _GLYPH_GAP in legend
assert _GLYPH_ZERO in legend
assert _GLYPH_PARTIAL in legend
# ---------------------------------------------------------------------------
# Issue #81: Constrained size layout
# ---------------------------------------------------------------------------
class TestConstrainedLayout:
"""Tests for constrained terminal size behavior (issue #81)."""
@pytest.mark.asyncio
async def test_constrained_at_80x24(self, tmp_path):
"""At 80×24 the dashboard renders normally with all regions visible."""
conn = init_store(tmp_path / "test.db")
_insert_baseline(conn, tbw_tb=1.0, verified=True)
_insert_segment(conn)
_open_period(conn)
for i in range(14):
d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100)
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(80, 24)) as pilot:
# All four regions should be visible
assert app.query_one("#headline-band") is not None
assert app.query_one("#usage-history") is not None
assert app.query_one("#drive-health") is not None
assert app.query_one("#service-strip") is not None
# Graph should not be in constrained mode
assert not app._is_constrained_mode
# Constrained summary should be hidden
summary = app.query_one("#constrained-summary")
assert str(summary.styles.display) == "none"
@pytest.mark.asyncio
async def test_constrained_below_80_width(self, tmp_path):
"""Below 80 columns the graph is hidden and textual summary shown."""
conn = init_store(tmp_path / "test.db")
_insert_baseline(conn, tbw_tb=1.0, verified=True)
_insert_segment(conn)
_open_period(conn)
for i in range(14):
d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100)
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(79, 24)) as pilot:
# Should be in constrained mode
assert app._is_constrained_mode
# Main grid should have constrained class
main_grid = app.query_one("#main-grid")
assert main_grid.has_class("constrained")
# Constrained summary should be visible
summary = app.query_one("#constrained-summary")
summary_text = str(summary.render())
assert "graph needs ≥80×24" in summary_text
assert "days" in summary_text
assert "GB total" in summary_text
@pytest.mark.asyncio
async def test_constrained_below_24_height(self, tmp_path):
"""Below 24 rows the graph is hidden and textual summary shown."""
conn = init_store(tmp_path / "test.db")
_insert_baseline(conn, tbw_tb=1.0, verified=True)
_insert_segment(conn)
_open_period(conn)
for i in range(14):
d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100)
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(80, 23)) as pilot:
# Should be in constrained mode
assert app._is_constrained_mode
# Constrained summary should be visible
summary = app.query_one("#constrained-summary")
summary_text = str(summary.render())
assert "graph needs ≥80×24" in summary_text
@pytest.mark.asyncio
async def test_resize_from_constrained_to_normal(self, tmp_path):
"""Resizing from constrained to normal restores graph and coherent state."""
conn = init_store(tmp_path / "test.db")
_insert_baseline(conn, tbw_tb=1.0, verified=True)
_insert_segment(conn)
_open_period(conn)
for i in range(14):
d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100)
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(79, 24)) as pilot:
# Start in constrained mode
assert app._is_constrained_mode
graph = app.query_one("#usage-history")
# Select a day while constrained
graph.selected_index = 5
# Resize to normal — pilot.resize_terminal changes terminal size
await pilot.resize_terminal(80, 24)
await pilot.pause()
# Trigger refresh so constrained state updates (timer would do this)
app._refresh()
await pilot.pause()
# Should no longer be constrained
assert not app._is_constrained_mode
# Main grid should not have constrained class
main_grid = app.query_one("#main-grid")
assert not main_grid.has_class("constrained")
# Selection should be preserved
assert graph.selected_index == 5
# Graph should have data
assert len(graph._day_data) > 0
@pytest.mark.asyncio
async def test_resize_from_normal_to_constrained(self, tmp_path):
"""Resizing from normal to constrained hides graph and shows summary."""
conn = init_store(tmp_path / "test.db")
_insert_baseline(conn, tbw_tb=1.0, verified=True)
_insert_segment(conn)
_open_period(conn)
for i in range(14):
d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100)
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(80, 24)) as pilot:
# Start in normal mode
assert not app._is_constrained_mode
graph = app.query_one("#usage-history")
# Focus and select a day
app.set_focus(graph)
await pilot.pause()
await pilot.press("right")
await pilot.pause()
assert graph.selected_index == 0
# Resize to constrained — pilot.resize_terminal changes terminal size
await pilot.resize_terminal(79, 24)
await pilot.pause()
# Trigger refresh so constrained state updates (timer would do this)
app._refresh()
await pilot.pause()
# Should be in constrained mode
assert app._is_constrained_mode
# Constrained summary should show selected day context
summary = app.query_one("#constrained-summary")
summary_text = str(summary.render())
assert "graph needs ≥80×24" in summary_text
# Selected day context should survive
assert "2026-09" in summary_text
@pytest.mark.asyncio
async def test_constrained_summary_empty_store(self, tmp_path):
"""Constrained summary shows awaiting message for empty store."""
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app.run_test(size=(79, 24)) as pilot:
assert app._is_constrained_mode
summary = app.query_one("#constrained-summary")
summary_text = str(summary.render())
assert "graph needs ≥80×24" in summary_text
assert "awaiting" in summary_text.lower()
@pytest.mark.asyncio
async def test_constrained_all_regions_usable(self, tmp_path):
"""All regions remain usable in constrained mode."""
conn = init_store(tmp_path / "test.db")
_insert_baseline(conn, tbw_tb=1.0, verified=True)
_insert_segment(conn)
_open_period(conn)
for i in range(14):
d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100)
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(79, 24)) as pilot:
# All regions should exist
assert app.query_one("#headline-band") is not None
assert app.query_one("#drive-health") is not None
assert app.query_one("#service-strip") is not None
assert app.query_one("#quit-rail") is not None
# Headline should contain projection
headline = str(app.query_one("#headline-band").render())
assert "remaining" in headline.lower() or "projection" in headline.lower()
# Service strip should have actions
strip = str(app.query_one("#service-strip").render())
assert "p pause" in strip
assert "r resume" in strip
assert "q quit" not in strip # Quit is in quit-rail
# Quit rail should be visible
rail = str(app.query_one("#quit-rail").render())
assert "QUIT" in rail
@pytest.mark.asyncio
async def test_constrained_long_reasons_visible(self, tmp_path):
"""Long status reasons remain visible in constrained mode."""
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
_open_period(conn)
# Insert stale data
stale_ts = (_clock() - timedelta(days=10)).isoformat()
_insert_sample(conn, stale_ts, pu=5)
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(79, 24)) as pilot:
strip = str(app.query_one("#service-strip").render())
# Status should be visible even with long reasons
assert "stale" in strip.lower() or "freshness" in strip.lower()
@pytest.mark.asyncio
async def test_constrained_keyboard_navigation(self, tmp_path):
"""Keyboard focus traversal works in constrained mode."""
conn = init_store(tmp_path / "test.db")
_insert_baseline(conn, tbw_tb=1.0, verified=True)
_insert_segment(conn)
_open_period(conn)
for i in range(14):
d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100)
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(79, 24)) as pilot:
# Quit should still work
await pilot.press("q")
assert not app.is_running
@pytest.mark.asyncio
async def test_constrained_theme_and_motion_toggle(self, tmp_path):
"""Theme and motion toggles work in constrained mode."""
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app.run_test(size=(79, 24)) as pilot:
assert app._is_constrained_mode
# Toggle theme
await pilot.press("t")
await pilot.pause()
# Toggle motion
await pilot.press("m")
await pilot.pause()
# Should still be constrained
assert app._is_constrained_mode
def test_min_width_constant(self):
"""_MIN_WIDTH is 80 columns."""
assert _MIN_WIDTH == 80
def test_min_height_constant(self):
"""_MIN_HEIGHT is 24 rows."""
assert _MIN_HEIGHT == 24
def test_is_constrained_with_terminal_width(self, tmp_path):
"""DailyBarGraph._is_constrained checks terminal width."""
graph = DailyBarGraph()
# Widget-level check (no terminal width)
assert graph._is_constrained() is False
# Terminal width below minimum
assert graph._is_constrained(terminal_width=79) is True
# Terminal width at minimum
assert graph._is_constrained(terminal_width=80) is False
# Terminal width above minimum
assert graph._is_constrained(terminal_width=120) is False