Compare commits

..
43 Commits
Author SHA1 Message Date
xavierk 670671cb25 chore: prepare v0.7.0 release
Release / release (push) Successful in 1m28s
CI / security (pull_request) Failing after 21s
CI / lint (pull_request) Successful in 36s
CI / ai-review (pull_request) Successful in 2m52s
Bump pyproject.toml to 0.7.0 and move the Unreleased section under
[0.7.0] - 2026-10-06.
2026-10-06 10:14:35 +05:30
xavierk 8c8710fe5a Merge origin/main into main
Reconcile the staged-projection and Diagnostics feature work with the CI
quality-gate changes from origin/main. The origin side of every conflict was
lint-only, so the feature imports were kept and the ruff fixes re-applied:

- keep the branch's added imports (timefmt, activity_readout, projection
  ladder helpers) and drop the unused ones ruff reported
- restore `noqa: E402` on the post-bootstrap imports in fenris-monitor and
  keep the `noqa: F401` re-exports in the TUI that tests assert on
- make the TUI monitoring-glyph test clock-relative instead of pinned to a
  fixed date
2026-10-06 10:14:09 +05:30
xavierk ab1d894adc feat(collector): capture additional smartctl diagnostics
Record host read and write commands, controller busy time, error log
entries, warning and critical temperature time, and thermal management
transitions from the existing smartctl -a -j acquisition. Schema 7 adds
nullable columns to samples so legacy rows read as unknown, and the
Diagnostics panel shows each counter with its change.
2026-10-05 21:12:25 +05:30
xavierk 948e097952 feat(tui): add Diagnostics panel for stored evidence
Toggle a Diagnostics panel with i, built on the Activity readout. It swaps
in for the Drive pane and shows the decoded critical_warning, power-on
hours, power cycles, unsafe shutdowns, media errors and available spare
with their change over the selection, and per-hour (Day) or per-day
(History) temperature min/avg/max, active/idle/powered-off/unknown split
and coverage. History bars draw reads beside writes while it is open, and a
set critical_warning shows as a badge in the endurance outlook without
affecting the projection. Counter deltas are withheld across a controller
segment change.
2026-10-05 21:05:44 +05:30
xavierk 6c517e4ecb feat(tui): unify keyboard and mouse navigation
Declare every dashboard key as a Binding on the widget that owns it, and
generate the footer, activity tool chips and help screen from those
bindings so they cannot drift. The footer collapses to "? help · q quit"
under 80x24.

Add Home/End and PgUp/PgDn (one week) navigation, mouse-wheel selection on
the graphs, click-to-focus on every panel, and clickable footer chips.
Tab and Shift+Tab now visit only the dashboard panels.
2026-10-05 20:42:04 +05:30
xavierk 79ebb10dea refactor(tui): extract Activity readout and show local time
Move activity data assembly out of tui.py into activity_readout, behind
readout(conn, selection, now). Dashboard and fenris-monitor enable now show
local time with a timezone label, inspected points keep a secondary UTC line,
and the legacy UTC-relabelled day and hour branches are gone. Time formatting
(including freshness_age_human) lives in timefmt. Hour offsets now render as
+05:30 to match day labels.
2026-10-05 20:21:22 +05:30
xavierk ef61ececc1 feat(tui): enter rated TBW from the dashboard
Add a b key and a no-baseline banner that open a form for rated TBW and an
optional source URL. The value is persisted as an unverified override through
fenris-monitor baseline set under the existing polkit action, and the outcome
is reported synchronously. Verified overrides stay CLI-only (ADR 0012 section 7).
2026-10-05 20:01:32 +05:30
xavierk e26a851115 feat(projection): show staged projection with evidence ladder
Report a projection stage, an evidence ladder and the sustained-regime
write rate from compute_projection, per ADR 0012. A provisional lifespan
appears after 3 observed hours and carries the hours observed and a
short-horizon spread; the complete-local-day condition becomes a fact
instead of a gate, and the write rate replaces the lifespan when no
baseline applies. The TUI and CLI render the stage, ladder count and
full ladder, and drop their own two-sample pre-gates.
2026-10-05 19:57:46 +05:30
xavierk 96fd1702fa fix(projection): align projection with ADR 0002
Use lifetime written bytes from the current segment's newest published
sample in the headline formula, select the Percentage-Used-implied
baseline after two increments in the segment (otherwise show the
too-coarse fact), and judge horizon agreement across existing horizons.
Make the vacuous projection tests assert unconditionally and add
coverage for implied-baseline gating and single-horizon agreement.
2026-10-05 19:45:38 +05:30
xavierk cd053cf125 Merge pull request 'ci: add quality-gate workflow (semgrep, ruff, jscpd, advisory PR-Agent)' (#112) from ci/quality-gates into main
CI / lint (push) Successful in 29s
CI / security (push) Successful in 19s
CI / ai-review (push) Skipped
Release / release (push) Failing after 6s
Reviewed-on: #112
2026-10-05 14:14:59 +00:00
soubarna e3b6f89ebb chore: annotate release.yml semgrep warnings
CI / security (pull_request) Successful in 22s
CI / lint (pull_request) Successful in 33s
CI / ai-review (pull_request) Successful in 2s
2026-10-05 17:29:31 +05:30
soubarna 412cbd51c6 fix: triage semgrep SQL findings 2026-10-05 17:29:31 +05:30
soubarna 3355c20202 fix: ruff findings 2026-10-05 17:28:15 +05:30
soubarna 2c93874799 ci: add quality-gate workflow 2026-10-05 17:21:50 +05:30
xavierk 117082af8c docs: record staged projection decisions (#105)
Add ADR 0012 amending ADR 0002 and ADR 0003: projection stages,
provisional lifespan after 3 observed hours, short scenario horizons,
evidence ladder, and TUI unverified TBW entry. Add Projection stage,
Provisional projection, Evidence ladder and Activity readout to the
glossary.
2026-10-03 08:13:12 +05:30
xavierk 3c07f37c78 fix(release): refresh published XBPS assets 2026-09-29 04:38:58 +05:30
xavierk b098132595 fix(ci): authenticate XBPS repository publish
Set the existing Fenris commit identity and pass the Gitea publish token to Git without storing credentials on the runner.
2026-09-29 04:32:54 +05:30
xavierk 1dbe372714 fix(ci): honor XBPS dispatch input
Handle Gitea boolean inputs in the publish condition. Mark Void available only after the repository publish step succeeds.
2026-09-29 04:27:57 +05:30
xavierk 1063fa4dae test(signing): align key storage contract with Gitea
Release / release (push) Successful in 2m3s
2026-09-29 04:19:23 +05:30
xavierk 3f2dd6a5a1 fix(release): retain XBPS key through publication
The optional XBPS publisher signs repository metadata after package signing. Remove the runner key only after publication and release asset upload.
2026-09-29 04:06:45 +05:30
xavierk a5b84f7566 docs: align signing ceremony with Gitea workflow 2026-09-29 03:57:24 +05:30
xavierk 917c94fd65 chore: prepare v0.6.0 release 2026-09-29 03:51:35 +05:30
xavierk a49808a247 docs: preserve session style instructions 2026-09-29 03:51:24 +05:30
xavierk 87c5dcdac6 test: fix runit missing-script fixture 2026-09-29 03:51:20 +05:30
xavierk 511905f519 docs: record observation history decisions 2026-09-29 03:51:17 +05:30
xavierk e22b99442e fix: validate complete-day monitoring coverage 2026-09-29 03:34:17 +05:30
xavierk d045e88043 Clarify incomplete history readouts (#103) 2026-09-28 17:54:56 +05:30
xavierk c4524cf654 Preserve historical activity selection (#103) 2026-09-28 17:46:18 +05:30
xavierk f780212a02 fix: follow live activity until inspection (#102) 2026-09-28 16:32:13 +05:30
xavierk 3e1ecc90e4 refactor: consolidate pending recovery checks (#101) 2026-09-28 15:05:47 +05:30
xavierk 59d2dd2634 fix: clarify pending admission failure outcome (#101) 2026-09-28 15:05:02 +05:30
xavierk fd4db45ae1 fix: bound pending publication admission (#101) 2026-09-28 15:02:58 +05:30
xavierk 5aeee7b6f1 fix: guard local-day pruning across monitoring gaps (#100) 2026-09-28 14:37:22 +05:30
xavierk d69753690a fix: prune old detail after publication (#100) 2026-09-28 14:03:37 +05:30
xavierk 017a562566 fix: rebuild trustworthy local-day evidence (#99) 2026-09-28 13:09:00 +05:30
xavierk 7e392c4ea2 fix: conserve local-day activity evidence 2026-09-28 10:21:55 +05:30
xavierk 8a5ef05188 refactor: clarify publication recovery count 2026-09-28 09:06:09 +05:30
xavierk 847bde1be0 test: account for pending publication table 2026-09-28 03:23:46 +05:30
xavierk 0d45f263d2 feat: retain pending publications (#97) 2026-09-28 03:08:00 +05:30
xavierk 7c21b044ba test: own monitoring period fixture commit 2026-09-28 02:26:21 +05:30
xavierk 437ea6be73 fix: publish collection history atomically 2026-09-28 01:56:32 +05:30
xavierk 4550dd5111 Keep package directories accessible under restrictive build umasks
Release / release (push) Successful in 1m14s
2026-09-19 11:21:57 +05:30
xavierk e49aac8569 Release Fenris 0.5.0 with Chalktone dashboard and dotted activity plots 2026-09-19 11:13:37 +05:30
96 changed files with 10920 additions and 2403 deletions
+92
View File
@@ -0,0 +1,92 @@
name: CI
on:
pull_request:
push:
branches: [main]
schedule:
- cron: '0 3 * * 1'
workflow_dispatch:
permissions:
contents: read
jobs:
security:
runs-on: bongbetic-ci
timeout-minutes: 15
container:
image: docker.io/semgrep/semgrep:1.178.0
steps:
- name: Checkout
env:
GIT_TOKEN: ${{ gitea.token }}
run: |
set -euo pipefail
git init -q .
git remote add origin "${{ gitea.server_url }}/${{ gitea.repository }}.git"
git -c http.extraheader="Authorization: token ${GIT_TOKEN}" fetch -q --depth=1 origin "${{ gitea.sha }}"
git checkout -q FETCH_HEAD
- name: Semgrep
run: semgrep scan --config p/default --config p/owasp-top-ten --metrics off --error
lint:
if: gitea.event_name != 'schedule'
runs-on: bongbetic-ci
timeout-minutes: 20
steps:
- name: Checkout
env:
GIT_TOKEN: ${{ gitea.token }}
run: |
set -euo pipefail
git init -q .
git remote add origin "${{ gitea.server_url }}/${{ gitea.repository }}.git"
git -c http.extraheader="Authorization: token ${GIT_TOKEN}" fetch -q --depth=1 origin "${{ gitea.sha }}"
git checkout -q FETCH_HEAD
# node:24-bookworm ships python3 without ensurepip, so python3-venv comes from apt.
- name: Install ruff
run: |
set -euo pipefail
apt-get update -qq
apt-get install -y -qq --no-install-recommends python3-venv
python3 -m venv /tmp/lint-venv
/tmp/lint-venv/bin/pip install -q ruff==0.16.10
- name: Ruff
run: /tmp/lint-venv/bin/ruff check src/ tests/
- name: Duplicate code (jscpd)
run: npx --yes jscpd@4.3.0 --config .jscpd.json .
ai-review:
if: gitea.event_name == 'pull_request'
runs-on: bongbetic-ci
timeout-minutes: 10
continue-on-error: true
container:
image: docker.io/pragent/pr-agent:0.47.0
env:
config__git_provider: gitea
gitea__url: https://git.bongbetic.com
gitea__personal_access_token: ${{ secrets.PR_AGENT_GITEA_TOKEN }}
openrouter__key: ${{ secrets.OPENROUTER_API_KEY }}
config__model: ${{ vars.PR_AGENT_MODEL || 'openrouter/anthropic/claude-sonnet-5' }}
config__fallback_models: "[\"${{ vars.PR_AGENT_MODEL || 'openrouter/anthropic/claude-sonnet-5' }}\"]"
config__custom_model_max_tokens: '200000'
steps:
# Advisory only: posts a review comment, never pushes code. Skips when secrets are absent
# (for example PRs from forks, where Gitea withholds secrets).
- name: PR-Agent review
env:
PR_URL: ${{ gitea.event.pull_request.html_url }}
run: |
set -euo pipefail
if [ -z "${gitea__personal_access_token}" ] || [ -z "${openrouter__key}" ]; then
echo "::notice::PR_AGENT_GITEA_TOKEN or OPENROUTER_API_KEY not set; skipping AI review"
exit 0
fi
cd /app
pr-agent --pr_url="${PR_URL}" review
+41 -14
View File
@@ -25,7 +25,7 @@ jobs:
release:
runs-on: [self-hosted]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v4 # nosemgrep: github-actions-mutable-action-tag -- release path unchanged, tag pinned by major version
- name: Validate release tag and notes
run: |
@@ -47,7 +47,7 @@ jobs:
--footer packaging/release-footer.md > "${RUNNER_TEMP}/release-body.md"
- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@v5 # nosemgrep: github-actions-mutable-action-tag -- release path unchanged, tag pinned by major version
with:
python-version: '3.12'
@@ -159,7 +159,6 @@ jobs:
gpg --batch --yes --delete-secret-keys "${FINGERPRINT}"
gpg --batch --yes --delete-keys "${FINGERPRINT}"
fi
rm -f ~/.ssh/id_xbps
- name: Determine version
id: version
@@ -204,9 +203,21 @@ jobs:
esac
- name: Publish XBPS to distribution repository
if: github.event.inputs.publish_xbps == 'true'
id: publish-xbps
if: ${{ github.event.inputs.publish_xbps == true || github.event.inputs.publish_xbps == 'true' }}
env:
GITEA_PUBLISH_TOKEN: ${{ secrets.GITEAPACKAGETOKEN }}
run: |
set -euo pipefail
if [ -z "${GITEA_PUBLISH_TOKEN}" ]; then
echo "::error::GITEAPACKAGETOKEN repository secret is not configured"
exit 1
fi
git config user.name "xavierk"
git config user.email "xavierk@bongbetic.com"
export GIT_CONFIG_COUNT=1
export GIT_CONFIG_KEY_0='http.https://git.bongbetic.com/.extraheader'
export GIT_CONFIG_VALUE_0="Authorization: token ${GITEA_PUBLISH_TOKEN}"
VERSION=${{ steps.version.outputs.version }}
XBPS_FILE="fenris-${VERSION}_1.x86_64.xbps"
if [ ! -f "${XBPS_FILE}" ]; then
@@ -218,6 +229,7 @@ jobs:
exit 1
fi
bash scripts/xbps-publish.sh --publish
echo "xbps_published=true" >> "$GITHUB_OUTPUT"
- name: Track format availability
id: formats
@@ -227,7 +239,7 @@ jobs:
DEB_EXISTS=$([ -f "dist/fenris_${VERSION}_amd64.deb" ] && echo "true" || echo "false")
RPM_EXISTS=$([ -f "dist/fenris-${VERSION}-1.x86_64.rpm" ] && echo "true" || echo "false")
XBPS_EXISTS=$([ -f "fenris-${VERSION}_1.x86_64.xbps" ] && echo "true" || echo "false")
XBPS_PUBLISHED=$([ "${{ github.event.inputs.publish_xbps }}" = "true" ] && echo "true" || echo "false")
XBPS_PUBLISHED=$([ "${{ steps.publish-xbps.outputs.xbps_published }}" = "true" ] && echo "true" || echo "false")
echo "deb_available=${DEB_EXISTS}" >> "$GITHUB_OUTPUT"
echo "rpm_available=${RPM_EXISTS}" >> "$GITHUB_OUTPUT"
echo "xbps_available=${XBPS_EXISTS}" >> "$GITHUB_OUTPUT"
@@ -318,7 +330,8 @@ jobs:
"https://git.bongbetic.com/api/v1/repos/xavierk/Fenris/releases/tags/v${VERSION}")
RELEASE_ID=$(printf '%s' "${RELEASE_JSON}" \
| python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
# Attach deb, rpm, clearsigned checksums, and XBPS artifacts once.
PUBLISH_XBPS="${{ steps.formats.outputs.xbps_published }}"
# Attach package artifacts; refresh XBPS assets after publication.
ARTIFACTS=(
"dist/fenris_${VERSION}_amd64.deb"
"dist/fenris-${VERSION}-1.x86_64.rpm"
@@ -327,19 +340,33 @@ jobs:
# Add XBPS artifacts if they exist
XBPS_FILE="fenris-${VERSION}_1.x86_64.xbps"
if [ -f "${XBPS_FILE}" ]; then
ARTIFACTS+=("${XBPS_FILE}")
if [ -f "${XBPS_FILE}.sig2" ]; then
ARTIFACTS+=("${XBPS_FILE}")
ARTIFACTS+=("${XBPS_FILE}.sig2")
else
echo "::warning::Unsigned XBPS artifact omitted from release assets"
fi
fi
for FILE in "${ARTIFACTS[@]}"; do
ASSET_NAME="${FILE##*/}"
if python3 -c 'import json,sys; name=sys.argv[1]; sys.exit(0 if any(a.get("name") == name for a in json.load(sys.stdin).get("assets", [])) else 1)' "${ASSET_NAME}" <<<"${RELEASE_JSON}"; then
echo "${ASSET_NAME}: already attached"
else
curl --fail --silent --show-error -X POST \
-H "Authorization: token ${GITEA_PUBLISH_TOKEN}" \
-F "attachment=@${FILE}" \
"https://git.bongbetic.com/api/v1/repos/xavierk/Fenris/releases/${RELEASE_ID}/assets"
EXISTING_ASSET_ID=$(python3 -c 'import json,sys; name=sys.argv[1]; print(next((a["id"] for a in json.load(sys.stdin).get("assets", []) if a.get("name") == name), ""))' \
"${ASSET_NAME}" <<<"${RELEASE_JSON}")
if [ -n "${EXISTING_ASSET_ID}" ]; then
if [ "${PUBLISH_XBPS}" = "true" ] && [[ "${ASSET_NAME}" = "${XBPS_FILE}" || "${ASSET_NAME}" = "${XBPS_FILE}.sig2" ]]; then
curl --fail --silent --show-error -X DELETE \
-H "Authorization: token ${GITEA_PUBLISH_TOKEN}" \
"https://git.bongbetic.com/api/v1/repos/xavierk/Fenris/releases/${RELEASE_ID}/assets/${EXISTING_ASSET_ID}"
else
echo "${ASSET_NAME}: already attached"
continue
fi
fi
curl --fail --silent --show-error -X POST \
-H "Authorization: token ${GITEA_PUBLISH_TOKEN}" \
-F "attachment=@${FILE}" \
"https://git.bongbetic.com/api/v1/repos/xavierk/Fenris/releases/${RELEASE_ID}/assets"
done
- name: Remove XBPS signing key
if: always()
run: rm -f ~/.ssh/id_xbps
+18
View File
@@ -0,0 +1,18 @@
{
"threshold": 8,
"minLines": 5,
"minTokens": 50,
"reporters": ["console"],
"gitignore": true,
"ignore": [
"**/node_modules/**",
"**/.git/**",
"**/dist/**",
"**/docs/**",
"**/packaging/**",
"**/*.lock",
"**/package-lock.json",
"**/requirements.txt",
"**/*.md"
]
}
+4
View File
@@ -1,3 +1,7 @@
## Session style
After the first user message in each session, load the global `caveman` skill and activate `ultra` mode. Keep `ultra` mode active until the user changes or stops it.
## Agent skills
## Commit messages
+47
View File
@@ -9,6 +9,53 @@ backfill releases from before this changelog.
## [Unreleased]
## [0.7.0] - 2026-10-06
### Added
- Show a provisional usage-adjusted theoretical lifespan after 3 observed hours, with the hours observed and a short-horizon spread, instead of waiting for a full local day.
- Show the projection stage and an evidence ladder of Supported conditions, each met or unmet with its reason, so confidence visibly strengthens with each collection run.
- Show the sustained-regime write rate when no endurance baseline applies.
- Enter a rated TBW from the dashboard with `b`, saved as an unverified override, so the lifespan appears without the CLI.
- Jump to the first or last point with Home and End on every graph, and a week at a time with PgUp and PgDn in the Day and History views.
- Move through graph points with the mouse wheel, focus any panel by clicking it, and click the footer and activity tool chips to run their actions.
- Open a Diagnostics panel with `i`: the decoded critical warning, power-on hours, power cycles, unsafe shutdowns, media errors and available spare with their change over the selection, plus hourly or daily temperature, active/idle/powered-off/unknown split and coverage. History bars draw reads beside writes while it is open, and a set critical warning shows as a badge in the endurance outlook.
- Record more drive evidence from each collection run (host read and write commands, controller busy time, error log entries, warning and critical temperature time, and thermal management transitions where the drive reports them) and show it in the Diagnostics panel. Existing stores migrate automatically; earlier samples show n/a for the new counters.
### Changed
- Generate the footer and help screen from the dashboard's key bindings so they always agree, and collapse the footer to "? help · q quit" under 80x24.
- Make Tab and Shift+Tab visit only the dashboard panels.
- Show every dashboard time in local time with its timezone, keep a secondary UTC line when a point is inspected, and report the monitoring period start in local time from `fenris-monitor enable`.
- Treat the complete-local-day requirement as a contributing fact rather than a gate, and stop the TUI and CLI from deciding when a projection can be shown.
## [0.6.0] - 2026-09-29
### Added
- Preserve local-day read and write evidence separately, including ambiguous midnight-spanning volume as shared evidence instead of assigning it to either day.
- Keep valid observations pending when publication fails, and recover them without exposing partial history or counting activity twice.
- Preserve live-point inspection and historical activity selection through graph refresh.
### Changed
- Prune old detail only after durable summaries and required boundary evidence are published.
- Require a complete local day to fit within one monitoring period before it can open the endurance outlook.
## [0.5.0] - 2026-09-19
### Changed
- Make drive activity the focus of a Chalktone dashboard with Live, Day, and History tabs, panel zoom, and fixed monitoring controls.
- Replace block bars with labelled dotted volume plots that fit the terminal and preserve gaps, partial evidence, and point inspection.
### Fixed
- Preserve historical selections and store-fault messages through graph refresh and resize.
- Show hourly drill-down results without overwriting them with a loading placeholder.
- Keep known unallocated daily volume visible across coverage gaps, and distinguish missing hourly evidence from zero on small terminals.
- Stage package directories with consistent public permissions, avoiding openSUSE RPM conflicts and inaccessible runtime paths when built with a restrictive umask.
## [0.4.0] - 2026-09-18
### Added
+48
View File
@@ -0,0 +1,48 @@
# CI quality gates
Workflow: `.gitea/workflows/ci.yml` (runner label `bongbetic-ci`, no third-party `uses:` actions; code is checked out with shell git).
Status contexts: `CI / security (pull_request)`, `CI / lint (pull_request)`, `CI / ai-review (pull_request)`.
| Job | Runs on | Blocks merge? | What it does |
|-----|---------|---------------|--------------|
| `security` | PR, push to main, weekly schedule, manual | Yes | Semgrep 1.178.0, `p/default` + `p/owasp-top-ten`, `--error` |
| `lint` | PR, push to main, manual | Yes | `ruff check src/ tests/` (ruff 0.16.10, rules E4/E7/E9/F) and jscpd 4.3.0 duplicate-code threshold 8% |
| `ai-review` | PR only | No (advisory) | PR-Agent `review`, comment-only, `continue-on-error: true` |
There is no `e2e` (no web UI) and no `deploy` job (not a Coolify app). The weekly schedule (`0 3 * * 1`) runs only `security`.
## Run locally
```sh
# security (same command as CI)
podman run --rm -v "$PWD:/src:Z" docker.io/semgrep/semgrep:1.178.0 \
semgrep scan --config p/default --config p/owasp-top-ten --metrics off --error
# lint (needs `pip install -e '.[dev]'` or `pip install ruff==0.16.10`)
make lint
# duplicate code
npx --yes jscpd@4.3.0 --config .jscpd.json .
```
Notes:
- The semgrep container needs no extra flags. `:Z` is only for SELinux hosts; its working directory is `/src`.
- The `lint` job installs `python3-venv` from apt because `node:24-bookworm` has no `ensurepip`; ruff itself is pinned.
- `.jscpd.json` threshold is 8%, just above the 7.15% baseline, so duplication cannot grow. Lower it as duplication is removed.
- Semgrep prints some non-fatal `PartialParsing` errors; they do not fail the job.
## PR-Agent (advisory)
`ai-review` posts a review comment on the PR through the Gitea API. It never pushes code and never blocks. It exits 0 with a notice when its secrets are empty (for example on fork PRs).
Required repository secrets: `OPENROUTER_API_KEY`, `PR_AGENT_GITEA_TOKEN` (Gitea token of the bot account, scopes to comment on PRs).
Optional repository variable: `PR_AGENT_MODEL` (default `openrouter/anthropic/claude-sonnet-5`). The workflow sets `config__custom_model_max_tokens` to 200000, so adjust it if you pick a model with a different context window.
## Caveats
- Semgrep registry rules (`p/default`, `p/owasp-top-ten`) are fetched at runtime and are not pinned, so a new rule can turn a green main red. The weekly scheduled `security` run catches this early.
- Intentional findings are suppressed with a narrow `# nosemgrep: <rule-id> -- <reason>` on the line. Do not use `.semgrepignore` for source files.
## Rollback
Revert the PR that added `ci.yml` (and its follow-up commits). If branch protection requires `CI / security`, `CI / lint` or `CI / ai-review`, relax it first, otherwise merges stay blocked on checks that no longer run.
+33 -1
View File
@@ -20,6 +20,18 @@ _Avoid_: Future life, actual lifespan, failure date
The degree to which the observation history is sufficiently long, complete, and stable to support the usage-adjusted theoretical lifespan.
_Avoid_: Accuracy percentage, certainty
**Projection stage**:
Where a usage-adjusted theoretical lifespan stands on its way to support: no observations yet, provisional, warming, limited, supported, or unavailable. Each stage is shown with the facts that place it there.
_Avoid_: Loading state, progress percentage
**Provisional projection**:
A lifespan shown from only a few hours of observation history, before a full daily cycle has been seen; it is labelled with the hours observed and its spread, and is expected to move.
_Avoid_: Estimate, preview, guess
**Evidence ladder**:
The conditions that supported projection confidence requires, each shown as met or unmet with its reason, so confidence visibly strengthens as each collection run adds evidence.
_Avoid_: Confidence score, accuracy meter
**Monitoring period**:
A span during which Fenris monitoring is enabled; powered-off time remains part of the usage habit, while deliberately disabled time does not.
_Avoid_: Daemon uptime, calibration window
@@ -28,6 +40,10 @@ _Avoid_: Daemon uptime, calibration window
The single SQLite database at `/var/lib/fenris/observations.db` that persists the observation history, monitoring periods, hour observations, day aggregates, and endurance baseline.
_Avoid_: Data directory, history.jsonl, the database (generic)
**Pending publication**:
The condition where valid acquired observations are retained for recovery but their dependent evidence has not yet been published consistently. Those observations are not part of the reader-visible observation history until publication succeeds; readers retain the last consistent evidence with the pending condition made explicit.
_Avoid_: Successful collection, fresh published evidence
**Store fault**:
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
@@ -40,6 +56,22 @@ _Avoid_: Hourly record, hourly.jsonl entry
One row per UTC day derived from hour observations; the grain at which usage-habit evidence is judged.
_Avoid_: Daily summary, daily stats
**Local-day evidence**:
Measured read and write activity attributable to a local calendar day with its recorded timezone and midnight boundaries. Known volumes remain incomplete when gaps or shared local-day evidence prevent an exact total; they are not estimates of the missing activity.
_Avoid_: Estimated daily total, localised UTC day aggregate
**Shared local-day evidence**:
Measured read or write volume spanning a local midnight that cannot honestly be allocated to either adjacent day. It is retained once, separately from either day's known volume, rather than prorated or counted in both days.
_Avoid_: Missing bytes, estimated midnight split
**Activity selection**:
The read/write measurement, date and point being inspected in live or historical drive activity. Live activity follows the newest point until deliberate inspection pins a point; historical selection survives refresh, while an expired live pin is explained before following resumes.
_Avoid_: Projection window, current usage habit
**Activity readout**:
The ready-to-read account of an activity selection: measured read and write volume, thermal evidence, usage-habit split, coverage and drive warnings for the selected live interval, local hour or local day, expressed in local time with its timezone.
_Avoid_: Graph data, view model
**Usage-history window**:
An exact consecutive span of UTC calendar days ending today, shown from day aggregates; a day without trustworthy evidence remains an explicit gap rather than disappearing or being estimated.
_Avoid_: Available records, dataset range
@@ -77,7 +109,7 @@ A sustained divergence between recent and earlier daily write rates that starts
_Avoid_: Spike, anomaly
**Scenario range**:
The spread of lifespan projections computed from the 7-, 28-, and 90-day horizons of the observation history, shown in place of a statistical interval.
The spread of lifespan projections computed from the horizons the observation history covers — 24-hour and 3-day horizons until a 7-day horizon exists, then 7-, 28-, and 90-day horizons — shown in place of a statistical interval.
_Avoid_: Confidence interval, error bar
**Coverage**:
+42 -1
View File
@@ -22,7 +22,7 @@ The Gitea instance Debian registry signs metadata with its own key. Verify the
instance key fingerprint (TOFU hardening):
```text
Fingerprint: <print after first release — paste beside the curl one-liner>
Fingerprint: F937E81D2FB0736B15BC611884BEBD586DFAC010
```
Add the instance key and repository:
@@ -252,6 +252,47 @@ is retried at the next interval; it never fabricates missing observations.
## Reading the dashboard
![Chalktone dashboard with a dotted activity plot](assets/dashboard-chalktone.png)
Preview uses synthetic observations, not measurements from a real drive.
The Chalktone dashboard opens with a large **Live** activity plot. **Day** shows
hourly evidence for a selected date, and **History** shows daily evidence. Every
time is local, labelled with its timezone, and the selected-point readout adds
a secondary UTC line. Local-day totals keep their recorded timezone. Dotted traces show
measured read/write volumes, not transfer speed; missing evidence breaks the
trace. `?` marks a gap, `~` a partial total, and `u` unallocated daily volume.
Use the selected-point readout for exact values and evidence state.
Local-day totals use measured intervals inside each recorded local date. An
interval that crosses local midnight appears once as shared evidence and stays
outside both known totals. The dashboard labels current totals as so far and
shows incomplete or unavailable dates without treating them as zero. Older
UTC-only summaries cannot establish exact local-day totals.
- `v` cycles Live / Day / History; the tabs are also clickable.
- `←` / `→` inspect points, `Home` / `End` jump to the first or last point, and
`PgUp` / `PgDn` jump a week in Day and History (the live window is three
hours); the mouse wheel moves the selection too. `w`
switches read/write volume in every view.
- `i` swaps the Drive pane for a Diagnostics panel built from the stored
evidence: the decoded `critical_warning`, power-on hours, power cycles,
unsafe shutdowns, media errors and available spare with their change over the
selection, and per-hour (Day) or per-day (History) temperature min/avg/max,
active / idle / powered-off / unknown split and coverage. History bars then
draw reads beside writes. A set `critical_warning` also shows as a badge in
the endurance outlook; it never changes the projection.
- `[` / `]` browse dates, `g` enters a date, and `t` returns to today/live.
- `Tab` / `Shift+Tab` move focus between panels (clicking a panel focuses it);
`z` expands the focused panel, and `z` or `Esc` restores it. Monitoring status and controls remain visible.
- `s` cycles Chalktone, Amber, Nord, and High Contrast; saved theme preferences
survive upgrades. `m` toggles reduced motion.
On smaller terminals, textual summaries and scrollable panels keep evidence
accessible. Pause, resume, collect, disclosures, help, and quit remain available
in the fixed control row, which collapses to `? help · q quit` under 80×24. `?`
lists every key; the footer and help are generated from the same bindings.
Run `fenris` as your normal user to open the TUI dashboard. The dashboard does
not need `sudo`. Use `sudo` for package installation and system configuration;
pause, resume, and collect-now actions normally authenticate through polkit.
Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

@@ -0,0 +1,11 @@
# 10. Preserve local-day activity history with its recorded timezone
Status: Accepted; legacy-history migration and repair implemented in issue #99.
Fenris will retain local-day read/write summaries and the boundary evidence needed to interpret them in the existing observation store before three-minute detail expires after 14 days. Each historical summary retains its recorded timezone and day boundaries; this extends [ADR 0001](0001-observation-store-sqlite.md) while preserving the UTC hour/day evidence used for endurance projections. Collection derives new measured intervals. Ordered schema migration and explicit repair rebuild legacy summaries from surviving evidence; TUI and CLI readers remain read-only.
UTC hourly summaries alone cannot recover a local day whose midnight falls inside a UTC hour. Preserving labelled local summaries trades arbitrary future timezone reinterpretation for bounded detailed-history retention; retaining all fine-grained intervals indefinitely or estimating a split from UTC summaries would violate the agreed retention or evidence semantics.
Only surviving evidence may be used to derive older local summaries. Dates without enough evidence remain incomplete or unavailable; a measured interval crossing midnight is retained once as shared boundary evidence, never prorated or counted in full on both days. A later timezone change does not silently rewrite historical day boundaries.
The agreed presentation and validation requirements are in the [live drive activity specification](../spec/live-drive-activity.md).
@@ -0,0 +1,20 @@
# 11. Preserve valid unpublished observations without exposing partial history
Status: Accepted; implemented on `main` for the planned v0.6.0 release (commit `e22b994`).
A non-invariant derivation failure must not discard valid acquired observations or report a successful collection: retain the observations as pending publication in the existing observation store and retry through the normal scheduled collection path. Readers continue to see the last consistent published evidence, with an explicit pending-publication explanation, rather than combining newly acquired counters with older derived totals. This trades recovery bookkeeping for preservation of measured evidence and consistent read views; neither discarding every valid acquisition on derivation failure nor exposing partially derived history satisfies both requirements.
## Constraints
- The publication distinction applies to every dependent reader, including activity, freshness, controller-segment interpretation and projection inputs, not just the graph. ADR 0001's freshest-sample signal means the freshest published sample; an unpublished observation must not make published evidence appear fresh.
- Pending-work bookkeeping qualifies ADR 0005's recovery-without-new-state wording: it records unfinished evidence publication, not a new health grade or escalation mechanism. Last-collection outcomes still come from the existing native monitoring and logging paths.
- Invariant violations retain [ADR 0005](0005-failure-detection-and-recovery.md)'s write-nothing rule; retaining recoverable work is not permission to persist invalid observations. Actual store faults retain their existing refusal and degradation behavior.
- Recovery and retention remain collector-owned. Repeated recovery must not duplicate measured volume, and required source evidence cannot be pruned before trustworthy derived evidence is durable.
- This extends [ADR 0001](0001-observation-store-sqlite.md)'s consistent read model and [ADR 0010](0010-local-day-activity-history.md)'s preservation rule without another observation store, sampler, background process or retry cadence. Pending-work metadata is not a persisted projection or a replacement for journalled collection outcomes.
- Pending observations use private staging separate from published samples and derived evidence inside the same observation store. This makes exclusion from ordinary reader and projection queries structural, rather than requiring each query to remember a publication filter; the exact table layout remains an implementation detail.
## Bounded pending work
Pending publication has a fixed initial admission capacity of 6,720 observations, equivalent to 14 days at the default three-minute cadence. This is a count limit, not an expiry rule: older pending observations are never discarded merely to admit newer ones.
The collection module attempts recovery and checks capacity before invoking acquisition. If capacity remains exhausted, the collection is unsuccessful and visibly explains why no new observation was acquired; normal scheduled recovery attempts continue. This deliberately accepts a gap in new observations rather than unlimited pending growth or loss of already retained evidence. It is not a deliberate disable, changes no monitoring intent, and never permits fabricated activity across the gap.
@@ -0,0 +1,29 @@
# 12. Staged projection with an evidence ladder
Status: Accepted. Amends [ADR 0002](0002-projection-model-sustained-regime.md) §2, §6, §8 and §11, and [ADR 0003](0003-service-lifecycle-and-sanctioned-toggle.md) §4 and §8 for the TUI baseline entry.
## Context
Under ADR 0002 as implemented, a fresh install showed no usage-adjusted theoretical lifespan until a manual baseline existed and a complete local observation day had passed. Confidence then stayed Limited until two scenario horizons existed, about 28 days. Users read this as Fenris being slow and its confidence never moving. The architecture review of 2026-10-02 also found that the TUI and CLI each pre-gated the projection with rules the projection itself does not use.
## Decision
1. **Projection stages.** The projection reports one projection stage: no observations yet, provisional, warming, limited, supported, or unavailable. Provisional and warming add to ADR 0002's three confidence states, which remain the categorical judgement once warm-up ends. Callers render the stage. They never pre-gate.
2. **First number after 3 observed hours.** A provisional projection appears once at least 3 hours of hour observations with samples exist. Until 24 hours are observed, it carries the fact "daily cycle not yet seen". From 24 hours until ADR 0002's 14-qualifying-day warm-up completes, the stage is warming. The complete-local-day condition is a contributing fact, not a gate. The rate formula is unchanged; only the evidence span is shorter.
3. **Short horizons.** Until a 7-day horizon is covered, the scenario range uses 24-hour and 3-day horizons, each shown only once covered. They drop out when the 7-day horizon exists. The scenario range remains the only spread shown, and still no statistical interval appears.
4. **Horizon agreement over existing horizons.** As ADR 0002 §8 already says, agreement is judged across the horizons that exist. A single 7-day horizon passes, so Supported is reachable at 14 qualifying days when every other condition holds.
5. **Evidence ladder.** Each Supported condition from ADR 0002 §8 is reported as met or unmet with its reason. The headline shows a count of conditions met, and the outlook shows the full ladder. Confidence is never a percentage.
6. **Write rate without a baseline.** When no endurance baseline applies, the sustained-regime write rate is shown in place of a lifespan. No baseline is synthesized.
7. **TUI baseline entry.** The TUI offers an unverified-override entry: rated TBW plus an optional source URL. It is persisted through `fenris-monitor baseline set` under the existing polkit action. Verified overrides with full provenance stay CLI-only.
## Considered options
- **Numeric confidence percentage.** Rejected: it implies precision the evidence cannot support, and ADR 0002 §8 already rejected it.
- **Capacity-based default baseline.** Rejected: ADR 0002 dropped `capacity × 600`, and a fabricated baseline would mislabel the lifespan.
- **Keep the complete-local-day gate.** Rejected: it delays the first number by up to 34 hours while adding nothing the provisional label and spread do not already disclose.
## Consequences
- Early lifespans swing widely. The provisional label, the hours observed and the short-horizon spread carry that honestly.
- The projection module's interface becomes the single test surface for every gating rule.
- ADR 0002 §10's implied baseline still needs two Percentage Used increments. Entering a rated TBW remains the fast path to a lifespan.
+43 -42
View File
@@ -12,7 +12,7 @@ and destruction.
| UID | `Fenris Packaging <packaging@bongbetic.com>` |
| Expiry | 2 years from creation |
| Hierarchy | Single key — no master/subkey split (single maintainer, manual builds) |
| Private key storage | Password manager only |
| Private key storage | Gitea repository Actions secret `GPG_PRIVATE_KEY` |
| Public key storage | `packaging/keys/fenris-packaging.asc` in-repo, release notes, docs |
| Keyservers | Never — TOFU-over-TLS via raw URL |
@@ -37,71 +37,72 @@ gpg --armor --export packaging@bongbetic.com > packaging/keys/fenris-packaging.a
gpg --fingerprint packaging@bongbetic.com
```
Save the **private key** to the password manager immediately:
Provision the **private key** as the repository Actions secret `GPG_PRIVATE_KEY`.
Run the export on the trusted key-generation machine, then enter its output in
the Gitea repository's Actions secret settings. Do not save it in the checkout,
logs, or a runner directory. The release workflow checks its fingerprint
against the committed public key before signing.
```bash
gpg --armor --export-secret-keys packaging@bongbetic.com
```
Then **delete the private key from the local keyring** — it must never persist
on any build host:
After provisioning the secret, delete the private key from the key-generation
keyring:
```bash
gpg --delete-secret-keys packaging@bongbetic.com
gpg --delete-keys packaging@bongbetic.com
gpg --batch --yes --delete-secret-keys packaging@bongbetic.com
gpg --batch --yes --delete-keys packaging@bongbetic.com
```
The committed `fenris-packaging.asc` must contain the real public key (replace
the placeholder comments).
## XBPS signing key
XBPS uses a separate RSA 3072 key. Its private half is stored as the Gitea
repository Actions secret `XBPS_SIGNING_KEY`. The corresponding public key is
published at
`https://git.bongbetic.com/xavierk/Fenris-xbps/raw/branch/stable/keys/fenris-xbps-signing.pub`,
with fingerprint `SHA256:AvPMRlKMikPg75u0iKr8AUkxlfU/Ad4k/S4o2M9W4/w`.
The secret must match that public key.
The release workflow writes the key to `~/.ssh/id_xbps` to sign the XBPS
package. A requested XBPS publication also uses the key to sign repository
metadata. A final `always()` cleanup removes the runner copy after publication
and release asset upload, including when an earlier step fails.
## Per-release signing flow
Each release performs: **import → sign → delete**. The private key is never
stored on disk longer than the release takes.
Each tagged release performs: **import → verify → sign → delete** on the
repository-scoped Gitea Actions runner. The Gitea secret remains configured;
the runner's keyring copy is removed after the job.
### Step 1: Import the private key
### Step 1: Push the release tag
Retrieve the private key from the password manager and import it:
After updating the version and dated changelog section, push the matching tag:
```bash
gpg --import /tmp/packaging-key-private.asc
rm /f /tmp/packaging-key-private.asc # Shred if possible
git push origin v<version>
```
### Step 2: Build and sign packages
### Step 2: Build, verify, and sign packages
The Makefile target `make release` handles signing automatically when the
key is in the keyring:
The release workflow imports `GPG_PRIVATE_KEY`, checks it against
`packaging/keys/fenris-packaging.asc`, builds packages, signs the RPM and
clearsigned checksum manifest, validates both, and publishes the release. The
workflow imports `XBPS_SIGNING_KEY` separately and signs the XBPS package.
XBPS publication is optional and also signs repository metadata; it requires
host acceptance and explicit selection during workflow dispatch.
```bash
make release # builds, signs RPM, clearsigns SHA256SUMS, prints upload steps
```
### Step 3: Verify runner cleanup
Under the hood:
The workflow's `always()` cleanup removes the GPG key from the runner's keyring
and deletes `~/.ssh/id_xbps`, including after a failed job. Confirm no signing
key remains on the runner after the release job.
1. `rpmsign --addsign` signs the RPM payload with the packaging key
(invoked by `make sign-rpm`).
2. `sha256sum` generates the checksum manifest.
3. `gpg --clearsign` produces `SHA256SUMS.asc` with the packaging key.
### Step 3: Delete the private key
Immediately after signing:
```bash
gpg --delete-secret-keys packaging@bongbetic.com
gpg --delete-keys packaging@bongbetic.com
```
Verify the key is gone:
```bash
gpg --list-keys packaging@bongbetic.com
# Should produce: gpg: keyblock resource ...: No such file or directory
```
The entire import → sign → delete cycle should take minutes. The private key
must never be left in any keyring between releases.
The Gitea Actions secret remains the approved signing source. Do not copy it to
the runner or repository outside the workflow.
## Key rotation (outline)
+67
View File
@@ -0,0 +1,67 @@
# Glint dashboard design adoption
Research date: 2026-09-19. Status: feasibility findings with interview decisions in progress. This note does not authorize implementation or change Fenris's accepted behavior.
## Finding
Fenris can adopt a Glint-inspired terminal dashboard through an independently authored redesign of its existing Textual presentation layer. Glint is a Rust terminal application, not an HTML/CSS dashboard or a drop-in Textual component library. Its most relevant ideas are a compact pane grid, consistent title rows and metadata, obvious focus, restrained colors, and enlarging a focused pane. A wholesale Glint integration would introduce a different UI runtime, unrelated application infrastructure, and a licensing decision without being necessary to achieve this visual direction.
The user has selected **Chalktone / screenshot 3**, **a large activity chart as the primary panel**, and **panel styling plus keyboard focus and zoom**. The activity panel has **Live / Day / History tabs**; enlarging a panel retains a **fixed monitoring status, freshness, and control strip**. A general dashboard builder is outside the selected scope. The developing product specification is [Glint dashboard design](../spec/glint-dashboard-design.md).
## Primary-source snapshot
Inspected Glint's default branch at commit [`c1d73d3e8ead2f4069630b2a237306af8f6e69c8`](https://github.com/ntrospect0/glint/tree/c1d73d3e8ead2f4069630b2a237306af8f6e69c8), committed 2026-07-19. A shallow reference checkout was created outside Fenris at `/tmp/fenris-glint-reference`.
- [README](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/README.md) contains three dashboard screenshots, a setup screenshot, and a live-capture link. The first two dashboard screenshots use `tokyonight`; the third uses `chalktone`. They show example compositions, not one mandatory layout.
- [Screenshot 1](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/docs/screenshots/glint-demo1.png), [screenshot 2](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/docs/screenshots/glint-demo2.png), [screenshot 3](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/docs/screenshots/glint-demo3.png), and [setup screenshot](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/docs/screenshots/glint-setup.png) are versioned in the repository. This source investigation does not substitute for visual inspection of those images.
- [Cargo.toml](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/Cargo.toml) declares Rust edition 2021, package version 0.5.0, Ratatui 0.28, Crossterm 0.28, and a `glint` binary. It also brings Tokio, HTTP clients, configuration/watch infrastructure, and optional widget dependencies.
- [GitHub repository API](https://api.github.com/repos/ntrospect0/glint), checked on the research date, reports an unarchived repository created 2026-05-27. [GitHub releases API](https://api.github.com/repos/ntrospect0/glint/releases) returned no releases. The [changelog](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/CHANGELOG.md) labels 0.5.0 and 0.4.0 unreleased, and README installation is from source. This is evidence of a young project and its distribution state, not proof that it is abandoned.
- [CI configuration](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/.github/workflows/ci.yml) runs Cargo tests and Clippy on Ubuntu; Clippy warnings do not fail CI. Source review alone does not establish that current CI passes. No Glint build or test run was performed for this assessment.
## What can transfer
| Glint pattern and source | Fit for Fenris | Scope implication |
| --- | --- | --- |
| Pane grid with row/column spans: [layout model](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/src/config/layout.rs), [default configuration](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/src/config/defaults/config.toml). | Fenris already uses a Textual grid and rounded bordered panes. A considered re-layout can use that existing ownership. | Decide which Fenris facts deserve persistent space. A fixed layout is much smaller than Glint's user-composable dashboard system. |
| Title integrated into the border, right-aligned metadata, focus treatment, shortcut indication: [UI title helpers](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/src/ui/mod.rs). | Useful for clearly named activity, history, drive, and monitoring panes. Date/range/freshness can become concise pane metadata where legible. | Independently implement the visible behavior in Fenris; do not translate or copy these GPL helpers. Ensure essential evidence is not merely truncated away. |
| Semantic theme roles separating border, title, metadata, and text: [theme model](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/src/theme/mod.rs), [bundled schemes](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/src/config/defaults/colorschemes.toml). | Fenris already owns themes in `src/fenris/themes.py`. Extend that role system for a chosen visual direction while keeping semantic status colors and text. | Select the preferred screenshot/palette before making the default. High Contrast and reduced-motion preferences already exist and must remain usable. |
| Tab/click focus, keyboard shortcuts, and focused-pane enlargement: [README controls](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/README.md), [app focus/zoom ownership](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/src/app.rs). | Particularly useful for graphs when a small terminal limits detail. | Zoom is new interaction behavior, not just styling. Selection/date/focus must survive entry, exit, resize, and refresh; essential fault/pause state needs a visibility decision. |
| Multiple widgets stacked in one cell, cycled with `.`/`,`: [stack implementation](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/src/widgets/stack.rs). | A possible way to expose secondary details without permanent screen cost. | Hiding monitoring state, confidence, or evidence behind inactive tabs would weaken Fenris's current contract. Choose deliberately; not assumed needed. |
| Responsive views exposing more detail at larger sizes: [view tiers](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/src/widgets/view_tier.rs), [widget author guide](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/docs/widget-sdk.md). | Useful principle, but Glint's breakpoints describe its content and are not Fenris requirements. | Preserve Fenris's constrained-terminal facts and controls, then design larger views. Do not simply reuse Glint's threshold values. |
| Compact bottom status bar: [status bar](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/src/ui/status_bar.rs). | Supports a quieter action/focus rail. | Fenris has real monitoring continuity, freshness, collection outcome, authentication, and quit semantics. Its footer cannot be reduced to Glint's version/clock/theme bar. |
Glint's calendar, stocks, news, email, weather, notes, galleries, external credentials, and profile/setup machinery are outside the stated Fenris redesign. Its widget SDK adds widgets inside the Rust application via a trait and registry; it is not an existing Python integration seam ([widget SDK](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/docs/widget-sdk.md), [widget interface](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/src/widgets/mod.rs)).
## Fenris ownership and constraints
The present application is Python with Textual, pinned to Textual 8.2.8 in [`requirements.txt`](../../requirements.txt). [`FenrisTuiApp`](../../src/fenris/tui.py) already owns the grid, header, bordered panes, compact layout, help, action bindings, and theme registration; `DailyBarGraph` and `LiveActivityGraph` already own graph selection/rendering. [`themes.py`](../../src/fenris/themes.py) owns Amber, Nord, High Contrast, and status color precedence. These are the natural extension points; a new backend or service is unnecessary for a visual redesign.
The design must preserve these existing responsibilities and evidence semantics:
- The collector owns privileged drive acquisition and observation-store writes; the TUI is an unprivileged reader and dispatches administrative actions through the sanctioned helper. A visual redesign does not imply a second sampler or store writer. Sources: [`CONTEXT.md`](../../CONTEXT.md), [redesign spec](../spec/fenris-redesign.md), [`tui.py`](../../src/fenris/tui.py).
- Usage-adjusted theoretical lifespan remains a write-endurance projection, accompanied by projection confidence and supporting facts. It must not become a physical failure countdown. Unknown, unavailable, warming, stale, and store-fault states require meaningful presentation. Sources: [`CONTEXT.md`](../../CONTEXT.md), [projection ADR](../adr/0002-projection-model-sustained-regime.md), [live activity specification](../spec/live-drive-activity.md).
- Gaps must not become zeros, and unavailable measurements must not become decorative smooth curves. Local-day labels, read/write units, incomplete evidence, selected dates, and timezone context matter. Sources: [local-day history ADR](../adr/0010-local-day-activity-history.md), [live activity specification](../spec/live-drive-activity.md).
- Pause is a deliberate disable; quitting only leaves the TUI. Monitoring continuity, last collection outcome, and freshness must remain understandable. Sources: [service lifecycle ADR](../adr/0003-service-lifecycle-and-sanctioned-toggle.md), [dashboard clarity specification](../spec/dashboard-clarity.md), [`status_composition.py`](../../src/fenris/status_composition.py).
- The current source switches to constrained presentation below 80 columns or 24 rows; the accepted live-activity specification requires daily totals, dates, evidence labels, confidence, and controls to remain accessible on narrow terminals. A new design must be reviewed against real target dimensions. Sources: [`tui.py`](../../src/fenris/tui.py), [live activity specification](../spec/live-drive-activity.md).
The fixed Panes information architecture is normative in the older [redesign specification, section 7](../spec/fenris-redesign.md), and [dashboard clarity](../spec/dashboard-clarity.md) says it preserves that architecture and prescribes a separate quit rail. A significantly different pane hierarchy or footer should explicitly amend those presentation decisions when the user selects the new design, rather than silently treating old contracts as irrelevant. The underlying evidence and action semantics can be retained.
## License boundary
Glint declares **GPL-3.0-or-later** in [Cargo.toml](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/Cargo.toml) and source SPDX headers. Its [README license section](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/README.md) says distributed modified versions must remain GPL-licensed with copyright notices, and its [LICENSE](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/LICENSE) specifies the distribution obligations. Fenris deliberately uses MIT across source and packages under [ADR 0009](../adr/0009-mit-license.md).
Copying or translating Glint implementation into an integrated distributed Fenris would therefore raise GPL compliance and licensing choices that conflict with keeping the combined implementation solely MIT. It is not an adoption route to take implicitly. The recommended route is independently authored Fenris UI code using common layout/interaction ideas observed in Glint, without copying Glint code or assets. If direct implementation reuse becomes a requirement, resolve licensing or separate permission first; this research does not establish that permission.
## Interview state
The selected direction is an independently implemented Chalktone-inspired dashboard with a fixed Fenris layout, an activity-first hierarchy, keyboard focus and enlargement, one activity panel with Live / Day / History tabs, and a persistent monitoring status/freshness/control strip during enlargement. Existing 80×24 support and constrained reflow are retained rather than reopened as a new minimum-size decision. The [developing design specification](../spec/glint-dashboard-design.md) owns the precise requirements and any remaining decisions; this research note is supporting evidence.
No product code, glossary, ADR, or existing acceptance criteria were changed by this investigation. Keyboard focus/zoom feasibility does not establish that exact interaction behavior has been implemented or runtime-validated in Fenris.
## Validation scope
Evidence comes from the pinned Glint source, its checked-in documentation/screenshots, GitHub's first-party API, and Fenris's current source and accepted documents. The main design investigation visually inspected screenshots 1 and 3 and separately verified Textual grid documentation.
The main investigation also reviewed synthetic renders of current Fenris at 140×44 and 80×24, after refresh cleared the launch authentication banner, using installed Textual 8.2.7 (the repository lock is 8.2.8). The captures are `/tmp/fenris-current-capture-3xdv46jm/fenris-current-140x44.png` and `/tmp/fenris-current-capture-3xdv46jm/fenris-current-80x24.png`. They showed a large blank vertical area; at 80×24 the drive, monitoring status, and actions fell below the initial viewport. This supports rearranging the panels and keeping the status/control strip visible, but is not a diagnosis of the deployed drive or proof across every state.
No Glint runtime was executed, and no Glint asset was copied into the product. No accessibility audit was conducted. This research establishes architectural feasibility and records decisions; it does not claim pixel fidelity, a working integration, an upstream test result, or a measured performance result.
+7 -1
View File
@@ -1,5 +1,11 @@
# Fenris dashboard clarity specification
**Presentation amendment (2026-09-19):** The accepted
[Glint-inspired dashboard design](glint-dashboard-design.md) supersedes the
preserved Panes arrangement and standalone heavy quit rail. Identity, continuity,
paused-state explanations, and the distinction between quitting and pausing
remain required; quit now has an explicit entry in the fixed controls.
**Status: decision-complete.** Assembled by [Assemble the dashboard clarity specification and close the map](https://git.bongbetic.com/xavierk/Fenris/issues/60) from the closed tickets of the Wayfinder map [Chart Fenris dashboard clarity](https://git.bongbetic.com/xavierk/Fenris/issues/55). This document is normative for the follow-up **execution effort**; nothing here is implemented by the map.
**Canonical roles.** The [redesign specification](fenris-redesign.md) (frozen) and [ADRs 0001–0007](../adr/) remain authoritative and untouched — this is a companion spec covering five dashboard clarity additions plus the changelog-driven release-notes mechanism. The [criteria register](acceptance-criteria.md) carries the testable statements: **DC-1–DC-8**, appended by this assembly, with **TUI-4's binding list amended** (§4). Terminology follows the glossary in [`CONTEXT.md`](../../CONTEXT.md), including *Deliberate disable* and *Release*.
@@ -119,4 +125,4 @@ This resolves the map's README-wording fog: the wording is decided here; the act
## 9. Out of scope
Executing any of this — code, tests, releases — and any TUI layout or information-architecture redesign beyond the five additions named above. Execution is a fresh effort after handoff.
Executing any of this — code, tests, releases — and any TUI layout or information-architecture redesign beyond the five additions named above. Execution is a fresh effort after handoff.
+6
View File
@@ -362,6 +362,12 @@ Never "82 % confidence" or "95 % accurate".
## 7. Panes TUI
**Presentation amendment (2026-09-19):** The accepted
[Glint-inspired dashboard design](glint-dashboard-design.md) supersedes this
section's panel arrangement and graph appearance with an activity-first layout,
dotted volume plots, Live / Day / History tabs, and focused-panel zoom. Shared
evidence, projection, authentication, and monitoring-action contracts still apply.
**Decisions:** [Prototype the TUI information architecture](https://git.bongbetic.com/xavierk/Fenris/issues/3) (Variant A adopted), [Evaluate Python TUI frameworks](https://git.bongbetic.com/xavierk/Fenris/issues/6) (Textual). **ADRs:** [0003](../adr/0003-service-lifecycle-and-sanctioned-toggle.md) §§8, 10; [0004](../adr/0004-install-upgrade-removal-lifecycle.md) §10. **Criteria:** TUI-1–TUI-4, CI-1, CI-2, CI-4. The [prototype](https://git.bongbetic.com/xavierk/Fenris/src/branch/prototype/tui-information-architecture/prototype/tui-ia) is visual reference only; this section is normative.
### 7.1 Framework and floor
+149
View File
@@ -0,0 +1,149 @@
# Glint-inspired Fenris dashboard
Status: accepted on 2026-09-19. The user confirmed the complete design and
additionally requested Glint-style plotted graphs in place of block bars.
## Purpose and reference
Adopt the visual approach of [Glint](https://github.com/ntrospect0/glint) for
Fenris's terminal dashboard. The selected visual reference is the third README
screenshot, using Chalktone, at upstream commit
`c1d73d3e8ead2f4069630b2a237306af8f6e69c8`:
[reference screenshot](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/docs/screenshots/glint-demo3.png).
The source and architectural assessment is recorded in
[the research note](../research/glint-dashboard-adoption.md).
## Confirmed user choices
- Adopt panel styling, keyboard focus, and panel zoom. A freely configurable
dashboard builder is outside the selected scope.
- Give the live read/write activity chart the largest opening-screen area,
with endurance outlook and monitoring state visible alongside it.
- Use the muted Chalktone appearance from Glint's third screenshot as the
visual direction.
- Put Live / Day / History views inside one activity panel, keeping both
read/write totals and the selected date visible.
- Expand a focused panel within the dashboard while retaining a fixed strip
for monitoring state, observation freshness, and essential controls.
- Replace block bars with thin dotted time-series plots resembling Glint's
chart. Label volume and time axes, retain exact selected-point readouts,
and leave explicit breaks for missing or incompatible evidence. Joining
adjacent measured points is visual guidance, not additional observations.
## Implementation recommendation
Implement the selected visual and interaction patterns independently in
Fenris's existing Python/Textual presentation layer. Retain the collector,
observation store, shared status/projection contracts, and authenticated control
path. Glint is a Rust/Ratatui application under GPL-3.0-or-later; copying or
porting its implementation into Fenris would require a separate licensing
decision under [ADR 0009](../adr/0009-mit-license.md). Referencing general panel
and navigation patterns does not require adopting its application architecture.
Use thin borders, compact titles, an explicit focused-panel indicator, and
restrained cream/earth-tone accents. Preserve readable contrast and semantic
state labels; the reference's dim secondary text is not a readability target.
Use Fenris content and identity, without Glint's unrelated clock, weather,
finance, email, or gallery features.
## Existing behavior to preserve
- Activity is measured read/write volume, with both totals accessible and
writes selected initially. A visual stock-chart reference must not turn
interval volume into speed or imply continuity through unknown evidence.
- Preserve the latest-three-hour opening view, date selection, local-day and
timezone labels, available history precision, and historical selection across
refresh under [live drive activity](live-drive-activity.md).
- Preserve the usage-adjusted theoretical lifespan and categorical projection
confidence with contributing facts. Navigation and graph selection cannot
change the projection evidence window or endurance accounting.
- Preserve explicit zero, gap, incomplete, unallocated, unavailable, paused,
stale, collection-failure, and store-fault states.
- Keep boot enablement, runtime activity, collection outcome, and observation
freshness distinct. Quit leaves background monitoring running; pause remains
a deliberate disable through the existing control path.
- Preserve keyboard and mouse access, constrained-terminal text/reflow,
high-contrast availability, reduced motion, help, and disclosures.
## Concrete layout and interaction proposal
- A compact identity header above one dashboard workspace.
- A narrow supporting column for the endurance outlook and drive facts;
a wide activity panel receives the remaining workspace. On the normal
dashboard, confidence and monitoring state remain visible beside activity.
- Live starts with the latest three hours; Day exposes the selected local
day's available detail; History exposes the retained longer-term evidence.
Reuse existing measurements and ranges rather than introducing a new data
model. Show selected date/timezone, read/write totals, measurement, and
evidence state wherever applicable.
- Use visible, clickable tabs; Tab/Shift+Tab and mouse clicks move focus.
Use `z` to toggle focused-panel zoom and Escape to restore the dashboard
when an input/dialog is not consuming Escape. Preserve selection, date,
range, measurement, and focus across zoom and refresh.
- Keep the bottom status/control area outside the expanding workspace. It
may wrap when required: compactness cannot merge the four service facts or
hide a fault/pause state. Keep quit distinct from pause in wording and
behavior without spending three full rows on a separate heavy quit box.
- Use existing date-navigation and read/write shortcuts, with visible help
updated for tabs, focus, and zoom. Text entry must consume its own keys.
- Use Chalktone-inspired styling as the new default while retaining existing
selectable themes, High Contrast, and reduced motion. Preserve an explicitly
saved theme preference during upgrade.
- Design for 80×24 and larger, with existing text/reflow behavior below that
size. Essential facts and actions must remain accessible; the large-screen
reference does not require squeezing its entire density into small terminals.
These defaults implement the confirmed choices. Date entry and inspection
retain existing evidence precision: every time is shown in local time with its
timezone label and a secondary UTC line on inspection, daily bars read
local-day evidence, and local-day totals keep their recorded timezone. This
visual redesign does not manufacture finer precision than the evidence holds.
## Smallest sufficient implementation proof
Render normal and zoomed views at a representative large terminal and 80×24,
plus a constrained terminal. Check focus/tab/zoom/date/read-write interactions
and selection persistence through refresh. Exercise paused, stale, store-fault,
missing-baseline, and incomplete-evidence displays using existing synthetic
stores and headless TUI patterns. Verify quit never invokes monitoring control
and existing action tests still cover the sanctioned helper. UI fixtures are
not evidence of the user's deployed drive state.
## Specification reconciliation
This accepted redesign supersedes conflicting presentation requirements in
[the original Panes specification](fenris-redesign.md#7-panes-tui) and
[dashboard clarity](dashboard-clarity.md): the full-width headline becomes a
supporting endurance panel, the history/live plots share a tabbed activity
panel, and a compact fixed control row replaces the heavy standalone quit
rail. The block-bar requirement is superseded by dotted volume plots. Their
behavioral requirements, including clear quit-versus-pause semantics, remain.
The live-drive-activity specification excludes unrelated redesign from that
earlier task. This is a separate design request, not permission to undo its
accepted data, date-navigation, evidence, or forecast behavior. Existing design
documents and source may differ in implementation status; this assessment is
not proof that every earlier acceptance criterion has shipped.
## Documentation scope
No new domain term has been resolved: panel, focus, zoom, and theme are general
interface concepts and do not belong in the domain glossary. No new ADR is
needed for a reversible presentation change that retains the existing stack,
license, data model, and privilege boundary. Record any later durable
architectural trade-off separately if one emerges.
## Implementation validation
Implemented in Fenris's existing presentation layer with Textual 8.2.8.
The full suite passed 801 tests; 43 packaging/signing checks were skipped for
missing package artifacts or signing tools. After the final incomplete-evidence
fixes, all 81 focused dashboard, history, and plot tests passed. New renderer
and regression files pass configured Ruff checks; affected production files
pass correctness lint and the diff passes whitespace checks. Existing broader
lint warnings were not part of this redesign.
Rendered normal and zoomed dashboards at 140×44, plus normal 80×24 and
constrained 70×20 views. The [saved preview](../../assets/dashboard-chalktone.png)
uses synthetic observations. No installation or release was performed.
+117
View File
@@ -0,0 +1,117 @@
## Problem Statement
Fenris users cannot readily understand how much data their monitored drive reads and writes each day, inspect recent activity as it arrives, or select historical dates from the keyboard. Existing graph shortcuts depend on focus, date navigation is limited, collection and screen refresh default to five minutes, and the graph reads hourly/daily summaries rather than three-minute activity. Source inspection also identified derivation and rendering paths that can leave displayed history incomplete or stale despite newly stored readings.
Users want daily data volume as the primary measurement because writes contribute to drive endurance. They also want an understandable end-of-life outlook even with limited history, without presenting an unsupported hardware-failure prediction or a misleading numerical confidence score.
## Solution
Show both local-day read and write totals, updating from background collection targeted every three minutes. Open on a live last-three-hours graph of written data volume, provide a read/write toggle, and support selected-day and longer-history views. Make date navigation discoverable through visible keyboard hints and clickable equivalents.
Retain three-minute detail for 14 days, followed by durable hourly/daily summaries. Preserve the timezone and boundaries of historical local-day summaries, identify incomplete evidence, and never invent missing activity or divide midnight-spanning measurements by assumption.
Show the usage-adjusted theoretical lifespan after one full local calendar day of observations, provided an applicable endurance baseline and usable write rate exist. Explain that this estimates remaining write endurance if observed habits continue, not a physical failure date. Present categorical projection confidence with contributing facts, including limited-history reasons.
## User Stories
1. As a drive owner, I want to see the amount of data written each local day, so that I can understand the activity that contributes to write endurance.
2. As a drive owner, I want to see the amount of data read each local day, so that I can understand the drive's broader activity.
3. As a drive owner, I want read and write totals shown separately, so that reads are not mistaken for writes consuming the endurance allowance.
4. As a TUI user, I want data-volume units rather than transfer speed as the primary graph measurement, so that the graph answers how much data was transferred.
5. As a TUI user, I want today's totals labelled as totals so far, so that a partial day is not presented as a completed day's usage.
6. As a TUI user, I want the opening graph to show written volume over the last three hours, so that recent activity is visible immediately.
7. As a TUI user, I want to switch the graph between writes and reads while both daily totals remain visible, so that I can inspect either measurement without losing context.
8. As a TUI user, I want each new activity point to include transfers between readings, so that brief bursts between collection runs contribute to the graph.
9. As a TUI user, I want new measurements approximately every three minutes, so that the recent-activity view stays useful while I work.
10. As a user who closes the TUI, I want collection to continue in the background, so that reopening it shows activity gathered while I was away.
11. As a user who reboots, I want observation history and the established monitoring lifecycle preserved, so that restarting does not erase activity or confuse monitoring state.
12. As a keyboard user, I want `[` and `]` to select the previous and next day, so that browsing nearby dates takes one action.
13. As a keyboard user, I want `g` to open a date field, so that I can jump directly to a historical date.
14. As a keyboard user, I want `t` to return to today and the live view, so that I can leave historical browsing immediately.
15. As a keyboard user, I want arrows to inspect graph points, so that I can read the volume, time, and evidence state behind a point.
16. As a new TUI user, I want visible shortcut hints that work from normal launch, so that I do not have to discover an invisible graph-focus prerequisite.
17. As a mouse user, I want clickable equivalents for navigation and the read/write toggle, so that I can use the same features without memorizing shortcuts.
18. As a user entering a date, I want typing and cancelling to remain within the date-entry workflow, so that graph or monitoring shortcuts do not fire accidentally.
19. As a user browsing history, I want background refresh to preserve my selected date and measurement, so that new activity does not interrupt inspection.
20. As a user inspecting daily totals, I want to switch between recent detail, a selected day, and longer history, so that I can understand both short bursts and daily habits.
21. As a user outside UTC, I want calendar navigation and timestamps expressed in labelled local time, so that the displayed day matches my calendar.
22. As a user in a timezone with a half-hour offset, I want local-day totals based on the correct midnight boundary, so that relabelled UTC totals do not misrepresent my day.
23. As a user experiencing a daylight-saving transition, I want real local-day boundaries respected, so that a 23-hour or 25-hour day remains understandable.
24. As a user who changes system timezone, I want historical summaries to retain their recorded timezone and boundaries, so that old totals do not silently change meaning.
25. As a user reviewing recent history, I want three-minute detail available for 14 days, so that I can investigate recent usage.
26. As a user reviewing older history, I want durable hourly/daily summaries after detailed readings expire, so that long-term activity remains available.
27. As a user with legacy history, I want dates that cannot be reconstructed at local-day precision labelled incomplete or unavailable, so that old summaries are not presented with invented precision.
28. As a user whose readings straddle midnight, I want the measured volume preserved once and its uncertain day allocation explained, so that it is neither lost nor counted twice.
29. As a new user with only one reading, I want an awaiting-another-reading state, so that a missing interval is not shown as zero activity.
30. As a user with a measured zero-activity interval, I want zero distinguished from missing evidence, so that a quiet drive is not confused with a collection failure.
31. As a user with missed collection runs, I want gaps, actual timestamps, and freshness facts, so that the graph does not imply measurements that never occurred.
32. As a user who deliberately pauses monitoring, I want paused time excluded according to monitoring-period rules, so that the pause does not distort the observed usage habit.
33. As a user whose controller resets or changes, I want counter and identity boundaries respected, so that unrelated readings do not create invalid activity or lifespan estimates.
34. As a user with a store fault, I want a clear explanation instead of guessed history, so that I know which information cannot be trusted.
35. As a drive owner, I want an estimate of the time until remaining write endurance is consumed, so that I can plan around my observed usage habit.
36. As a new user, I want that estimate withheld until one full local calendar day has been observed, so that a few minutes of activity do not immediately produce a lifespan number.
37. As a user who starts monitoring at noon, I want the partial first day excluded from the full-day gate, so that 24 hours since launch is not mistaken for a complete calendar day.
38. As a user with one complete day but limited history, I want an estimate when the other inputs support it, so that I do not have to wait for high confidence before seeing an outlook.
39. As a user with limited evidence, I want a confidence category and concrete reasons, so that I understand why an estimate may change without being shown an unjustified percentage.
40. As a user without an applicable endurance baseline or usable write rate, I want the missing prerequisite explained, so that unavailable evidence is not disguised as merely low confidence.
41. As a drive owner, I want the endurance estimate distinguished from a physical failure date, so that I do not treat a write-endurance projection as a hardware guarantee.
42. As a CLI user, I want status and the TUI to agree on forecast eligibility and confidence, so that choosing a different interface does not change the facts.
43. As a user on a narrow terminal, I want daily totals, dates, evidence labels, and controls to remain accessible, so that terminal size does not hide the information I need.
44. As a user of existing themes and reduced-motion settings, I want those features preserved when navigation shortcuts change, so that better date controls do not remove accessibility or preferences.
45. As a user upgrading Fenris, I want migration, repair, refresh, and pruning to preserve measured evidence without duplication, so that the new graph can be trusted across restarts and upgrades.
## Implementation Decisions
- Extend the existing collector, observation store, derivation, shared status/projection, service scheduling, and TUI responsibilities. The privileged collector remains the single device-acquisition and store-write owner; the TUI remains an unprivileged reader. Do not add another sampler, service, export layer, or generic plotting abstraction.
- Set the default background collection target to three minutes on both systemd and runit. Update shared cadence/freshness semantics, scheduler definitions, user documentation, and relevant acceptance contracts together. Use actual observation timestamps: delayed runs must not be represented as perfectly spaced measurements or synthetic catch-up points.
- Derive interval read/write volumes from compatible cumulative counters. A first reading is an anchor, not an interval. Preserve controller-segment and monitoring-period boundaries, measured zero, unknown time, deliberate disables, and unsupported evidence. Do not compute deltas across a controller identity or counter discontinuity.
- Make successful collection publish consistent data for dependent read views, using the existing transactional and migration patterns. Ensure raw samples, affected summaries, and retained boundary evidence cannot disagree through partial publication. Validate source-level derivation gaps before implementing fixes; this spec does not claim a runtime diagnosis.
- Keep daily read and write totals separate. The write graph is the default; a read/write toggle changes only the displayed measurement. Each live point represents measured interval volume, not instantaneous speed. Daily totals remain visible independently of graph mode, and graph selection must not redefine the projection's evidence window.
- Open on the latest three hours. Offer selected-day and longer-history views at the precision supported by retained evidence. Selected-point readouts expose time, labelled timezone, data-volume units, and incomplete/gap state. Do not imply that a partial current day or an incompletely allocated day is a known full-day total.
- Use `[` / `]` for previous/next day, `g` for date entry, `t` for today/live, and arrows for point inspection, with visible hints and clickable equivalents. Date entry must handle invalid or unavailable dates visibly and must not leak keystrokes into unrelated actions. Cancelling returns to the prior selection; refresh preserves historical selection until the user changes it.
- Resolve the existing `t` theme binding in favor of the accepted today/live action. Preserve theme selection through a discoverable non-conflicting control and update help consistently. Preserve pause, resume, collect-now, disclosures, quit, and reduced-motion behavior; quitting still does not pause monitoring. No exact replacement theme shortcut was selected in the interview.
- Preserve UTC timestamps and the established UTC hour/day evidence used by endurance calculations. Local-day activity is a separate presentation aggregate using the actual local midnight boundaries, including non-whole-hour offsets and daylight-saving transitions. Timestamp relabelling alone is insufficient.
- Extend the existing versioned observation store to retain local-day read/write summaries with their controller identity/segment context, recorded timezone, local date, UTC boundaries, evidence state, and the information needed to explain unallocated boundary volume. These are required semantics, not prescribed table names or a finalized column layout. The collector derives durable summaries before source evidence is eligible for pruning.
- Retain three-minute detail for 14 days and hourly/daily summaries indefinitely. Keep the boundary evidence and anchors necessary for honest derivation. Do not introduce unbounded retention of all fine-grained intervals solely to support arbitrary historical timezone reinterpretation, and do not delete existing durable evidence merely to simplify migration.
- Preserve the timezone and boundaries recorded for historical summaries when the system timezone changes. New observations follow the current local timezone without rewriting old days; labels must make historical timezone context explicit. Do not blend differently bounded summaries into an apparently exact daily total.
- Allocate a measured interval to a local day only when evidence supports that allocation. Preserve an ambiguous midnight-spanning delta once as shared/unallocated boundary evidence; neither prorate it nor copy its full value into both days. If only coarse legacy UTC summaries survive, show them at their actual precision and mark unreconstructable local-day totals incomplete/unavailable. Repeated derivation and repair must be idempotent.
- Use the existing usage-adjusted theoretical lifespan model and categorical projection confidence. Forecast remaining write endurance from an applicable endurance baseline and a usable observed write rate. Lifetime-written counters consume the endurance allowance; observation-window deltas determine the usage rate. Read volume is not included in endurance consumption.
- Add the agreed complete-observation-day gate to the shared projection contract. A completed local midnight-to-midnight day within a monitoring period with usable evidence is required; a partial first day, deliberately disabled span, or day with no usable evidence cannot independently qualify. Monday-noon setup can first qualify at Wednesday 00:00 after observing Tuesday. Real daylight-saving day boundaries apply; do not replace this with a fixed 24-hour timer.
- Before that gate, explain that Fenris is waiting for a full local observation day. Afterward, allow an estimate with Limited confidence when the existing baseline, rate, identity, and evidence-validity rules permit it; preserve the longer warm-up and Supported-confidence requirements. Missing evidence is still evaluated under the existing validity/coverage rules: completing a date does not turn unknown measurements into known data.
- Show confidence as a category plus contributing facts, never a numeric confidence percentage. Explain missing baseline, unusable/zero rate, stale evidence, and controller changes through their applicable states. Do not fabricate a baseline, suppress a missing prerequisite behind a generic confidence warning, or label the output as a predicted hardware-failure date. Preserve the existing baseline setup path and zero-rate explanation.
- Preserve visible freshness, last-collection outcome, paused state, and store-fault behavior. On constrained terminals retain the existing textual fallback/reflow approach so both daily totals, the selected date, evidence state, confidence, and navigation remain accessible; colour alone must not carry meaning.
- Migrate through the existing ordered, versioned, transactional store mechanism. Preserve observation history, refuse unknown newer schemas, establish new durable evidence before pruning, and keep readers consistent while the collector writes. Migration cannot manufacture local precision from historical data that lacks it.
- This design amends ADR 0003's five-minute default on both native service backends under ADR 0008, adds a first-full-local-day eligibility gate to ADR 0002 while retaining its forecasting and confidence model, and extends ADR 0001 with the local-history decision recorded as ADR 0010. ADR 0005's no-fabrication/store-fault contract remains authoritative. Existing historical design documents are not evidence that the current runtime already implements these amendments.
## Testing Decisions
- Prefer one primary acceptance path: controlled acquisition fixtures and an injected clock enter the existing public collection operation; it writes a real temporary observation store; the normal read path supplies the TUI and CLI. Assert resulting displayed volumes, evidence states, selections, forecast eligibility, and public outcomes. Do not mock the internal derivation or query results under test. A good test would continue passing after an internal refactor and fail when a user's observed result becomes incorrect.
- Reuse existing collector tracer and collector-history tracer fixtures, including SMART JSON, a temporary sysfs tree, the injected clock, and a real SQLite store. Compose these with the existing status reader, projection fixtures, and headless TUI driver rather than creating a new production testing interface. Fix defects that this path reproduces; do not make tests reproduce incorrect implementation arithmetic.
- Exercise normal launch, previous/next date, direct date entry, invalid input, cancel, today/live, arrow inspection, read/write switching, refresh during historical browsing, and hourly/day navigation through user actions. Assert visible graph/readout results, not only private selection indices or row counts. In particular, do not manually force graph focus to make the advertised shortcuts work. Existing headless graph, help, theme, motion, and constrained-terminal tests provide prior art.
- Drive known successive read/write counter changes through collection and verify exact interval volumes and aggregate totals. Cover first reading, first compatible pair, measured zero, repeated collection, repeated read/refresh, restart, same-hour accumulation, and a day-boundary crossing. Preserve each measured byte once: attributable totals plus separately retained unallocated evidence must account for the measured delta without duplication.
- Use the same public path for local midnight, Asia/Kolkata's half-hour boundary, a 23-hour local day, a 25-hour local day, repeated local clock labels, and a system-timezone change. Verify historical labels and boundaries remain stable and ambiguous intervals are not silently divided. Add direct derivation cases only when they prove an invariant that the primary path cannot isolate clearly.
- Advance controlled time beyond the 14-day detail window, invoke normal pruning, restart readers, and verify recent detail expiry with durable summary/boundary preservation. Use existing pruning, repair, legacy-migration, and store-migration tests for focused persistence cases that the main path cannot prove, including interruption, reruns, concurrent readers, unknown newer schema, and inability to reconstruct legacy local-day precision.
- Test the forecast gate at concrete local times: Monday noon setup; Tuesday noon with 24 elapsed hours but no complete observed calendar day; and Wednesday 00:00 after a usable Tuesday. Include partial/disabled/unknown days and daylight-saving dates. After eligibility, verify Limited confidence with reasons and a correct estimate where inputs support one; no baseline, invalid counters, zero rate, stale evidence, and segment changes must preserve the relevant unavailable/degraded behavior. Reuse existing projection and status-parity tests.
- Independently calculate an expected endurance result from known lifetime-written counters, a known applicable baseline, and known observed-window deltas. This guards against confusing lifetime consumption with writes inside the rate window. Rendering, browsing another day, and switching to reads must not change that accounting.
- Add focused checks at the existing native scheduler boundary for the three-minute defaults, serialization, failures, and freshness calculations. Reuse systemd/runit control and packaging tests; use only necessary controlled platform probes to verify actual scheduled/background behavior. No live-system scheduling, pause, install, or reboot action is authorized merely by writing this spec. Report any unavailable platform verification during implementation.
- Verify at 80×24 and below that visible totals, date entry, navigation, confidence, and evidence states remain accessible and that theme/reduced-motion controls still work. Avoid pixel-perfect snapshots and assertions about private widget layout when visible text and action results express the contract.
- The affected modules are collection/derivation, observation-store migration and retention, shared projection/status, native service scheduling, and the TUI. Most acceptance coverage should remain at the single collection-to-visible-result path; targeted persistence and scheduler checks are supporting boundaries, not a new layer of test-only architecture.
## Out of Scope
- Adding SATA/HDD acquisition, another monitored device, or multi-drive management; the selected existing NVMe drive remains the source.
- Reporting occupied/free filesystem capacity, making transfer speed the primary measurement, or treating read bytes as write-endurance consumption.
- Forecasting today's final data volume or future daily activity; the selected forecast is the usage-adjusted theoretical lifespan.
- Predicting a physical hardware-failure date, adding numeric confidence percentages, fabricating endurance baselines, or introducing a new forecasting algorithm when the existing model fits.
- Interpolating missing samples, zero-filling unknown activity, prorating ambiguous midnight intervals, or retroactively rewriting historical timezone boundaries.
- Requiring all historical dates to retain three-minute detail forever or to support arbitrary later timezone regrouping.
- A new web UI, plotting service, generic graph framework, notifications, telemetry, theme redesign, or unrelated dashboard rework.
- Package publication, release creation, installation, host-monitoring changes, or runtime diagnosis of the user's deployed observation store as part of this specification task.
## Further Notes
- This spec synthesizes the completed design conversation. The user accepted daily volume, a live three-hour opening view, the specified keyboard date workflow, local dates, 14-day detail retention, a writes-first graph with read toggle and both totals, an endurance forecast, categorical confidence, one full local observation day before forecasting, and historically labelled timezone boundaries with honest incomplete evidence.
- Related prior issue: [Implement Fenris TUI polish and hourly history](https://git.bongbetic.com/xavierk/Fenris/issues/72). This newer accepted design supersedes its conflicting requirements for the opening graph, writes-only presentation, the `t` theme shortcut, unrestricted historical timezone regrouping, indefinite fine-grained interval retention, and the earlier projection-eligibility presentation. Preserve unrelated requirements for identity, status, themes, motion, accessibility, collector publication, evidence conservation, and CLI parity. Do not treat the older issue as a reason to undo these newly agreed choices.
- ADR 0010 records the deliberate trade-off: retain labelled local summaries and necessary boundary evidence before detailed readings expire, rather than retaining every fine-grained interval for arbitrary future timezone reinterpretation. The design adds durable local activity evidence while retaining UTC projection evidence and the existing collector/reader ownership boundary.
- Source inspection identified normal collection paths that do not consistently rebuild daily totals, existing-hour updates that omit read deltas, cross-day deltas added to both dates, and drill-down rendering overwritten by a loading placeholder. These are concrete investigation/regression targets, not claims of executed reproductions or deployed-store corruption.
- The user confirmed the existing collector-to-store-to-TUI/CLI test boundary, with focused migration/pruning and scheduler checks where needed. Publication is an implementation handoff; no application behavior or test result is claimed solely by creating this issue.
+4 -3
View File
@@ -44,7 +44,8 @@
- **RPM payload: signed.** rpmsign with the dedicated packaging key, invoked by `make sign-rpm` after the package is built. This is required, not optional: it is the only working dnf-native verification path.
- **deb: unsigned.** apt never verifies payload signatures; trust = instance-signed `InRelease` (signed-by keyring) + TLS + Acquire-By-Hash. Manual-download integrity is covered by SHA256SUMS.
- **SHA256SUMS: clearsigned** with the packaging key — the trust anchor for manually downloaded release assets, independent of TLS.
- **Packaging key:** single dedicated key, RSA 3072, UID `Fenris Packaging <packaging@bongbetic.com>`, 2-year expiry, no master/subkey hierarchy (single maintainer, manual builds). Private key lives in the password manager only; each release does import → sign → delete — nothing permanent on any build host. The full ceremony is documented in `docs/install/signing-key-ceremony.md`.
- **Packaging key:** single dedicated key, RSA 3072, UID `Fenris Packaging <packaging@bongbetic.com>`, 2-year expiry, no master/subkey hierarchy. The private key is stored as the repository Actions secret `GPG_PRIVATE_KEY`. The release workflow imports it on the self-hosted runner, verifies it against the in-repo public key, signs the RPM and SHA256SUMS, then deletes the runner's keyring copy in an `always()` cleanup step. The full ceremony is documented in `docs/install/signing-key-ceremony.md`.
- **XBPS key:** separate RSA 3072 key stored as the repository Actions secret `XBPS_SIGNING_KEY`; its public key and fingerprint are published in `Fenris-xbps`. The release workflow uses it for the XBPS package and, when publication is explicitly requested, the repository index. A final `always()` cleanup deletes its runner copy after publication and release asset upload.
- **Public key publication:** in-repo `packaging/keys/fenris-packaging.asc` (raw URL doubles as the `.repo` gpgkey target), release notes, docs page. No keyservers — TOFU-over-TLS.
- **Rotation (outline):** new key published alongside old; rpm signed with the new key; `fenris.repo` gpgkey lists both URLs (dnf accepts multiple); old key dropped after one release cycle. Procedure details stay in map fog.
@@ -53,9 +54,9 @@
- **A Release is:** a version tag, its packages in the channel, a Gitea release entry with notes, and a clearsigned SHA256SUMS — all together. **Bare tags are forbidden** (tag without packages + release entry is not a Release).
- **Cadence: on-demand.** Tag when user-visible changes or fixes accumulate; no calendar, no empty releases, no frequency SLA, no RC ceremony — fixes ship as a revision bump of the current version.
- **Versioning: plain semver.** Major = breaking CLI/config/unit change; store schema changes ride the natural bump (the forward-only refusal handles old-reader/new-store).
- **Promotion flow:** tag → `make release` (automated: `make package` → RPM signing via nfpm → SHA256SUMS generation → clearsign → prints registry PUTs + Gitea release steps). The ceremony is documented in `docs/install/signing-key-ceremony.md`.
- **Promotion flow:** bump `pyproject.toml` and the matching dated `CHANGELOG.md` section, then push tag `v<version>`. The repository-scoped Gitea Actions workflow builds and validates the deb, rpm, checksums, and release entry. XBPS publication remains a manual dispatch option after host acceptance. `make release` is for local artifact preparation and does not replace the tag workflow as the supported publication path. The ceremony is documented in `docs/install/signing-key-ceremony.md`.
- **Rollback:** installing an older package over a newer store is **unsupported** — the store's forward-only version refusal fails it by design. Documented rollback = restore the observation-store snapshot, then install the old Release. No automatic downgrade machinery exists or will be built.
- **CI:** no runners are registered on the instance today ([Actions runner research](https://git.bongbetic.com/xavierk/Fenris/issues/37)), so the manual flow above is primary. A dormant `.gitea/workflows/release.yml` (`on: push: tags: ['v*']`, single job, host-mode runner) is committed alongside; if it fires, it replicates `make release`. Cheapest future upgrade: one `act_runner` static binary in host-label mode on the existing Gitea host.
- **CI:** a repository-scoped self-hosted runner is registered and online (checked 2026-09-29). `.gitea/workflows/release.yml` is the tag-triggered release path; maintainers must confirm runner availability and required Gitea secrets before tagging. The workflow publishes deb/rpm packages and release assets; Void publication is withheld unless the signed XBPS host-release step is explicitly requested.
## 6. Package layout and ownership
+4
View File
@@ -14,6 +14,10 @@
# /usr/lib/tmpfiles.d/fenris.conf
set -euo pipefail
# Package directories must be traversable by every runtime user and agree
# with distribution-owned directories, regardless of the builder's umask.
umask 022
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
STAGE_DIR="${REPO_ROOT}/build/stage"
+5 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "fenris"
version = "0.4.0"
version = "0.7.0"
description = "NVMe wear monitor with persistent TUI"
requires-python = ">=3.10"
license = {file = "LICENSE"}
@@ -13,8 +13,12 @@ dev = [
"pytest>=7.0.0",
"pytest-cov>=4.0.0",
"pytest-asyncio>=0.20.0",
"ruff==0.16.10",
]
[tool.ruff.lint]
select = ["E4", "E7", "E9", "F"]
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
+1 -1
View File
@@ -1,2 +1,2 @@
"""Fenris: NVMe wear monitor with persistent TUI."""
__version__ = "0.4.0"
__version__ = "0.5.0"
+131
View File
@@ -0,0 +1,131 @@
"""A terminal volume plot shared by Fenris's activity views.
Braille provides two by four dots per terminal cell. Only adjacent, complete
measurements are connected; gaps and partial evidence never imply continuity.
The returned columns also place mouse inspection on the plotted time axis.
"""
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from rich.text import Text
@dataclass(frozen=True)
class VolumePoint:
time: float
volume: int | None
label: str
state: str = "measured"
def volume_plot(
points: Sequence[VolumePoint], width: int, height: int,
selected: int, colors: Mapping[str, str],
secondary: Sequence[VolumePoint] | None = None,
) -> tuple[Text, list[int], str]:
"""Render bounded axes and a dotted trace, without resampling evidence.
*secondary* is a second series over the same time axis (reads beside
writes); it shares the volume scale and draws in its own colour.
"""
width, height = max(12, width), max(4, height)
axis_width = 8
columns, rows = width - axis_width, height - 2
pixel_width, pixel_height = columns * 2, rows * 4
maximum = max(
(p.volume or 0 for p in (*points, *(secondary or ()))), default=0)
scale, unit = (1e12, "TB") if maximum >= 1e12 else (
(1e9, "GB") if maximum >= 1e9 else
(1e6, "MB") if maximum >= 1e6 else
(1e3, "KB") if maximum >= 1e3 else (1, "B")
)
ceiling = maximum or scale
start = points[0].time if points else 0
span = (points[-1].time - start) if len(points) > 1 else 0
xs = [round((p.time - start) / span * (pixel_width - 1)) if span else 0
for p in points]
cells = [[0] * columns for _ in range(rows)]
second = [[0] * columns for _ in range(rows)]
# Unicode braille dot numbering, indexed by y within cell and then x.
bits = ((1, 8), (2, 16), (4, 32), (64, 128))
def dot(x: int, y: int, grid: list[list[int]] = cells) -> None:
grid[y // 4][x // 2] |= bits[y % 4][x % 2]
def y_of(volume: int) -> int:
y = round((1 - volume / ceiling) * (pixel_height - 1))
return min(pixel_height - 1, max(0, y))
previous = None
markers = {}
for index, (point, x) in enumerate(zip(points, xs)):
if point.volume is None:
previous = None
if point.state != "future":
markers[x // 2] = "?"
continue
y = y_of(point.volume)
if previous is not None and point.state == "measured":
px, py = previous
steps = max(abs(x - px), abs(y - py), 1)
for step in range(steps + 1):
dot(round(px + (x - px) * step / steps),
round(py + (y - py) * step / steps))
dot(x, y)
previous = (x, y) if point.state == "measured" else None
if point.state != "measured":
markers[x // 2] = "~" if point.state == "partial" else "u"
elif point.volume == 0:
markers.setdefault(x // 2, "·")
previous = None
for point, x in zip(secondary or (), xs):
if point.volume is None:
previous = None
continue
y = y_of(point.volume)
if previous is not None and point.state == "measured":
px, py = previous
steps = max(abs(x - px), abs(y - py), 1)
for step in range(steps + 1):
dot(round(px + (x - px) * step / steps),
round(py + (y - py) * step / steps), second)
dot(x, y, second)
previous = (x, y) if point.state == "measured" else None
selected_column = xs[selected] // 2 if 0 <= selected < len(xs) else -1
result = Text(no_wrap=True, overflow="crop")
ticks = {0, rows // 2, rows - 1}
for row, values in enumerate(cells):
value = ceiling * (rows - 1 - row) / max(1, rows - 1) / scale
label = f"{value:6.2f}"[-6:] if row in ticks else " "
result.append(label + " │", style=colors["muted"])
for col, value in enumerate(values):
extra = second[row][col]
char = chr(0x2800 + (value | extra)) if value | extra else " "
style = colors["allocated"] if value or not extra else colors["secondary"]
value |= extra
if col == selected_column:
style = "bold " + colors["selection"]
if not value:
char = "┊"
style = colors["muted"]
result.append(char, style=style)
result.append("\n")
result.append(" └", style=colors["muted"])
for col in range(columns):
result.append("▼" if col == selected_column else markers.get(col, "─"),
style=colors["selection"] if col == selected_column else colors["muted"])
result.append("\n" + " " * axis_width)
labels = [" "] * columns
end_of_label = -1
for index in sorted({0, len(points) // 2, len(points) - 1}):
if index < 0 or not points:
continue
label = points[index].label[:columns]
column = max(0, min(columns - len(label), xs[index] // 2 - len(label) // 2))
if column > end_of_label:
labels[column:column + len(label)] = label
end_of_label = column + len(label)
result.append("".join(labels), style=colors["muted"])
return result, [axis_width + x // 2 for x in xs], unit
File diff suppressed because it is too large Load Diff
+140
View File
@@ -0,0 +1,140 @@
"""User choices for live and historical activity views."""
from collections.abc import Sequence
from dataclasses import dataclass
HISTORY_RANGE_DEFAULT = 14
HISTORY_RANGE_OPTIONS = (7, 14, 30, 90)
HISTORY_RANGE_MAX = max(HISTORY_RANGE_OPTIONS)
@dataclass(frozen=True)
class IntervalIdentity:
"""Stable identity for one measured interval."""
start_ts: str
end_ts: str
@dataclass(frozen=True)
class HistoryDayIdentity:
"""Stable identity for one recorded local-day summary."""
local_date: str
timezone: str
utc_start: str
utc_end: str
class ActivitySelection:
"""Keep activity navigation state separate from its Textual rendering."""
def __init__(self) -> None:
self.view = "live"
self.browse_date: str | None = None
self.selected_history_day: HistoryDayIdentity | None = None
self.selected_history_hour: str | None = None
self.history_range_days = HISTORY_RANGE_DEFAULT
self.measure = "written"
self.diagnostics = False
self.following_live = True
self.selected_live_interval: IntervalIdentity | None = None
self.live_interval_expired = False
def set_view(self, view: str) -> None:
"""Select live, day, or history view."""
if view not in ("live", "day", "history"):
raise ValueError(f"unknown activity view: {view}")
self.view = view
if view == "live":
self.follow_live()
def select_history_date(
self,
date: str | None,
identity: HistoryDayIdentity | None = None,
) -> None:
"""Keep requested date and evidence identity across refresh."""
if date != self.browse_date or (
identity is not None and identity != self.selected_history_day
):
self.selected_history_hour = None
if date != self.browse_date:
self.selected_history_day = None
self.browse_date = date
if identity is not None:
self.selected_history_day = identity
elif date is None:
self.selected_history_day = None
def select_history_hour(self, hour: str | None) -> None:
"""Keep the chosen historical hour across refresh."""
self.selected_history_hour = hour
def toggle_measure(self) -> str:
"""Switch read/write presentation without changing point selection."""
self.measure = "read" if self.measure == "written" else "written"
return self.measure
def toggle_diagnostics(self) -> bool:
"""Show or hide the Diagnostics panel and read-beside-write bars."""
self.diagnostics = not self.diagnostics
return self.diagnostics
def follow_live(self) -> None:
"""Resume following the newest interval and clear stale-pin notices."""
self.view = "live"
self.browse_date = None
self.selected_history_day = None
self.selected_history_hour = None
self.following_live = True
self.selected_live_interval = None
self.live_interval_expired = False
def update_live(self, intervals: Sequence[IntervalIdentity]) -> None:
"""Reconcile the selected interval with the rolling live window."""
if self.following_live:
self.selected_live_interval = intervals[-1] if intervals else None
return
if self.selected_live_interval in intervals:
return
self.following_live = True
self.selected_live_interval = intervals[-1] if intervals else None
self.live_interval_expired = True
def move_live(
self, intervals: Sequence[IntervalIdentity], offset: int,
) -> IntervalIdentity | None:
"""Inspect an adjacent interval by stable identity."""
if not intervals:
return None
if self.selected_live_interval in intervals:
current = intervals.index(self.selected_live_interval)
else:
current = len(intervals) - 1
selected = intervals[max(0, min(len(intervals) - 1, current + offset))]
self._pin_live_interval(selected)
return selected
def inspect_live(
self,
intervals: Sequence[IntervalIdentity],
interval: IntervalIdentity,
) -> None:
"""Pin the interval chosen by mouse inspection."""
if interval in intervals:
self._pin_live_interval(interval)
def selected_live_index(self, intervals: Sequence[IntervalIdentity]) -> int:
"""Return the selected point's current render index, or -1."""
if self.selected_live_interval is None:
return -1
try:
return intervals.index(self.selected_live_interval)
except ValueError:
return -1
def _pin_live_interval(self, interval: IntervalIdentity) -> None:
self.selected_live_interval = interval
self.following_live = False
self.live_interval_expired = False
+15 -11
View File
@@ -27,8 +27,7 @@ if VENV_DIR.exists():
if site_packages:
sys.path.insert(0, str(site_packages))
from fenris.store import init_store, get_store_path
from fenris.collector import run_collection
from fenris.collector import run_collection # noqa: E402 -- must follow the sys.path bootstrap above
CONFIG_PATH = Path("/etc/fenris/fenris.conf")
@@ -103,14 +102,6 @@ def main() -> None:
config = load_config()
device = config["device"]
# Interrogate the drive
smartctl_data = interrogate_drive(device)
# Find sysfs path
sysfs_path = find_nvme_sysfs()
if sysfs_path is None:
raise RuntimeError("No NVMe controller found in sysfs")
# Inject a simple clock
class SimpleClock:
def utcnow(self):
@@ -118,11 +109,24 @@ def main() -> None:
clock = SimpleClock()
def acquire():
"""Interrogate the drive after pending-work preflight."""
smartctl_data = interrogate_drive(device)
sysfs_path = find_nvme_sysfs()
if sysfs_path is None:
raise RuntimeError("No NVMe controller found in sysfs")
return smartctl_data, sysfs_path
# Run collection
result = run_collection(smartctl_data, sysfs_path, config, clock)
result = run_collection(config=config, clock=clock, acquire=acquire)
if result["ok"]:
print(f"Collection successful: {result['sample_count']} sample(s)")
if result.get("retention_error"):
print(
f"Retention deferred: {result['retention_error']}",
file=sys.stderr,
)
sys.exit(0)
else:
print(f"Collection failed: {result['error']}", file=sys.stderr)
+310 -102
View File
@@ -5,19 +5,21 @@ This module implements the thinnest complete write path:
- Acquire controller identity from sysfs
- Normalize identity exactly once at write time
- Validate every row against store invariants
- Commit one well-formed sample
- Stage valid observations privately before derivation
- Publish the sample and derived evidence in one collection-owned transaction
No code path outside the collector interrogates the device.
"""
import json
import sqlite3
from collections.abc import Callable
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, Optional, Tuple
from typing import Any, Dict, Optional
from .store import init_store, get_store_path
from .derive import derive_hours_from_interval, find_previous_sample
from .monitoring_periods import ensure_period_open
from .derive import find_previous_sample, derive_hours_from_interval
from .store import DIAGNOSTIC_SAMPLE_COLUMNS, init_store, get_store_path
class AcquisitionError(Exception):
@@ -30,6 +32,29 @@ class InvariantViolationError(Exception):
pass
class _PendingDerivationError(Exception):
"""A valid staged observation failed while deriving dependent evidence."""
def __init__(self, cause: Exception):
super().__init__(str(cause))
self.cause = cause
class _PendingRecoveryFailure(Exception):
"""A publication attempt failed after earlier pending rows committed."""
def __init__(self, error: Exception, published_count: int):
derivation_error = isinstance(error, _PendingDerivationError)
cause = error.cause if derivation_error else error
super().__init__(str(cause))
self.cause = cause
self.published_count = published_count
self.derivation_error = derivation_error
PENDING_PUBLICATION_LIMIT = 6720
def acquire_from_smartctl(smartctl_data: Dict[str, Any]) -> Dict[str, Any]:
"""Acquire counters and thermal evidence from smartctl -a -j data.
@@ -78,6 +103,8 @@ def acquire_from_smartctl(smartctl_data: Dict[str, Any]) -> Dict[str, Any]:
"bytes_written": log["data_units_written"] * 512000,
"bytes_read": log["data_units_read"] * 512000,
"critical_warning": log.get("critical_warning", 0),
# Diagnostic counters (issue #111); absent keys stay None, never zero.
**{column: log.get(column) for column in DIAGNOSTIC_SAMPLE_COLUMNS},
}
@@ -195,12 +222,12 @@ def write_sample(
sample: Dict[str, Any],
identity: Dict[str, Any],
conn: sqlite3.Connection,
clock,
observed_at: datetime,
) -> Dict[str, Any]:
"""Write one sample to the observation store.
Identity normalization happens exactly once here.
Returns segment info for the caller.
Returns segment info for the caller. Caller owns the transaction.
"""
from .segment import find_current_segment, should_open_new_segment, open_segment
@@ -219,8 +246,7 @@ def write_sample(
# Open new segment if needed
segment_opened = False
if should_open:
now = clock.utcnow()
open_segment(conn, now, identity, identity_key, identity_degraded)
open_segment(conn, observed_at, identity, identity_key, identity_degraded)
segment_opened = True
# Get current segment_id for provenance
@@ -235,9 +261,10 @@ 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, segment_id, local_tz, %s
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, %s)
""" % (", ".join(DIAGNOSTIC_SAMPLE_COLUMNS),
", ".join("?" * len(DIAGNOSTIC_SAMPLE_COLUMNS))),
(
sample["ts"],
sample["device"],
@@ -259,122 +286,303 @@ def write_sample(
sample["bytes_read"],
sample["critical_warning"],
segment_id,
sample.get("local_tz"),
# Samples staged by an earlier version lack these keys.
*(sample.get(column) for column in DIAGNOSTIC_SAMPLE_COLUMNS),
),
)
conn.commit()
return {
"segment_opened": segment_opened,
"segment_reason": reason,
"identity_key": identity_key,
"identity_degraded": identity_degraded,
"segment_id": segment_id,
"sample_id": cursor.lastrowid,
}
def run_collection(
smartctl_data: Dict[str, Any],
sysfs_path: Path,
config: Dict[str, Any],
clock,
) -> Dict[str, Any]:
"""Run one collection run.
This is the main entry point for the collector.
Returns the run outcome.
"""
def _observation_time(sample: Dict[str, Any]) -> datetime:
"""Read an observation's original timestamp for ordered recovery."""
observed_at = datetime.fromisoformat(sample["ts"])
if observed_at.tzinfo is None:
return observed_at.replace(tzinfo=timezone.utc)
return observed_at.astimezone(timezone.utc)
def _publish_observation(
conn: sqlite3.Connection,
sample: Dict[str, Any],
identity: Dict[str, Any],
tz_name: str,
) -> None:
"""Publish one staged sample and all dependent evidence in caller transaction."""
observed_at = _observation_time(sample)
sample["local_tz"] = tz_name
validate_sample_invariants(sample, conn)
ensure_period_open(conn, observed_at)
seg_info = write_sample(sample, identity, conn, observed_at)
current_id = seg_info["sample_id"]
prev = find_previous_sample(conn, seg_info.get("segment_id"), current_id)
try:
# Acquire counters and thermal evidence
counters = acquire_from_smartctl(smartctl_data)
# Acquire controller identity
identity = acquire_from_sysfs(sysfs_path)
# Build sample with injected clock
sample = {
"ts": clock.utcnow().isoformat(),
"device": config["device"],
**counters,
**identity,
}
# Initialize store if needed
if prev is not None:
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"],
"local_tz": tz_name,
}
derive_hours_from_interval(conn, prev, current)
from .local_day import record_local_activity_interval
record_local_activity_interval(
conn,
prev,
current,
start_sample_id=prev["id"],
end_sample_id=current_id,
segment_id=seg_info.get("segment_id"),
)
else:
previous_any_segment = find_previous_sample(conn, None, current_id)
if previous_any_segment is not None:
from .local_day import mark_local_activity_gap
mark_local_activity_gap(
conn,
sample["ts"],
tz_name,
previous_any_segment,
)
from .day_aggregate import derive_all_days, persist_day_aggregate
for aggregate in derive_all_days(conn):
persist_day_aggregate(conn, aggregate)
from .local_day import derive_local_day_summary, persist_local_day
local_summary = derive_local_day_summary(conn, tz_name, observed_at)
if local_summary is not None:
persist_local_day(conn, local_summary)
except Exception as exc: # noqa: BLE001 - preserve valid staged evidence for retry.
raise _PendingDerivationError(exc) from exc
def _pending_count(conn: sqlite3.Connection) -> int:
return conn.execute("SELECT COUNT(*) FROM pending_publications").fetchone()[0]
def _stage_observation(
conn: sqlite3.Connection,
sample: Dict[str, Any],
identity: Dict[str, Any],
tz_name: str,
) -> int:
"""Durably stage valid acquired evidence before attempting publication."""
payload = json.dumps(
{"sample": sample, "identity": identity, "tz_name": tz_name},
separators=(",", ":"),
sort_keys=True,
)
cursor = conn.execute(
"INSERT INTO pending_publications (sample_ts, payload) VALUES (?, ?)",
(sample["ts"], payload),
)
return cursor.lastrowid
def _recover_pending(conn: sqlite3.Connection) -> int:
"""Publish pending observations oldest first; stop at first failure."""
recovered = 0
while True:
conn.execute("BEGIN IMMEDIATE")
try:
row = conn.execute(
"SELECT id, payload FROM pending_publications ORDER BY id LIMIT 1"
).fetchone()
if row is None:
conn.rollback()
return recovered
pending_id, payload = row
observation = json.loads(payload)
try:
_publish_observation(
conn,
observation["sample"],
observation["identity"],
observation["tz_name"],
)
except Exception as exc:
raise _PendingRecoveryFailure(exc, recovered) from exc
conn.execute("DELETE FROM pending_publications WHERE id = ?", (pending_id,))
conn.commit()
recovered += 1
except Exception:
conn.rollback()
raise
def _is_store_or_invariant_failure(exc: Exception) -> bool:
return (
isinstance(exc, sqlite3.Error)
and not isinstance(exc, sqlite3.IntegrityError)
) or isinstance(exc, InvariantViolationError)
def _pending_capacity_error(
reason: str,
*,
acquisition_skipped: bool = False,
) -> RuntimeError:
outcome = "; no new observation acquired" if acquisition_skipped else ""
return RuntimeError(
"pending publication capacity full "
f"({PENDING_PUBLICATION_LIMIT} observations){outcome}; "
f"{reason}"
)
def _recover_pending_for_collection(conn: sqlite3.Connection) -> int:
"""Retry queued work and report exhausted capacity without masking store faults."""
try:
return _recover_pending(conn)
except Exception as exc: # noqa: BLE001 - preserve pending evidence on recovery errors.
cause = exc.cause if isinstance(exc, _PendingRecoveryFailure) else exc
if _is_store_or_invariant_failure(cause):
raise cause
try:
capacity_full = _pending_count(conn) >= PENDING_PUBLICATION_LIMIT
except sqlite3.Error:
raise cause
if capacity_full:
raise _pending_capacity_error(f"recovery failed: {cause}") from cause
raise cause
def _recover_pending_for_admission(conn: sqlite3.Connection) -> int:
"""Recover in order, then admit one observation if bounded space remains."""
try:
recovered = _recover_pending(conn)
except _PendingRecoveryFailure as failure:
if (
not failure.derivation_error
or _is_store_or_invariant_failure(failure.cause)
):
raise failure.cause
try:
pending_count = _pending_count(conn)
except sqlite3.Error:
raise failure.cause
if pending_count >= PENDING_PUBLICATION_LIMIT:
raise _pending_capacity_error(
f"recovery failed: {failure.cause}",
acquisition_skipped=True,
) from failure.cause
return failure.published_count
if _pending_count(conn) >= PENDING_PUBLICATION_LIMIT:
raise _pending_capacity_error(
"recovery left the queue full",
acquisition_skipped=True,
)
return recovered
def run_collection(
smartctl_data: Optional[Dict[str, Any]] = None,
sysfs_path: Optional[Path] = None,
config: Optional[Dict[str, Any]] = None,
clock=None,
*,
acquire: Optional[Callable[[], tuple[Dict[str, Any], Path]]] = None,
) -> Dict[str, Any]:
"""Recover old work, acquire one observation, then publish it atomically.
Production callers pass ``acquire`` so recovery and capacity checks run
before device interrogation. Direct sample arguments remain useful for
deterministic collector tests.
"""
conn = None
try:
if config is None or clock is None:
raise ValueError("config and clock are required")
store_path = get_store_path(config)
conn = init_store(store_path)
# Run legacy import if needed (idempotent)
from .legacy import import_legacy_history
history_path = Path(config.get("data_dir", ".")) / "history.jsonl"
if history_path.exists():
import_legacy_history(conn, history_path, clock=clock)
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]
published_count = _recover_pending_for_admission(conn)
checked_pending_count = _pending_count(conn)
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)
# Hold the writer reservation across the capacity check and acquisition.
# Recheck recovery if another collector appended work during preflight.
while True:
conn.execute("BEGIN IMMEDIATE")
waiting = _pending_count(conn)
if (
waiting >= PENDING_PUBLICATION_LIMIT
or waiting > checked_pending_count
):
conn.rollback()
published_count += _recover_pending_for_admission(conn)
checked_pending_count = _pending_count(conn)
continue
# Rebuild day aggregates from hour observations
from .day_aggregate import derive_all_days, persist_day_aggregate
for agg in derive_all_days(conn):
persist_day_aggregate(conn, agg)
from .tz_util import detect_system_tz
tz_name = detect_system_tz()
if acquire is not None:
smartctl_data, sysfs_path = acquire()
if smartctl_data is None or sysfs_path is None:
raise AcquisitionError("No acquired SMART data or sysfs identity")
# Derive local-day summary using system timezone (issue #90)
try:
from .tz_util import detect_system_tz
from .local_day import derive_local_day_summary, persist_local_day
tz_name = detect_system_tz()
clock_now = clock.utcnow()
local_summary = derive_local_day_summary(conn, tz_name, clock_now)
if local_summary is not None:
persist_local_day(conn, local_summary)
except Exception:
# Local-day derivation failure must not prevent publication
pass
conn.commit()
except Exception:
# Derivation failure must not prevent sample persistence (issue #73 AC6)
pass
return {
"ok": True,
"sample_count": 1,
"store_path": str(store_path),
counters = acquire_from_smartctl(smartctl_data)
identity = acquire_from_sysfs(sysfs_path)
sample = {
"ts": clock.utcnow().isoformat(),
"device": config["device"],
**counters,
**identity,
}
finally:
conn.close()
except (AcquisitionError, InvariantViolationError) as e:
validate_sample_invariants(sample, conn)
_stage_observation(conn, sample, identity, tz_name)
conn.commit()
break
published_count += _recover_pending_for_collection(conn)
retention_error = None
try:
if _pending_count(conn) == 0:
from .pruning import prune_old_samples
prune_old_samples(conn, clock.utcnow())
except Exception as exc: # noqa: BLE001 - publication already committed.
# Publication already committed. Keep collection successful and
# retry atomic retention on the next normal collection.
conn.rollback()
retention_error = str(exc)
return {
"ok": True,
"sample_count": published_count,
"retention_error": retention_error,
"store_path": str(store_path),
}
except Exception as exc:
if conn is not None:
conn.rollback()
return {
"ok": False,
"error": str(e),
"error_type": type(e).__name__,
"error": str(exc),
"error_type": type(exc).__name__,
}
finally:
if conn is not None:
conn.close()
+1 -1
View File
@@ -9,7 +9,7 @@ usage-habit classification is known rather than unknown (§5.3).
"""
import sqlite3
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from datetime import datetime, timedelta
@dataclass(frozen=True)
+5 -8
View File
@@ -12,7 +12,7 @@ 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 typing import Any, Dict, List, Optional
from .hour_classify import classify_hour, HourSplit
@@ -29,7 +29,7 @@ def find_previous_sample(
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 "
" temperature_c, data_units_written, data_units_read, local_tz "
"FROM samples WHERE id < ? AND segment_id = ? "
"ORDER BY id DESC LIMIT 1",
(current_sample_id, segment_id),
@@ -37,7 +37,7 @@ def find_previous_sample(
else:
cursor = conn.execute(
"SELECT id, ts, bytes_written, bytes_read, power_on_hours, "
" temperature_c, data_units_written, data_units_read "
" temperature_c, data_units_written, data_units_read, local_tz "
"FROM samples WHERE id < ? "
"ORDER BY id DESC LIMIT 1",
(current_sample_id,),
@@ -49,7 +49,7 @@ def find_previous_sample(
"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],
"data_units_read": row[7], "local_tz": row[8],
}
@@ -96,6 +96,7 @@ def derive_hours_from_interval(
"""Derive hour observations from a sample pair interval.
Returns list of hour observation dicts that were written/updated.
Caller owns the transaction.
"""
prev_ts = _parse_ts(prev_sample["ts"])
next_ts = _parse_ts(next_sample["ts"])
@@ -207,9 +208,6 @@ def _upsert_hour_observation(
"WHERE id = ?",
(new_bw, new_br, new_samples, existing[0]),
)
conn.commit()
def _add_unattributed_bytes(
conn: sqlite3.Connection,
prev_ts: datetime,
@@ -241,4 +239,3 @@ def _add_unattributed_bytes(
"unattributed_bytes_read = unattributed_bytes_read + ? WHERE day = ?",
(bw_delta, br_delta, day),
)
conn.commit()
-1
View File
@@ -26,7 +26,6 @@ Runit guarantees:
Spec: §8.4, §8.5, §8.6, §8.7, §8.8, ADR 0008
"""
import os
import shutil
import subprocess
import sys
from enum import Enum
+2 -3
View File
@@ -18,11 +18,10 @@ import logging
import sqlite3
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from typing import Any, Dict, List, Optional
from .hour_classify import classify_hour
from .segment import open_segment, normalize_identity
from .monitoring_periods import close_period
from .segment import open_segment
logger = logging.getLogger(__name__)
+1003 -133
View File
File diff suppressed because it is too large Load Diff
+6 -4
View File
@@ -31,13 +31,14 @@ if VENV_DIR.exists():
if site_packages:
sys.path.insert(0, str(site_packages))
from fenris.store import init_store, get_store_path
from fenris.monitoring_periods import (
from fenris.store import init_store # noqa: E402 -- must follow the sys.path bootstrap above
from fenris.timefmt import local_timestamp, local_zone_name # noqa: E402 -- sys.path bootstrap above
from fenris.monitoring_periods import ( # noqa: E402 -- sys.path bootstrap above
ensure_period_open,
close_period,
get_open_period,
)
from fenris.init_system import (
from fenris.init_system import ( # noqa: E402 -- sys.path bootstrap above
enable_timer,
disable_timer,
collect_now,
@@ -69,7 +70,8 @@ def cmd_enable(args: argparse.Namespace) -> None:
open_period = get_open_period(conn)
if open_period is None:
ensure_period_open(conn, now)
print("Monitoring period opened at", now.isoformat())
conn.commit()
print("Monitoring period opened at", local_timestamp(now, local_zone_name()))
else:
print("Monitoring period already open (id=%d)" % open_period["id"])
+42 -3
View File
@@ -9,14 +9,14 @@ Key contracts:
- End causes: user_disabled, migrated, unknown_gap
"""
import sqlite3
from datetime import datetime
from datetime import datetime, timezone
def ensure_period_open(conn: sqlite3.Connection, run_time: datetime) -> None:
"""Ensure a monitoring period is open. If none exists, open one at run_time.
Spec §9.8: A collection run finding no open monitoring period opens one
at the run moment, never backdated.
at the run moment, never backdated. Caller owns the transaction.
"""
if get_open_period(conn) is not None:
return # Already open — no-op
@@ -26,7 +26,6 @@ def ensure_period_open(conn: sqlite3.Connection, run_time: datetime) -> None:
"INSERT INTO monitoring_periods (started_at) VALUES (?)",
(ts,),
)
conn.commit()
def close_period(
@@ -83,6 +82,46 @@ def is_inside_period(conn: sqlite3.Connection, ts: datetime) -> bool:
return cursor.fetchone() is not None
def interval_within_one_monitoring_period(
conn: sqlite3.Connection,
start: str | datetime,
end: str | datetime,
) -> bool:
"""Return whether one monitoring period contains the full interval.
Compare timestamps as UTC instants. Malformed timestamps fail closed.
"""
try:
interval_start = as_utc_datetime(start)
interval_end = as_utc_datetime(end)
except (TypeError, ValueError, OverflowError):
return False
if interval_end <= interval_start:
return False
for started_at, ended_at in conn.execute(
"SELECT started_at, ended_at FROM monitoring_periods"
):
try:
period_start = as_utc_datetime(started_at)
period_end = as_utc_datetime(ended_at) if ended_at is not None else None
except (TypeError, ValueError, OverflowError):
continue
if period_start <= interval_start and (
period_end is None or interval_end <= period_end
):
return True
return False
def as_utc_datetime(value: str | datetime) -> datetime:
"""Parse a timestamp and normalize it to an aware UTC datetime."""
parsed = value if isinstance(value, datetime) else datetime.fromisoformat(value)
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc)
def wall_clock_in_periods(
conn: sqlite3.Connection,
start: datetime,
+3 -3
View File
@@ -19,8 +19,8 @@ from typing import Any, Dict
PREFERENCE_FILE_NAME = "preferences.json"
VALID_THEMES = {"amber", "nord", "high_contrast"}
DEFAULT_THEME = "amber"
VALID_THEMES = {"chalktone", "amber", "nord", "high_contrast"}
DEFAULT_THEME = "chalktone"
DEFAULT_REDUCED_MOTION = False
@@ -46,7 +46,7 @@ def load_preferences() -> Dict[str, Any]:
reduced_motion: bool
If the file is missing, corrupt, unreadable, or contains invalid
values, returns safe defaults (Amber theme, normal motion).
values, returns safe defaults (Chalktone theme, normal motion).
"""
path = get_preference_path()
+264 -124
View File
@@ -20,20 +20,29 @@ Arithmetic (§6.3):
E_rated = entered_TBW × 10¹² bytes
E_implied = 100 · W_t / p (1 ≤ p ≤ 254)
Staging (ADR 0012): the result carries a projection stage, the evidence
ladder (each Supported condition met or unmet with its reason) and the
sustained-regime write rate, so callers render the stage and never
pre-gate the projection themselves.
Criteria: PR-1–PR-17, CI-4.
"""
import sqlite3
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from datetime import datetime, timedelta
from enum import Enum
from typing import Any, Dict, List, Optional, Tuple
from typing import Any, Dict, List, Optional
from .monitoring_periods import interval_within_one_monitoring_period
# ---------------------------------------------------------------------------
# Constants (spec §6)
# ---------------------------------------------------------------------------
HORIZON_DAYS = (7, 28, 90)
SHORT_HORIZON_DAYS = (1, 3)
PROVISIONAL_MIN_HOURS = 3
DAILY_CYCLE_HOURS = 24
TBW_TO_BYTES = 10 ** 12
IMPLIED_P_MIN = 1
IMPLIED_P_MAX = 254
@@ -61,6 +70,15 @@ class ConfidenceState(Enum):
SUPPORTED = "Supported"
class ProjectionStage(Enum):
NO_OBSERVATIONS = "No observations yet"
PROVISIONAL = "Provisional"
WARMING = "Warming"
LIMITED = "Limited"
SUPPORTED = "Supported"
UNAVAILABLE = "Unavailable"
class BaselineTier(Enum):
VERIFIED = "verified_override"
UNVERIFIED = "unverified_override"
@@ -76,6 +94,14 @@ class ScenarioRange:
horizon_reasons: Dict[int, str] = field(default_factory=dict)
@dataclass(frozen=True)
class LadderCondition:
"""One Supported condition from ADR 0002 §8, met or unmet with its reason."""
name: str
met: bool
reason: str
@dataclass(frozen=True)
class ProjectionResult:
confidence_state: ConfidenceState
@@ -93,6 +119,25 @@ class ProjectionResult:
degraded_identity_fact: Optional[str]
zero_rate_fact: Optional[str]
qualifying_days_progress: Optional[str] = None # Issue #77: honest qualifying-day progress
stage: ProjectionStage = ProjectionStage.NO_OBSERVATIONS
evidence_ladder: List[LadderCondition] = field(default_factory=list)
write_rate: Optional[float] = None # sustained-regime rate, bytes per second
hours_observed: int = 0
@property
def stage_label(self) -> str:
"""Display wording; observations that are too few are not "none"."""
if self.stage == ProjectionStage.NO_OBSERVATIONS and self.hours_observed > 0:
return "Collecting observations"
return self.stage.value
@property
def ladder_met(self) -> int:
return sum(1 for c in self.evidence_ladder if c.met)
@property
def ladder_total(self) -> int:
return len(self.evidence_ladder)
# ---------------------------------------------------------------------------
@@ -149,16 +194,40 @@ def _get_all_days(conn):
for r in cursor.fetchall()]
def _get_latest_published_sample(conn, segment_id):
"""Newest published sample in the segment: (lifetime bytes written, PU).
W_t is the lifetime counter, not a regime delta. Pending staging lives
in a separate table (ADR 0011), so only published samples are seen here.
bytes_written is DUW x 512000 as stored by the collector; fall back to
that conversion if only the raw counter is present.
"""
if segment_id is not None:
cursor = conn.execute(
"SELECT COALESCE(bytes_written, data_units_written * 512000), percentage_used "
"FROM samples WHERE segment_id = ? ORDER BY id DESC LIMIT 1",
(segment_id,),
)
else:
cursor = conn.execute(
"SELECT COALESCE(bytes_written, data_units_written * 512000), percentage_used "
"FROM samples ORDER BY id DESC LIMIT 1"
)
row = cursor.fetchone()
return (row[0], row[1]) if row else (None, None)
def _get_latest_pu(conn):
cursor = conn.execute("SELECT percentage_used FROM samples ORDER BY id DESC LIMIT 1")
row = cursor.fetchone()
return row[0] if row else None
def _get_pu_increments_in_segment(conn, segment_opened_at):
def _get_pu_increments_in_segment(conn, segment_id):
cursor = conn.execute(
"SELECT COUNT(DISTINCT percentage_used) FROM samples WHERE ts >= ?",
(segment_opened_at,),
"SELECT COUNT(DISTINCT percentage_used) FROM samples "
"WHERE segment_id = ? AND percentage_used IS NOT NULL",
(segment_id,),
)
row = cursor.fetchone()
return max(0, (row[0] if row else 0) - 1)
@@ -181,15 +250,50 @@ def _wall_clock_in_range(conn, start, end):
return total
def _get_hours_observed(conn, segment_opened_at, days):
"""Hours of sampled evidence in the current segment.
Counted from hour observations. A day aggregate without any hour rows
(imported or pruned history) stands for a whole observed day.
"""
hour_floor = segment_opened_at[:13] if segment_opened_at else ""
cursor = conn.execute(
"SELECT substr(hour, 1, 10), COUNT(*) FROM hour_observations "
"WHERE sample_count > 0 AND hour >= ? GROUP BY 1",
(hour_floor,),
)
by_day = dict(cursor.fetchall())
total = 0
for d in days:
if d["day"] in by_day:
total += by_day.pop(d["day"])
elif d["sample_count"] > 0:
total += DAILY_CYCLE_HOURS
return total + sum(by_day.values())
# ---------------------------------------------------------------------------
# Baseline resolution (§6.1, §6.2)
# ---------------------------------------------------------------------------
def _resolve_implied_baseline(conn, current_segment, facts):
"""No override exists: Percentage-Used-implied tier, or unavailable (§10)."""
if current_segment is not None:
_, p = _get_latest_published_sample(conn, current_segment["id"])
increments = _get_pu_increments_in_segment(conn, current_segment["id"])
if (increments >= IMPLIED_MIN_PU_INCREMENTS and p is not None
and IMPLIED_P_MIN <= p <= IMPLIED_P_MAX):
return (BaselineTier.IMPLIED, None,
"implied from vendor wear (%d%% used) — coarse" % p, facts)
facts.append("vendor wear estimate too coarse to imply endurance")
return BaselineTier.NONE, None, "no baseline", facts
def _resolve_baseline(conn, current_segment):
baseline = _get_baseline(conn)
facts = []
facts: list[str] = []
if baseline is None:
return BaselineTier.NONE, None, "no baseline", facts
return _resolve_implied_baseline(conn, current_segment, facts)
mandatory = [baseline["source_url"], baseline["document_revision"],
baseline["entry_date"], baseline["model_string"],
@@ -340,84 +444,88 @@ def _detect_habit_change(days):
# Confidence rule table (§6.7)
# ---------------------------------------------------------------------------
def _evaluate_confidence(tier, rate, regime_days, days, current_segment,
clock_now, warming_days, warming_low_coverage,
habit_change, staleness_hours, scenario_range):
facts = []
DEGRADED_IDENTITY_FACT = "controller identity unavailable — replacement detection relies on write-counter continuity only"
if tier == BaselineTier.NONE:
facts.append("no applicable endurance baseline")
return ConfidenceState.UNSUPPORTED, facts
if rate is None or rate <= 0:
facts.append("no finite projection from this history")
return ConfidenceState.UNSUPPORTED, facts
def _build_evidence_ladder(tier, regime_days, days, current_segment,
staleness_hours, scenario_range):
"""The eight Supported conditions of ADR 0002 §8, each met or unmet."""
ladder = []
supported_facts = []
failing = False
def add(name, met, met_reason, unmet_reason):
ladder.append(LadderCondition(name, met, met_reason if met else unmet_reason))
# 1. Verified baseline
if tier != BaselineTier.VERIFIED:
failing = True
else:
supported_facts.append("verified manufacturer TBW")
add("verified baseline", tier == BaselineTier.VERIFIED,
"verified manufacturer TBW",
{BaselineTier.UNVERIFIED: "baseline is unverified (user-supplied)",
BaselineTier.IMPLIED: "baseline is implied from vendor wear — coarse"}.get(
tier, "no applicable endurance baseline"))
# 2. >= 14 qualifying days
qualifying = sum(1 for d in days if d["coverage"] >= WARMING_COVERAGE_FLOOR and d["sample_count"] > 0)
if qualifying < WARMING_MIN_DAYS:
failing = True
else:
supported_facts.append("%d calendar days" % qualifying)
qualifying = sum(1 for d in days
if d["coverage"] >= WARMING_COVERAGE_FLOOR and d["sample_count"] > 0)
add("14 qualifying days", qualifying >= WARMING_MIN_DAYS,
"%d calendar days" % qualifying,
"%d of %d qualifying days" % (qualifying, WARMING_MIN_DAYS))
# 3. Coverage >= 80%
total_wc = len(days) * 86400
total_known = sum(int(d["coverage"] * 86400) for d in days)
avg_cov = total_known / total_wc if total_wc > 0 else 0.0
if avg_cov < SUPPORTED_COVERAGE_FLOOR:
failing = True
add("coverage at least 80%", avg_cov >= SUPPORTED_COVERAGE_FLOOR,
"%d%% interval coverage" % int(avg_cov * 100),
"interval coverage %d%% (≥%d%% required)"
% (int(avg_cov * 100), int(SUPPORTED_COVERAGE_FLOOR * 100)))
if staleness_hours is None:
add("fresh data", False, "", "no observation days yet")
else:
supported_facts.append("%d%% interval coverage" % int(avg_cov * 100))
add("fresh data", staleness_hours <= STALENESS_HOURS, "recent data",
"newest data %dh old (≥%dh)" % (staleness_hours, STALENESS_HOURS))
# 4. Fresh (< 48h)
if staleness_hours is not None and staleness_hours > STALENESS_HOURS:
failing = True
elif staleness_hours is not None:
supported_facts.append("recent data")
# 5. Horizon agreement
if scenario_range is not None and len(scenario_range.rates) >= 2:
if scenario_range is None or not scenario_range.rates:
add("horizons agree", False, "", "no scenario horizon covered yet")
else:
rl = list(scenario_range.rates.values())
if min(rl) > 0 and max(rl) / min(rl) > HORIZON_AGREEMENT_FACTOR:
failing = True
else:
supported_facts.append("%d weekly cycles" % len(scenario_range.rates))
else:
failing = True
spread = max(rl) / min(rl) if min(rl) > 0 else None
agree = spread is None or spread <= HORIZON_AGREEMENT_FACTOR
add("horizons agree", agree, "%d weekly cycles" % len(rl),
"horizon rates differ by %.1f× (≤%d× required)"
% (spread or 0.0, HORIZON_AGREEMENT_FACTOR))
# 6. Burst guard
if not failing and len(days) >= BURST_GUARD_LOOKBACK:
t28 = sum(d["bytes_written"] for d in days[-BURST_GUARD_LOOKBACK:])
for d in days[-BURST_GUARD_LOOKBACK:]:
if t28 > 0 and d["bytes_written"] >= BURST_GUARD_FRACTION * t28:
failing = True
break
if not failing:
supported_facts.append("no burst days")
recent = days[-BURST_GUARD_LOOKBACK:]
t28 = sum(d["bytes_written"] for d in recent)
burst = t28 > 0 and any(d["bytes_written"] >= BURST_GUARD_FRACTION * t28 for d in recent)
add("no burst day", bool(recent) and not burst, "no burst days",
"one day carries ≥%d%% of trailing writes" % int(BURST_GUARD_FRACTION * 100)
if burst else "no observation days yet")
# 7. Regime >= 7 days
if regime_days < YOUNG_REGIME_DAYS:
failing = True
add("regime at least 7 days", regime_days >= YOUNG_REGIME_DAYS,
"regime %d days" % regime_days,
"regime only %d days old (≥%d required)" % (regime_days, YOUNG_REGIME_DAYS))
# 8. Degraded identity
if current_segment and current_segment.get("identity_degraded"):
failing = True
facts.append("controller identity unavailable — replacement detection relies on write-counter continuity only")
degraded = bool(current_segment and current_segment.get("identity_degraded"))
add("controller identity", not degraded, "controller identity available",
DEGRADED_IDENTITY_FACT)
return ladder
if not failing:
return ConfidenceState.SUPPORTED, supported_facts
# Limited
limited_facts = list(supported_facts)
def _evaluate_confidence(tier, rate, regime_days, days, current_segment,
habit_change, staleness_hours, scenario_range):
"""Return (confidence state, facts, evidence ladder)."""
ladder = _build_evidence_ladder(tier, regime_days, days, current_segment,
staleness_hours, scenario_range)
if tier == BaselineTier.NONE:
return ConfidenceState.UNSUPPORTED, ["no applicable endurance baseline"], ladder
if rate is None or rate <= 0:
return ConfidenceState.UNSUPPORTED, ["no finite projection from this history"], ladder
met_facts = [c.reason for c in ladder if c.met]
if all(c.met for c in ladder):
return ConfidenceState.SUPPORTED, met_facts, ladder
# Limited: met facts plus the contributing failures
limited_facts = list(met_facts)
if staleness_hours is not None and staleness_hours > STALENESS_HOURS:
limited_facts.append("newest data %dh old (≥48h)" % staleness_hours)
if habit_change is not None:
@@ -425,11 +533,27 @@ def _evaluate_confidence(tier, rate, regime_days, days, current_segment,
if regime_days < YOUNG_REGIME_DAYS:
limited_facts.append("regime only %d days old (≥7 required)" % regime_days)
if current_segment and current_segment.get("identity_degraded"):
degraded_fact = "controller identity unavailable — replacement detection relies on write-counter continuity only"
if degraded_fact not in limited_facts:
limited_facts.append(degraded_fact)
if DEGRADED_IDENTITY_FACT not in limited_facts:
limited_facts.append(DEGRADED_IDENTITY_FACT)
return ConfidenceState.LIMITED, limited_facts, ladder
return ConfidenceState.LIMITED, limited_facts
def ladder_count_text(result):
"""Headline form of the evidence ladder: how many conditions are met."""
return "%d of %d conditions met" % (result.ladder_met, result.ladder_total)
def ladder_lines(result):
"""Full evidence ladder for the outlook, one line per condition."""
return ["[%s] %s — %s" % ("x" if c.met else " ", c.name, c.reason)
for c in result.evidence_ladder]
def write_rate_gb_day(result):
"""The sustained-regime write rate in GB/day, or None when not yet known."""
if result.write_rate is None:
return None
return result.write_rate * 86400 / 1e9
# ---------------------------------------------------------------------------
@@ -494,24 +618,37 @@ def _has_complete_local_day(conn):
within a monitoring period that has usable observation evidence.
This is the prerequisite for showing an endurance outlook.
"""
row = conn.execute(
"SELECT 1 FROM local_days WHERE complete = 1 LIMIT 1"
).fetchone()
return row is not None
def _count_complete_local_days(conn):
"""Count the number of complete local observation days."""
row = conn.execute(
"SELECT COUNT(*) FROM local_days WHERE complete = 1"
).fetchone()
return row[0] if row else 0
local_days = conn.execute(
"SELECT utc_start, utc_end FROM local_days "
"WHERE complete = 1 "
"AND activity_precision IN ('measured', 'coarse') "
"AND activity_intervals > 0 ORDER BY id"
).fetchall()
return any(
interval_within_one_monitoring_period(conn, utc_start, utc_end)
for utc_start, utc_end in local_days
)
# ---------------------------------------------------------------------------
# Main projection function
# ---------------------------------------------------------------------------
def _determine_stage(state, hours_observed, total_days, days_below_coverage):
"""Where the projection stands on its way to support (ADR 0012 §1, §2)."""
if hours_observed < PROVISIONAL_MIN_HOURS:
return ProjectionStage.NO_OBSERVATIONS
if state == ConfidenceState.UNSUPPORTED:
return ProjectionStage.UNAVAILABLE
if hours_observed < DAILY_CYCLE_HOURS:
return ProjectionStage.PROVISIONAL
if total_days < WARMING_MIN_DAYS or days_below_coverage > WARMING_MAX_LOW_COVERAGE:
return ProjectionStage.WARMING
if state == ConfidenceState.SUPPORTED:
return ProjectionStage.SUPPORTED
return ProjectionStage.LIMITED
def compute_projection(conn, clock_now):
facts = []
habit_change_fact = None
@@ -524,32 +661,14 @@ def compute_projection(conn, clock_now):
tier, baseline, baseline_label, baseline_facts = _resolve_baseline(conn, current_segment)
facts.extend(baseline_facts)
# --- Complete observation day gate (issue #94) ---
# An endurance outlook requires at least one complete local
# midnight-to-midnight calendar day with usable observation evidence.
has_complete_day = _has_complete_local_day(conn)
if not has_complete_day:
# The complete-local-day condition is a fact, not a gate (ADR 0012 §2).
if not _has_complete_local_day(conn):
facts.append("waiting for a full local observation day")
return ProjectionResult(
confidence_state=ConfidenceState.UNSUPPORTED,
contributing_facts=facts,
headline_remaining_seconds=None,
scenario_range=None,
pu_context_line="Percentage Used: unknown" if _get_latest_pu(conn) is None else "Percentage Used: %d%%" % (_get_latest_pu(conn) or 0),
disclosure_text=list(DISCLOSURES),
baseline_tier=tier,
baseline_label=baseline_label,
regime_days=None,
habit_change_fact=None,
warming_fact=None,
staleness_fact=None,
degraded_identity_fact=None,
zero_rate_fact=None,
qualifying_days_progress=None,
)
segment_days = _get_days_in_segment(conn, current_segment["opened_at"]) if current_segment else _get_all_days(conn)
all_days = _get_all_days(conn)
hours_observed = _get_hours_observed(
conn, current_segment["opened_at"] if current_segment else None, segment_days)
regime_start_day = None
habit_change = None
@@ -579,12 +698,15 @@ def compute_projection(conn, clock_now):
scenario = None
horizon_rates = {}
horizon_reasons = {}
for h in HORIZON_DAYS:
for h in SHORT_HORIZON_DAYS + HORIZON_DAYS:
hr, reason = _compute_horizon_rate(all_days, conn, h, clock_now)
if hr is not None:
horizon_rates[h] = hr
else:
elif h in HORIZON_DAYS:
horizon_reasons[h] = reason
if HORIZON_DAYS[0] in horizon_rates:
for h in SHORT_HORIZON_DAYS:
horizon_rates.pop(h, None)
if horizon_rates:
scenario = ScenarioRange(
rates=horizon_rates,
@@ -620,33 +742,47 @@ def compute_projection(conn, clock_now):
facts.append(staleness_fact)
if current_segment and current_segment.get("identity_degraded"):
degraded_identity_fact = "controller identity unavailable — replacement detection relies on write-counter continuity only"
degraded_identity_fact = DEGRADED_IDENTITY_FACT
facts.append(degraded_identity_fact)
state, conf_facts = _evaluate_confidence(
tier, rate, regime_days_count, segment_days, current_segment, clock_now,
qualifying, 0, habit_change, staleness_hours, scenario,
state, conf_facts, ladder = _evaluate_confidence(
tier, rate, regime_days_count, segment_days, current_segment,
habit_change, staleness_hours, scenario,
)
stage = _determine_stage(state, hours_observed, total_days_count, days_below_coverage)
if stage == ProjectionStage.NO_OBSERVATIONS:
# Too little evidence for any number: no rate, no lifespan.
state = ConfidenceState.UNSUPPORTED
rate = None
scenario = None
if hours_observed > 0:
facts.insert(0, "%d of %d hours observed before the first projection"
% (hours_observed, PROVISIONAL_MIN_HOURS))
elif stage == ProjectionStage.PROVISIONAL:
# Short-horizon spread: the rate over the hours observed so far.
scenario = ScenarioRange(rates={SHORT_HORIZON_DAYS[0]: rate},
min_days=SHORT_HORIZON_DAYS[0],
max_days=SHORT_HORIZON_DAYS[0])
facts[:0] = ["%d hours observed" % hours_observed, "daily cycle not yet seen"]
all_facts = list(facts)
for cf in conf_facts:
if cf not in all_facts:
all_facts.append(cf)
headline_seconds = None
if state != ConfidenceState.UNSUPPORTED and rate is not None and rate > 0 and baseline is not None:
if tier in (BaselineTier.VERIFIED, BaselineTier.UNVERIFIED):
if state != ConfidenceState.UNSUPPORTED and rate is not None and rate > 0:
W_t, p = _get_latest_published_sample(
conn, current_segment["id"] if current_segment else None)
E_baseline = None
if tier in (BaselineTier.VERIFIED, BaselineTier.UNVERIFIED) and baseline is not None:
E_baseline = baseline["tbw_terabytes"] * TBW_TO_BYTES
elif tier == BaselineTier.IMPLIED:
p = _get_latest_pu(conn)
if p is not None and IMPLIED_P_MIN <= p <= IMPLIED_P_MAX:
E_baseline = 100 * regime_bytes / p
else:
E_baseline = None
else:
E_baseline = None
if E_baseline is not None:
headline_seconds = max(E_baseline - regime_bytes, 0) / rate
elif (tier == BaselineTier.IMPLIED and W_t is not None and p is not None
and IMPLIED_P_MIN <= p <= IMPLIED_P_MAX):
E_baseline = 100 * W_t / p
if E_baseline is not None and W_t is not None:
headline_seconds = max(E_baseline - W_t, 0) / rate
pu_line = _build_pu_context_line(conn, rate, segment_days, clock_now)
@@ -666,4 +802,8 @@ def compute_projection(conn, clock_now):
degraded_identity_fact=degraded_identity_fact,
zero_rate_fact=zero_rate_fact,
qualifying_days_progress=qualifying_days_progress,
stage=stage,
evidence_ladder=ladder,
write_rate=rate,
hours_observed=hours_observed,
)
+349 -145
View File
@@ -1,131 +1,321 @@
"""Raw sample pruning per spec §3.4, ST-5.
"""Expire detail only after UTC and local_days replacement evidence is durable.
Raw samples are pruned opportunistically to 14 days.
Hour observations and day aggregates are retained indefinitely.
Boundary anchors required for successor evidence are retained.
Local-day summaries (local_days table) are never touched by pruning.
They are persisted at collection time from hour observations and survive
raw-sample pruning because they depend on hour observations, not on raw
samples. This is the mechanism that keeps local-day history trustworthy
after detail expires (issue #93).
The 14-day cutoff never overrides local-day preservation, shared boundary
evidence, publication recovery, or the newest sample's successor-anchor role.
"""
import sqlite3
from datetime import datetime, timedelta, timezone
from datetime import date, datetime, timedelta
from .monitoring_periods import (
as_utc_datetime,
interval_within_one_monitoring_period,
)
# Spec §3.4: Raw-sample retention
RAW_SAMPLE_RETENTION_DAYS = 14
def _parse_sample_time(value: str) -> datetime | None:
"""Parse old timestamps conservatively; malformed legacy values stay."""
try:
parsed = datetime.fromisoformat(value)
except (TypeError, ValueError):
return None
return as_utc_datetime(parsed)
def _sample_rows(conn: sqlite3.Connection) -> list[tuple]:
return conn.execute(
"SELECT id, ts, segment_id, bytes_written, bytes_read, local_tz "
"FROM samples ORDER BY id"
).fetchall()
def _required_hour_window(
start: datetime,
end: datetime,
) -> tuple[datetime, datetime, int] | None:
"""Return first hour, exclusive end and count for [start, end)."""
if end <= start:
return None
first_hour = start.replace(minute=0, second=0, microsecond=0)
last_hour = end.replace(minute=0, second=0, microsecond=0)
try:
exclusive_end = last_hour if end == last_hour else last_hour + timedelta(hours=1)
except OverflowError:
return None
count = int((exclusive_end - first_hour).total_seconds() // 3600)
return first_hour, exclusive_end, count
def _has_utc_replacement(
conn: sqlite3.Connection,
previous: tuple,
current: tuple,
start: datetime,
end: datetime,
) -> bool:
window = _required_hour_window(start, end)
if window is None:
return False
first_hour, exclusive_end, expected_hour_count = window
actual_hour_count = conn.execute(
"SELECT COUNT(*) FROM hour_observations WHERE hour >= ? AND hour < ?",
(
first_hour.strftime("%Y-%m-%dT%H:00:00+00:00"),
exclusive_end.strftime("%Y-%m-%dT%H:00:00+00:00"),
),
).fetchone()[0]
if actual_hour_count < expected_hour_count:
return False
start_written, end_written = previous[3], current[3]
start_read, end_read = previous[4], current[4]
if None in (start_written, end_written, start_read, end_read):
return False
if end_written < start_written or end_read < start_read:
return False
bytes_written = end_written - start_written
bytes_read = end_read - start_read
if expected_hour_count == 1:
totals = conn.execute(
"SELECT bytes_written_delta, bytes_read_delta FROM hour_observations "
"WHERE hour = ?",
(first_hour.strftime("%Y-%m-%dT%H:00:00+00:00"),),
).fetchone()
else:
totals = conn.execute(
"SELECT unattributed_bytes_written, unattributed_bytes_read "
"FROM day_aggregates WHERE day = ?",
(start.date().isoformat(),),
).fetchone()
if totals is None or totals[0] < bytes_written or totals[1] < bytes_read:
return False
last_day = (exclusive_end - timedelta(microseconds=1)).date()
expected_day_count = (last_day - first_hour.date()).days + 1
actual_day_count = conn.execute(
"SELECT COUNT(*) FROM day_aggregates WHERE day >= ? AND day <= ?",
(first_hour.date().isoformat(), last_day.isoformat()),
).fetchone()[0]
return actual_day_count >= expected_day_count
def _local_day_row_exists(
conn: sqlite3.Connection,
local_date: str,
tz_name: str,
) -> bool:
return conn.execute(
"SELECT 1 FROM local_days WHERE local_date = ? AND tz_name = ?",
(local_date, tz_name),
).fetchone() is not None
def _local_day_bounds(
conn: sqlite3.Connection,
local_date: str,
tz_name: str,
) -> tuple[datetime, datetime] | None:
row = conn.execute(
"SELECT utc_start, utc_end FROM local_days "
"WHERE local_date = ? AND tz_name = ?",
(local_date, tz_name),
).fetchone()
if row is None:
return None
start = _parse_sample_time(row[0])
end = _parse_sample_time(row[1])
if start is None or end is None or end <= start:
return None
return start, end
def _contains_instant(
conn: sqlite3.Connection,
local_date: str,
tz_name: str,
instant: datetime,
) -> bool:
bounds = _local_day_bounds(conn, local_date, tz_name)
return bounds is not None and bounds[0] <= instant < bounds[1]
def _monitoring_period_covers(
conn: sqlite3.Connection,
start: datetime,
end: datetime,
) -> bool:
"""Require continuous monitoring before a local-day total replaces detail."""
return interval_within_one_monitoring_period(conn, start, end)
def _has_local_replacement(
conn: sqlite3.Connection,
previous: tuple,
current: tuple,
start: datetime,
end: datetime,
) -> bool:
start_tz = previous[5]
end_tz = current[5]
if not end_tz:
return False
start_id, end_id = previous[0], current[0]
segment_id = current[2]
if (
start_tz
and start_tz == end_tz
and segment_id is not None
and _monitoring_period_covers(conn, start, end)
):
known_days = conn.execute(
"SELECT local_days.utc_start, local_days.utc_end "
"FROM local_days JOIN local_day_segment_totals "
" ON local_day_segment_totals.local_day_id = local_days.id "
"WHERE local_days.tz_name = ? "
" AND local_days.activity_precision IN ('measured', 'coarse') "
" AND local_days.last_sample_id >= ? "
" AND local_days.activity_intervals > 0 "
" AND local_day_segment_totals.segment_id = ? "
" AND local_day_segment_totals.activity_intervals > 0",
(end_tz, end_id, segment_id),
).fetchall()
for row in known_days:
bounds_start = _parse_sample_time(row[0])
bounds_end = _parse_sample_time(row[1])
if (
bounds_start is not None
and bounds_end is not None
and bounds_start <= start
and end < bounds_end
):
return True
evidence = conn.execute(
"SELECT bytes_written, bytes_read, reason, start_local_date, end_local_date, "
" start_tz_name, end_tz_name, started_at, ended_at "
"FROM local_day_unallocated_evidence "
"WHERE start_sample_id = ? AND end_sample_id = ?",
(start_id, end_id),
).fetchone()
if evidence is None:
return False
start_written, end_written = previous[3], current[3]
start_read, end_read = previous[4], current[4]
if None in (start_written, end_written, start_read, end_read):
return False
if end_written < start_written or end_read < start_read:
return False
# The source IDs uniquely identify this interval. Confirm its durable
# deltas and retain every recorded local-day boundary it touches.
if (end_written - start_written, end_read - start_read) != evidence[:2]:
return False
if evidence[5] != start_tz or evidence[6] != end_tz:
return False
if evidence[2] not in {
"counter_discontinuity",
"legacy_timezone_unknown",
"timezone_change",
"monitoring_period",
"local_midnight",
"segment_unknown",
}:
return False
if _parse_sample_time(evidence[7]) != start or _parse_sample_time(evidence[8]) != end:
return False
affected_days: set[tuple[str, str]] = set()
if start_tz:
affected_days.add((evidence[3], start_tz))
affected_days.add((evidence[4], end_tz))
if start_tz and start_tz == end_tz and evidence[3] < evidence[4]:
day = date.fromisoformat(evidence[3]) + timedelta(days=1)
last_day = date.fromisoformat(evidence[4])
while day < last_day:
affected_days.add((day.isoformat(), end_tz))
day += timedelta(days=1)
if not all(
_local_day_row_exists(conn, local_date, zone)
for local_date, zone in affected_days
):
return False
if start_tz and not _contains_instant(conn, evidence[3], start_tz, start):
return False
return _contains_instant(conn, evidence[4], end_tz, end)
def _interval_has_replacement(
conn: sqlite3.Connection,
previous: tuple,
current: tuple,
) -> bool:
start = _parse_sample_time(previous[1])
end = _parse_sample_time(current[1])
if start is None or end is None or end <= start:
return False
try:
return (
_has_utc_replacement(conn, previous, current, start, end)
and _has_local_replacement(conn, previous, current, start, end)
)
except (sqlite3.Error, ValueError, OverflowError, TypeError):
# Missing or unreadable derived evidence never authorizes deletion.
return False
def _sample_needs_preservation(
conn: sqlite3.Connection,
rows: list[tuple],
index: int,
newest_id: int,
) -> bool:
sample = rows[index]
segment_id = sample[2]
if segment_id is None or conn.execute(
"SELECT 1 FROM controller_segments WHERE id = ?",
(segment_id,),
).fetchone() is None:
return True
if sample[0] == newest_id:
# Keep newest sample as the source anchor for the next collection.
return True
pairs = []
if index > 0 and rows[index - 1][2] == segment_id:
pairs.append((rows[index - 1], sample))
if index + 1 < len(rows) and rows[index + 1][2] == segment_id:
pairs.append((sample, rows[index + 1]))
return any(
not _interval_has_replacement(conn, previous, current)
for previous, current in pairs
)
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 whether an old sample still carries unreplaced evidence."""
sample_time = _parse_sample_time(sample_ts)
cutoff = as_utc_datetime(now) - timedelta(days=RAW_SAMPLE_RETENTION_DAYS)
if sample_time is None:
return True
if sample_time >= 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,),
rows = _sample_rows(conn)
matching = [index for index, row in enumerate(rows) if row[1] == sample_ts]
if not matching:
return False
newest_id = rows[-1][0] if rows else -1
return any(
_sample_needs_preservation(conn, rows, index, newest_id)
for index in matching
)
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(
@@ -133,36 +323,50 @@ def prune_old_samples(
now: datetime,
retention_days: int = RAW_SAMPLE_RETENTION_DAYS,
) -> int:
"""Remove raw samples older than retention_days.
"""Delete expired samples only after every dependent fact is durable.
Retains boundary anchors required for successor evidence.
Args:
conn: Connection to the observation store.
now: Current UTC time.
retention_days: Number of days to retain (default 14).
Returns:
Number of samples removed.
Pending publications are stored separately and never selected here. A
sample stays when it is the newest successor anchor, has an unpublishable
neighbour interval, lacks UTC or local-day replacement evidence, or has a
timestamp that cannot be safely interpreted.
"""
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
conn.commit()
return removed
cutoff = as_utc_datetime(now) - timedelta(days=retention_days)
owns_transaction = not conn.in_transaction
savepoint = "fenris_sample_retention"
if owns_transaction:
conn.execute("BEGIN IMMEDIATE")
else:
conn.execute(f"SAVEPOINT {savepoint}") # nosemgrep: sqlalchemy-execute-raw-query, formatted-sql-query -- savepoint name is a function-local constant; SQLite cannot bind identifiers
try:
rows = _sample_rows(conn)
if not rows:
if owns_transaction:
conn.commit()
else:
conn.execute(f"RELEASE SAVEPOINT {savepoint}") # nosemgrep: sqlalchemy-execute-raw-query, formatted-sql-query -- savepoint name is a function-local constant; SQLite cannot bind identifiers
return 0
newest_id = rows[-1][0]
expired = []
for index, row in enumerate(rows):
sample_time = _parse_sample_time(row[1])
if sample_time is None or sample_time >= cutoff:
continue
if _sample_needs_preservation(conn, rows, index, newest_id):
continue
expired.append(row[0])
conn.executemany("DELETE FROM samples WHERE id = ?", ((sample_id,) for sample_id in expired))
if owns_transaction:
conn.commit()
else:
conn.execute(f"RELEASE SAVEPOINT {savepoint}") # nosemgrep: sqlalchemy-execute-raw-query, formatted-sql-query -- savepoint name is a function-local constant; SQLite cannot bind identifiers
return len(expired)
except Exception:
if owns_transaction:
conn.rollback()
else:
conn.execute(f"ROLLBACK TO SAVEPOINT {savepoint}") # nosemgrep: sqlalchemy-execute-raw-query, formatted-sql-query -- savepoint name is a function-local constant; SQLite cannot bind identifiers
conn.execute(f"RELEASE SAVEPOINT {savepoint}") # nosemgrep: sqlalchemy-execute-raw-query, formatted-sql-query -- savepoint name is a function-local constant; SQLite cannot bind identifiers
raise
+28 -28
View File
@@ -1,8 +1,9 @@
"""Historical repair and retention (issue #74).
"""Historical repair and retention (issue #74, #99).
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.
rebuilds legacy local-day evidence from surviving samples or trustworthy UTC
hours, and preserves import markers, boundary anchors, and valid summaries.
Repair is idempotent and interruption-safe.
Contracts:
- Idempotent: running repair multiple times produces no duplicates
@@ -12,11 +13,11 @@ Contracts:
"""
import logging
import sqlite3
from dataclasses import dataclass, field
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Optional, List, Tuple
from .derive import find_previous_sample, derive_hours_from_interval, _parse_ts
from .derive import derive_hours_from_interval, _parse_ts
logger = logging.getLogger(__name__)
@@ -71,7 +72,6 @@ def _set_repair_in_progress(conn: sqlite3.Connection, in_progress: bool) -> None
"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:
@@ -100,8 +100,6 @@ def _update_repair_status(conn: sqlite3.Connection, result: RepairResult) -> Non
"INSERT OR REPLACE INTO store_metadata (key, value) VALUES (?, ?)",
("days_derived", str(days)),
)
conn.commit()
def get_repair_status(conn: sqlite3.Connection) -> RepairStatus:
@@ -364,20 +362,16 @@ def repair_derivation(
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
# A committed in-progress marker may be stale after process death.
# SQLite's write lock serializes active repairs; retry idempotently.
if conn.in_transaction:
conn.commit()
conn.execute("BEGIN IMMEDIATE")
_set_repair_in_progress(conn, True)
conn.commit()
conn.execute("BEGIN IMMEDIATE")
# 1. Find and derive intervals from sample pairs
@@ -407,7 +401,12 @@ def repair_derivation(
result.days_created += 1
else:
result.days_updated += 1
# Rebuild only legacy or unavailable local-day activity. This shares
# the migration derivation and leaves trustworthy published totals intact.
from .local_day import repair_legacy_local_day_evidence
repair_legacy_local_day_evidence(conn)
# 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")
@@ -428,21 +427,22 @@ def repair_derivation(
)
result.legacy_summaries_preserved = cursor.fetchone()[0]
# Commit transaction
conn.commit()
# Update repair status
# Publish derived rows and completion status together. A process
# interruption rolls back the in-progress marker with the repair.
_update_repair_status(conn, result)
_set_repair_in_progress(conn, False)
conn.commit()
except Exception as e:
conn.rollback()
try:
_set_repair_in_progress(conn, False)
conn.commit()
except sqlite3.Error:
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
-1
View File
@@ -17,7 +17,6 @@ import sqlite3
from datetime import datetime
from typing import Any, Dict, Optional, Tuple
from .collector import normalize_identity
def find_current_segment(conn: sqlite3.Connection) -> Optional[Dict[str, Any]]:
+20 -31
View File
@@ -15,15 +15,17 @@ Criteria: LC-9, CI-2, CI-4, FL-4, FL-5, FL-7.
"""
import sqlite3
from contextlib import contextmanager
import sys
from datetime import datetime, timedelta, timezone
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, Iterator, List, Optional, Tuple, TYPE_CHECKING
if TYPE_CHECKING:
from .status_composition import StatusComposition
from .projection import compute_projection, ConfidenceState, DISCLOSURES
from .projection import (
compute_projection, DISCLOSURES, ProjectionStage, ladder_count_text,
ladder_lines, write_rate_gb_day,
)
from .store import SCHEMA_VERSION
from .init_system import (
query_service_state as _init_query_service_state,
@@ -180,19 +182,6 @@ def grade_freshness(newest_sample_ts: Optional[str], clock_now: datetime) -> str
return "stale"
def freshness_age_human(age_s: Optional[int]) -> str:
"""Human-readable age string for freshness fact."""
if age_s is None:
return "unknown age"
if age_s < 60:
return "%ds ago" % age_s
if age_s < 3600:
return "%dm ago" % (age_s // 60)
if age_s < 86400:
return "%dh %dm ago" % (age_s // 3600, (age_s % 3600) // 60)
return "%dd ago" % (age_s // 86400)
# ---------------------------------------------------------------------------
# Drive anomalies (§9.7 — FL-7)
# ---------------------------------------------------------------------------
@@ -255,8 +244,7 @@ def check_retired_flag(flag: str) -> Optional[str]:
# ---------------------------------------------------------------------------
def _format_projection(proj, freshness: str, drive_facts: List[str],
config_error: Optional[str],
sample_count: int = 0, day_count: int = 0) -> str:
config_error: Optional[str]) -> str:
"""Format projection details; monitoring status has its own renderer."""
lines = []
@@ -272,15 +260,6 @@ def _format_projection(proj, freshness: str, drive_facts: List[str],
lines.append("Enable monitoring: fenris monitor resume")
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.")
return "\n".join(lines)
if proj is None:
lines.append("no projection available")
return "\n".join(lines)
@@ -290,6 +269,9 @@ def _format_projection(proj, freshness: str, drive_facts: List[str],
lines.append(headline)
lines.append("")
# --- Stage and ladder count (ADR 0012) ---
lines.append("Projection stage: %s · %s" % (proj.stage_label, ladder_count_text(proj)))
# --- 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 []
@@ -305,6 +287,11 @@ def _format_projection(proj, freshness: str, drive_facts: List[str],
lines.append("%s evidence" % state_label)
lines.append("")
# --- Full evidence ladder in the outlook (ADR 0012 §5) ---
lines.append("Evidence ladder")
lines.extend(" " + line for line in ladder_lines(proj))
lines.append("")
# --- Scenario range (§6.5) with horizon reasons ---
if proj.scenario_range:
parts = []
@@ -333,8 +320,13 @@ def _format_projection(proj, freshness: str, drive_facts: List[str],
def _format_headline(proj) -> str:
"""Format the lifespan headline or its no-projection wording (§6.11)."""
if proj.headline_remaining_seconds is None:
if proj.stage == ProjectionStage.NO_OBSERVATIONS:
return "no projection yet — collecting observations"
if proj.zero_rate_fact:
return "no finite projection from this history"
rate = write_rate_gb_day(proj)
if rate is not None:
return "write rate: %.2f GB/day · no lifespan without an endurance baseline" % rate
if proj.warming_fact:
return proj.warming_fact
return "no projection available"
@@ -507,10 +499,7 @@ def get_status(store_path: Optional[Path] = None, clock_now: Optional[datetime]
proj = compute_projection(conn, clock_now)
except (sqlite3.Error, ValueError, TypeError):
pass
parts.append(_format_projection(
proj, comp.freshness, drive_facts, config_error,
comp.sample_count, comp.day_count,
))
parts.append(_format_projection(proj, comp.freshness, drive_facts, config_error))
if query_journal and (
comp.store_fault or comp.last_collect_ok is False
or comp.freshness in ("missed", "stale")
+22 -10
View File
@@ -11,24 +11,19 @@ Freshness grading uses shared constants from status.py.
import enum
import sqlite3
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from datetime import datetime, 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,
)
from .timefmt import freshness_age_human
# Status poll interval (AC78-6): lightweight 5s systemctl show poll
STATUS_POLL_INTERVAL_S = 5
# ---------------------------------------------------------------------------
@@ -115,6 +110,7 @@ class StatusComposition:
# Sample counts for waiting explanations
sample_count: int = 0
day_count: int = 0
pending_publication_count: int = 0
# Whether the status dot should blink (only Monitoring)
should_blink: bool = False
@@ -286,6 +282,7 @@ def compose_status(
freshness_age_s = None
sample_count = 0
day_count = 0
pending_publication_count = 0
deliberately_paused = False
if conn is None and store_fault is None and newer_schema is None:
@@ -315,6 +312,10 @@ def compose_status(
sample_count = cursor.fetchone()[0]
cursor = conn.execute("SELECT COUNT(*) FROM day_aggregates")
day_count = cursor.fetchone()[0]
schema_version = conn.execute("PRAGMA user_version").fetchone()[0]
if schema_version >= 4:
cursor = conn.execute("SELECT COUNT(*) FROM pending_publications")
pending_publication_count = cursor.fetchone()[0]
except sqlite3.Error as exc:
store_fault = str(exc)
@@ -328,6 +329,7 @@ def compose_status(
freshness = "unknown"
freshness_age_s = None
sample_count = day_count = 0
pending_publication_count = 0
deliberately_paused = False
# --- External stop detection ---
@@ -417,6 +419,7 @@ def compose_status(
overlay_base=overlay_base,
sample_count=sample_count,
day_count=day_count,
pending_publication_count=pending_publication_count,
continuity=continuity,
paused_lines=paused_lines,
)
@@ -459,6 +462,8 @@ def render_status_cli(comp: StatusComposition) -> str:
facts.append("timer: %s" % (
"unknown" if comp.timer_active is None else "active" if comp.timer_active else "inactive"
))
if comp.pending_publication_count:
facts.append(_pending_publication_text(comp.pending_publication_count))
if facts:
lines.append(" · ".join(facts))
@@ -524,6 +529,8 @@ def render_status_tui(comp: StatusComposition) -> str:
facts.append("Timer: %s" % (
"unknown" if comp.timer_active is None else "active" if comp.timer_active else "inactive"
))
if comp.pending_publication_count:
facts.append(_pending_publication_text(comp.pending_publication_count).capitalize())
if facts:
lines.append(" · ".join(facts))
@@ -539,3 +546,8 @@ def render_status_tui(comp: StatusComposition) -> str:
lines.append(pl[:1].upper() + pl[1:])
return "\n".join(lines)
def _pending_publication_text(count: int) -> str:
noun = "observation" if count == 1 else "observations"
return f"pending publication: {count} {noun} retained for retry"
+237 -61
View File
@@ -3,16 +3,30 @@
This module handles:
- Store initialization with WAL mode
- Schema versioning with PRAGMA user_version
- The six entities: samples, hour_observations, day_aggregates,
monitoring_periods, controller_segments, endurance_baseline
- Observation records, derived activity, publication state, and metadata
"""
import sqlite3
from pathlib import Path
from typing import Optional
# Schema version - increment on each migration
SCHEMA_VERSION = 3
SCHEMA_VERSION = 7
# Diagnostic counters added in schema 7 (issue #111), named after the
# NVMe health-log keys they come from. All nullable: legacy rows and drives that
# do not report a counter stay unknown rather than zero.
DIAGNOSTIC_SAMPLE_COLUMNS = (
"host_reads",
"host_writes",
"controller_busy_time",
"num_err_log_entries",
"warning_temp_time",
"critical_comp_time",
"thm_temp1_trans_count",
"thm_temp1_total_time",
"thm_temp2_trans_count",
"thm_temp2_total_time",
)
# Packaged default placement (spec §8.3). The config may override it, but a
@@ -33,7 +47,7 @@ def get_store_path(config: dict) -> Path:
def init_store(store_path: Path) -> sqlite3.Connection:
"""Initialize the observation store if not present.
Creates the database with WAL mode and all six entities.
Creates the observation schema, including private pending-publication storage.
Returns a connection to the store.
"""
conn = sqlite3.connect(str(store_path))
@@ -62,7 +76,7 @@ def init_store(store_path: Path) -> sqlite3.Connection:
if current_version == 0:
# New database - create schema
_create_schema(conn)
conn.execute(f"PRAGMA user_version={SCHEMA_VERSION}")
conn.execute(f"PRAGMA user_version={SCHEMA_VERSION}") # nosemgrep: sqlalchemy-execute-raw-query, formatted-sql-query -- SCHEMA_VERSION is an int constant; PRAGMA cannot bind parameters
conn.commit()
elif current_version > SCHEMA_VERSION:
# Unknown newer version - refuse
@@ -73,9 +87,11 @@ def init_store(store_path: Path) -> sqlite3.Connection:
)
elif current_version < SCHEMA_VERSION:
# Older version - apply migrations
_apply_migrations(conn, current_version)
conn.execute(f"PRAGMA user_version={SCHEMA_VERSION}")
conn.commit()
try:
_apply_migrations(conn, current_version)
except Exception:
conn.close()
raise
return conn
@@ -107,7 +123,18 @@ def _create_schema(conn: sqlite3.Connection):
bytes_written INTEGER,
bytes_read INTEGER,
critical_warning INTEGER,
segment_id INTEGER
segment_id INTEGER,
local_tz TEXT,
host_reads INTEGER,
host_writes INTEGER,
controller_busy_time INTEGER,
num_err_log_entries INTEGER,
warning_temp_time INTEGER,
critical_comp_time INTEGER,
thm_temp1_trans_count INTEGER,
thm_temp1_total_time INTEGER,
thm_temp2_trans_count INTEGER,
thm_temp2_total_time INTEGER
)
""")
@@ -209,10 +236,18 @@ def _create_schema(conn: sqlite3.Connection):
coverage REAL DEFAULT 0.0,
sample_count INTEGER DEFAULT 0,
complete BOOLEAN DEFAULT 0,
activity_seconds INTEGER NOT NULL DEFAULT 0,
activity_intervals INTEGER NOT NULL DEFAULT 0,
activity_incomplete BOOLEAN NOT NULL DEFAULT 0,
activity_precision TEXT NOT NULL DEFAULT 'measured',
last_sample_id INTEGER,
UNIQUE(local_date, tz_name)
)
""")
_create_local_day_shared_evidence(conn)
_create_local_day_segment_totals(conn)
# Metadata table for store state (e.g., legacy import marker)
conn.execute("""
CREATE TABLE IF NOT EXISTS store_metadata (
@@ -221,59 +256,198 @@ def _create_schema(conn: sqlite3.Connection):
)
""")
_create_pending_publications(conn)
def _create_pending_publications(conn: sqlite3.Connection) -> None:
"""Create private staging for valid observations awaiting derivation."""
conn.execute("""
CREATE TABLE IF NOT EXISTS pending_publications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sample_ts TEXT NOT NULL,
payload TEXT NOT NULL
)
""")
def _create_local_day_shared_evidence(conn: sqlite3.Connection) -> None:
"""Create once-only local activity evidence that cannot be day-allocated."""
conn.execute("""
CREATE TABLE IF NOT EXISTS local_day_unallocated_evidence (
id INTEGER PRIMARY KEY AUTOINCREMENT,
start_sample_id INTEGER NOT NULL,
end_sample_id INTEGER NOT NULL,
start_local_date TEXT NOT NULL,
end_local_date TEXT NOT NULL,
start_tz_name TEXT,
end_tz_name TEXT,
started_at TEXT NOT NULL,
ended_at TEXT NOT NULL,
bytes_written INTEGER NOT NULL DEFAULT 0,
bytes_read INTEGER NOT NULL DEFAULT 0,
reason TEXT NOT NULL,
segment_id INTEGER,
UNIQUE(start_sample_id, end_sample_id)
)
""")
conn.execute(
"CREATE INDEX IF NOT EXISTS local_day_evidence_start "
"ON local_day_unallocated_evidence(start_local_date, start_tz_name)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS local_day_evidence_end "
"ON local_day_unallocated_evidence(end_local_date, end_tz_name)"
)
def _create_local_day_segment_totals(conn: sqlite3.Connection) -> None:
"""Retain the controller-segment provenance behind known day totals."""
conn.execute("""
CREATE TABLE IF NOT EXISTS local_day_segment_totals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
local_day_id INTEGER NOT NULL,
segment_id INTEGER NOT NULL,
bytes_written INTEGER NOT NULL DEFAULT 0,
bytes_read INTEGER NOT NULL DEFAULT 0,
activity_seconds INTEGER NOT NULL DEFAULT 0,
activity_intervals INTEGER NOT NULL DEFAULT 0,
UNIQUE(local_day_id, segment_id)
)
""")
def _apply_migrations(conn: sqlite3.Connection, current_version: int):
"""Apply forward-only migrations from current_version to SCHEMA_VERSION.
Each migration step is a transactional block. Add new steps as sequential
elif branches when SCHEMA_VERSION increases.
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 2→3: add local_days table for local-day activity totals
# (issue #90, ADR 0010). Pure addition — no existing rows touched.
if current_version < 3:
tables = {row[0] for row in conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
Commit each version transition independently. A failed step rolls back in
full while earlier successful steps remain versioned and retryable.
"""
migrations = {
2: _migrate_1_to_2,
3: _migrate_2_to_3,
4: _migrate_3_to_4,
5: _migrate_4_to_5,
6: _migrate_5_to_6,
7: _migrate_6_to_7,
}
while current_version < SCHEMA_VERSION:
target_version = current_version + 1
migration = migrations.get(target_version)
if migration is None:
raise ValueError(f"No migration registered for schema {target_version}")
conn.execute("BEGIN IMMEDIATE")
try:
migration(conn)
conn.execute(f"PRAGMA user_version={target_version}") # nosemgrep: sqlalchemy-execute-raw-query, formatted-sql-query -- target_version is an int key of the static migrations map; PRAGMA cannot bind parameters
conn.commit()
except Exception:
conn.rollback()
raise
current_version = target_version
def _migrate_1_to_2(conn: sqlite3.Connection) -> None:
"""Add segment provenance and unattributed UTC byte tracking."""
tables = {row[0] for row in conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
).fetchall()}
if "samples" in tables:
cols = {row[1] for row in conn.execute(
"PRAGMA table_info(samples)"
).fetchall()}
if "local_days" not in tables:
conn.execute("""
CREATE TABLE IF NOT EXISTS local_days (
id INTEGER PRIMARY KEY AUTOINCREMENT,
local_date TEXT NOT NULL,
tz_name TEXT NOT NULL,
tz_offset TEXT NOT NULL,
utc_start TEXT NOT NULL,
utc_end TEXT NOT NULL,
bytes_written INTEGER DEFAULT 0,
bytes_read INTEGER DEFAULT 0,
coverage REAL DEFAULT 0.0,
sample_count INTEGER DEFAULT 0,
complete BOOLEAN DEFAULT 0,
UNIQUE(local_date, tz_name)
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()}
for column in ("unattributed_bytes_written", "unattributed_bytes_read"):
if column not in cols:
conn.execute( # nosemgrep: sqlalchemy-execute-raw-query, formatted-sql-query -- column comes from a hardcoded tuple; DDL cannot bind identifiers
f"ALTER TABLE day_aggregates ADD COLUMN {column} INTEGER DEFAULT 0"
)
""")
current_version = 3
def _migrate_2_to_3(conn: sqlite3.Connection) -> None:
"""Add local-day activity summaries."""
tables = {row[0] for row in conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
).fetchall()}
if "local_days" not in tables:
conn.execute("""
CREATE TABLE local_days (
id INTEGER PRIMARY KEY AUTOINCREMENT,
local_date TEXT NOT NULL,
tz_name TEXT NOT NULL,
tz_offset TEXT NOT NULL,
utc_start TEXT NOT NULL,
utc_end TEXT NOT NULL,
bytes_written INTEGER DEFAULT 0,
bytes_read INTEGER DEFAULT 0,
coverage REAL DEFAULT 0.0,
sample_count INTEGER DEFAULT 0,
complete BOOLEAN DEFAULT 0,
UNIQUE(local_date, tz_name)
)
""")
def _migrate_3_to_4(conn: sqlite3.Connection) -> None:
"""Add private publication staging for acquired observations."""
_create_pending_publications(conn)
def _migrate_4_to_5(conn: sqlite3.Connection) -> None:
"""Add measured local-day evidence storage and mark old totals legacy."""
tables = {row[0] for row in conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
).fetchall()}
if "samples" in tables:
sample_cols = {row[1] for row in conn.execute(
"PRAGMA table_info(samples)"
).fetchall()}
if "local_tz" not in sample_cols:
conn.execute("ALTER TABLE samples ADD COLUMN local_tz TEXT")
if "local_days" in tables:
local_cols = {row[1] for row in conn.execute(
"PRAGMA table_info(local_days)"
).fetchall()}
for column, declaration in (
("activity_seconds", "INTEGER NOT NULL DEFAULT 0"),
("activity_intervals", "INTEGER NOT NULL DEFAULT 0"),
("activity_incomplete", "BOOLEAN NOT NULL DEFAULT 0"),
("activity_precision", "TEXT NOT NULL DEFAULT 'legacy'"),
("last_sample_id", "INTEGER"),
):
if column not in local_cols:
conn.execute( # nosemgrep: sqlalchemy-execute-raw-query, formatted-sql-query -- column and declaration come from a hardcoded tuple; DDL cannot bind identifiers
f"ALTER TABLE local_days ADD COLUMN {column} {declaration}"
)
_create_local_day_shared_evidence(conn)
_create_local_day_segment_totals(conn)
def _migrate_5_to_6(conn: sqlite3.Connection) -> None:
"""Rebuild local-day summaries from surviving trustworthy evidence."""
from .local_day import repair_legacy_local_day_evidence
repair_legacy_local_day_evidence(conn)
def _migrate_6_to_7(conn: sqlite3.Connection) -> None:
"""Add nullable diagnostic counters to samples (issue #111).
Additive only: existing rows are untouched and read NULL (unknown).
Rollback (SQLite 3.35+, with the collector stopped): for each column in
DIAGNOSTIC_SAMPLE_COLUMNS run ``ALTER TABLE samples DROP COLUMN <name>``,
then ``PRAGMA user_version=6``. Dropping loses only the new counters.
"""
cols = {row[1] for row in conn.execute("PRAGMA table_info(samples)").fetchall()}
if not cols:
return
for column in DIAGNOSTIC_SAMPLE_COLUMNS:
if column not in cols:
conn.execute(f"ALTER TABLE samples ADD COLUMN {column} INTEGER")
def migrate_to_latest(store_path: Path) -> int:
@@ -304,15 +478,17 @@ def migrate_to_latest(store_path: Path) -> int:
# 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.execute(f"PRAGMA user_version={SCHEMA_VERSION}") # nosemgrep: sqlalchemy-execute-raw-query, formatted-sql-query -- SCHEMA_VERSION is an int constant; PRAGMA cannot bind parameters
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}")
conn.commit()
try:
_apply_migrations(conn, current_version)
except Exception:
conn.close()
raise
conn.close()
return steps
+22 -5
View File
@@ -1,6 +1,6 @@
"""Fenris theme presets (issue #80).
Three accessible colour presets: Amber (default), Nord, and High Contrast.
Chalktone-inspired default plus Amber, Nord, and High Contrast.
Themes style chrome, borders, accents, muted text, and graph roles;
status semantic colours/glyphs/text always win.
@@ -126,7 +126,22 @@ _HIGH_CONTRAST = Theme(
# Theme registry
# ---------------------------------------------------------------------------
_CHALKTONE = Theme(
name="fenris-chalktone",
primary="#abc4b3", secondary="#c9b69a", accent="#dfc49a",
warning="#e9bc79", error="#e58d89", success="#acd29c",
foreground="#e0d8c5", background="#202426", surface="#202426",
panel="#252a2c", boost="#333b3d", dark=True,
variables={
"graph-allocated": "#abc4b3", "graph-unallocated": "#dfc49a",
"graph-gap": "#e58d89", "graph-zero": "#a7b1a9",
"graph-partial": "#e9bc79", "graph-selection": "#f3dbb0",
"border-default": "#606d6a", "muted-text": "#a7b1a9",
},
)
THEMES = {
"chalktone": _CHALKTONE,
"amber": _AMBER,
"nord": _NORD,
"high_contrast": _HIGH_CONTRAST,
@@ -138,24 +153,26 @@ THEME_NAMES = set(THEMES.keys())
def get_theme(name: str) -> Theme:
"""Return a registered theme by preset name.
Unknown names fall back to Amber.
Unknown names fall back to Chalktone.
"""
return THEMES.get(name, _AMBER)
return THEMES.get(name, _CHALKTONE)
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.
Returns a dict with keys: allocated, unallocated, secondary (the second
series), gap, zero, partial, selection and muted text. Falls back to Chalktone 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"),
"secondary": 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"),
"muted": variables.get("muted-text", "#a7b1a9"),
}
+63
View File
@@ -0,0 +1,63 @@
"""Time formatting for the dashboard and CLI.
One home for every user-facing time string: local clock times labelled with
their timezone, the secondary UTC form shown beside them, and relative ages.
Stored evidence stays UTC; only presentation is localised.
"""
from datetime import datetime, timezone
from typing import Optional
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from .tz_util import detect_system_tz, get_tz_offset_str
def local_zone_name() -> str:
"""Return the system timezone name, falling back to UTC when unusable."""
name = detect_system_tz()
try:
ZoneInfo(name)
except (ZoneInfoNotFoundError, ValueError, OSError):
return "UTC"
return name
def tz_label(when: datetime, zone_name: str) -> str:
"""Timezone name with its UTC offset at *when*, e.g. ``Asia/Kolkata +05:30``."""
return "%s %s" % (zone_name, get_tz_offset_str(when, zone_name))
def local_clock(when: datetime, zone_name: str) -> str:
"""Local wall-clock ``HH:MM`` for *when*."""
return when.astimezone(ZoneInfo(zone_name)).strftime("%H:%M")
def local_timestamp(when: datetime, zone_name: str) -> str:
"""Local date, clock time and timezone label for CLI output."""
local = when.astimezone(ZoneInfo(zone_name))
return "%s %s" % (local.strftime("%Y-%m-%d %H:%M"), tz_label(when, zone_name))
def utc_stamp_label(when: datetime) -> str:
"""Secondary UTC form of a single instant."""
return when.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
def utc_range_label(start: datetime, end: datetime) -> str:
"""Secondary UTC form of an interval."""
return "%s → %s UTC" % (
start.astimezone(timezone.utc).strftime("%H:%M"),
end.astimezone(timezone.utc).strftime("%H:%M"),
)
def freshness_age_human(age_s: Optional[int]) -> str:
"""Human-readable age string for freshness fact."""
if age_s is None:
return "unknown age"
if age_s < 60:
return "%ds ago" % age_s
if age_s < 3600:
return "%dm ago" % (age_s // 60)
if age_s < 86400:
return "%dh %dm ago" % (age_s // 3600, (age_s % 3600) // 60)
return "%dd ago" % (age_s // 86400)
+1020 -1155
View File
File diff suppressed because it is too large Load Diff
+9 -6
View File
@@ -5,7 +5,7 @@ computation used by local-day derivation. All functions are
stateless and safe to call from the collector and TUI reader.
"""
import os
from datetime import datetime, timezone
from datetime import datetime
from pathlib import Path
@@ -25,11 +25,14 @@ def detect_system_tz() -> str:
localtime = Path("/etc/localtime")
if localtime.is_symlink():
target = os.readlink(str(localtime))
# Strip common prefixes: /usr/share/zoneinfo/, /usr/lib/zoneinfo/
for prefix in ("/usr/share/zoneinfo/", "/usr/lib/zoneinfo/"):
if target.startswith(prefix):
return target[len(prefix):]
target_path = Path(os.readlink(str(localtime)))
if not target_path.is_absolute():
target_path = localtime.parent / target_path
target = target_path.resolve().as_posix()
marker = "/zoneinfo/"
marker_index = target.find(marker)
if marker_index >= 0:
return target[marker_index + len(marker):]
return target
return "UTC"
-1
View File
@@ -1,7 +1,6 @@
"""Shared test helpers for Fenris test suite."""
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parent.parent
VERSION_FILE = REPO_ROOT / "pyproject.toml"
+5 -13
View File
@@ -9,7 +9,6 @@ CI-3 Prohibition set: automated structural checks
CI-4 Required wording and six disclosures in both views
"""
import re
import sqlite3
from datetime import datetime, timedelta, timezone
from pathlib import Path
from unittest.mock import patch
@@ -19,16 +18,13 @@ import sys
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from fenris.store import init_store, SCHEMA_VERSION
from fenris.store import init_store
from fenris.monitoring_periods import ensure_period_open
from fenris.projection import (
compute_projection,
ConfidenceState,
BaselineTier,
DISCLOSURES,
STALENESS_HOURS,
WARMING_MIN_DAYS,
YOUNG_REGIME_DAYS,
)
from fenris.status import (
grade_freshness,
@@ -36,13 +32,9 @@ from fenris.status import (
render_status,
format_disclosures,
FRESH_THRESHOLD_S,
STALENESS_THRESHOLD_S,
CADENCE_DEFAULT_S,
ACCURACY_SEC,
)
from fenris.tui import (
FenrisTuiApp,
_format_remaining,
)
@@ -98,8 +90,8 @@ def _insert_day(conn, day, bw=1024*1024*100, coverage=0.95, samples=24):
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 (?, ?, ?, ?, ?, ?, ?, ?)",
"percentage_used, bytes_written, bytes_read, power_on_hours, segment_id) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1)",
(ts, "/dev/nvme0n1", 1000000, 500000, pu, 512000000000, 256000000000, 8765),
)
conn.commit()
@@ -121,8 +113,8 @@ def _insert_local_day(conn, local_date, tz_name="UTC", tz_offset="+00:00",
conn.execute(
"INSERT INTO local_days "
"(local_date, tz_name, tz_offset, utc_start, utc_end, "
" bytes_written, bytes_read, coverage, sample_count, complete) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
" bytes_written, bytes_read, coverage, sample_count, complete, activity_intervals) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)",
(local_date, tz_name, tz_offset, utc_start, utc_end,
bw, br, coverage, samples, complete),
)
+74
View File
@@ -0,0 +1,74 @@
"""Visible geometry and evidence boundaries of the terminal volume plot."""
from fenris.activity_plot import VolumePoint, volume_plot
from fenris.themes import get_graph_colors
def plot(points, selected=-1, width=48, height=10):
return volume_plot(points, width, height, selected, get_graph_colors("chalktone"))
def test_trace_fits_viewport_and_uses_time_not_sample_index():
points = [VolumePoint(t, v, str(t)) for t, v in ((0, 0), (3, 50), (30, 100))]
text, columns, unit = plot(points)
assert unit == "B"
assert len(text.plain.splitlines()) == 10
assert all(len(line) <= 48 for line in text.plain.splitlines())
assert columns[1] - columns[0] < (columns[2] - columns[0]) / 5
assert any(0x2801 <= ord(c) <= 0x28ff for c in text.plain)
assert not any(c in text.plain for c in "█▒░")
assert "100.00" in text.plain and "0.00" in text.plain
def test_gap_breaks_trace_while_measured_zero_stays_on_axis():
text, columns, _ = plot([
VolumePoint(0, 0, "00:00"), VolumePoint(1, None, "00:03", "gap"),
VolumePoint(2, 100, "00:06"),
])
rows = text.plain.splitlines()
assert "?" in rows[-2]
# No invented intermediate dots on either side of the missing measurement.
for row in rows[:-2]:
assert all(c == " " for c in row[columns[0] + 1:columns[-1]])
assert 0x2801 <= ord(rows[-3][columns[0]]) <= 0x28ff
def test_partial_and_unallocated_totals_are_isolated_and_labelled():
text, columns, unit = plot([
VolumePoint(0, 1_000_000, "01", "partial"),
VolumePoint(1, 2_000_000, "02", "unallocated"),
VolumePoint(2, 3_000_000, "03"),
])
assert unit == "MB"
assert "~" in text.plain and "u" in text.plain
for row in text.plain.splitlines()[:-2]:
assert all(c == " " for c in row[columns[0] + 1:columns[1]])
def test_many_intervals_and_selection_fit_small_plot_without_losing_points():
points = [VolumePoint(i, i % 9 * 1_000_000, str(i)) for i in range(180)]
text, columns, _ = plot(points, selected=100, width=32, height=6)
assert len(columns) == len(points)
assert text.plain.splitlines()[-2][columns[100]] == "▼"
assert all(len(row) == 32 for row in text.plain.splitlines())
def test_secondary_series_shares_the_axis_and_colours_its_own_dots():
colors = get_graph_colors("chalktone")
writes = [VolumePoint(t, v, str(t)) for t, v in ((0, 10), (1, 10), (2, 10))]
reads = [VolumePoint(t, v, str(t)) for t, v in ((0, 100), (1, 100), (2, 100))]
text, columns, unit = volume_plot(
writes, 48, 10, -1, colors, secondary=reads)
assert "100.00" in text.plain # the larger read volume sets the ceiling
styles = {span.style for span in text.spans}
assert colors["allocated"] in styles and colors["secondary"] in styles
def test_secondary_gap_does_not_draw_or_connect():
colors = get_graph_colors("chalktone")
points = [VolumePoint(t, 5, str(t)) for t in range(3)]
reads = [VolumePoint(0, 9, "0"), VolumePoint(1, None, "1", "gap"),
VolumePoint(2, 9, "2")]
text, columns, _ = volume_plot(points, 48, 10, -1, colors, secondary=reads)
rows = text.plain.splitlines()[:-2]
top = next(r for r in rows if any(0x2801 <= ord(c) <= 0x28ff for c in r))
assert all(c == " " for c in top[columns[0] + 1:columns[-1]])
+483
View File
@@ -0,0 +1,483 @@
"""Tests for the Activity readout module (issue #108).
The readout is exercised against SQLite fixtures only; no Textual is involved.
The local timezone is pinned through the TZ environment variable.
"""
from datetime import datetime, timezone
from pathlib import Path
import sys
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from fenris.activity_readout import (
critical_warning_badge,
decode_critical_warning,
diagnostics_text,
day_readout_text,
hour_readout_text,
local_day_text,
interval_readout_text,
readout,
selected_local_day,
)
from fenris.activity_selection import (
HISTORY_RANGE_MAX,
ActivitySelection,
HistoryDayIdentity,
)
from fenris.store import init_store
from fenris.timefmt import (
freshness_age_human,
local_timestamp,
utc_range_label,
utc_stamp_label,
)
NOW = datetime(2026, 9, 30, 20, 0, tzinfo=timezone.utc) # 01:30 on 10-01 in Kolkata
@pytest.fixture
def kolkata(monkeypatch):
monkeypatch.setenv("TZ", "Asia/Kolkata")
@pytest.fixture
def conn(tmp_path):
connection = init_store(tmp_path / "observations.db")
yield connection
connection.close()
def _local_day(conn, local_date, utc_start, utc_end, tz="Asia/Kolkata",
offset="+05:30", written=1_000_000_000, read=500_000_000,
intervals=1, seconds=86400, incomplete=0, precision="measured"):
conn.execute(
"INSERT INTO local_days (local_date, tz_name, tz_offset, utc_start, "
"utc_end, bytes_written, bytes_read, coverage, sample_count, complete, "
"activity_seconds, activity_intervals, activity_incomplete, "
"activity_precision) VALUES (?,?,?,?,?,?,?,1.0,24,1,?,?,?,?)",
(local_date, tz, offset, utc_start, utc_end, written, read,
seconds, intervals, incomplete, precision),
)
conn.commit()
def _kolkata_day(conn, local_date, **kwargs):
day = datetime.fromisoformat(local_date)
start = datetime(day.year, day.month, day.day, tzinfo=timezone.utc)
start_utc = start.timestamp() - 19800
end_utc = start_utc + 86400
_local_day(
conn, local_date,
datetime.fromtimestamp(start_utc, timezone.utc).isoformat(),
datetime.fromtimestamp(end_utc, timezone.utc).isoformat(),
**kwargs,
)
def _hour(conn, hour, written=0, read=0, active=0, idle=0, off=0, unknown=0,
samples=1, coverage=1.0, tmin=None, tmax=None, tavg=None):
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_max, temperature_avg, "
"sample_count, coverage) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
(hour, active, idle, off, unknown, written, read, tmin, tmax, tavg,
samples, coverage),
)
conn.commit()
def _sample(conn, ts, written, read, segment=1, temp=35, critical=0):
conn.execute(
"INSERT INTO samples (ts, device, data_units_written, data_units_read, "
"percentage_used, bytes_written, bytes_read, power_on_hours, "
"segment_id, temperature_c, critical_warning) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?)",
(ts, "/dev/nvme0n1", 1, 1, 1, written, read, 100, segment, temp,
critical),
)
conn.commit()
class TestHistoryDays:
def test_daily_bars_use_local_dates_not_utc_aggregates(self, conn, kolkata):
# A UTC aggregate for 09-30 must never be relabelled as a local day.
conn.execute(
"INSERT INTO day_aggregates (day, bytes_written_delta, "
"sample_count, coverage) VALUES ('2026-09-30', 9000000000, 24, 1.0)"
)
conn.commit()
_kolkata_day(conn, "2026-10-01")
result = readout(conn, ActivitySelection(), NOW)
assert result.days[-1]["day"] == "2026-10-01"
assert result.days[-1]["timezone_label"] == "Asia/Kolkata +05:30"
by_day = {d["day"]: d for d in result.days}
assert by_day["2026-09-30"]["is_gap"]
assert by_day["2026-09-30"]["total_written"] is None
def test_window_is_exact_and_missing_days_stay_explicit_gaps(self, conn, kolkata):
_kolkata_day(conn, "2026-09-29")
result = readout(conn, ActivitySelection(), NOW)
assert len(result.days) == HISTORY_RANGE_MAX
gaps = [d for d in result.days if d["is_gap"]]
assert len(gaps) == HISTORY_RANGE_MAX - 1 # only 09-29 has evidence
known = next(d for d in result.days if d["day"] == "2026-09-29")
assert known["activity_state"] == "complete"
assert known["total_written"] == 1_000_000_000
def test_current_local_day_is_partial_and_incomplete_day_is_flagged(self, conn, kolkata):
_kolkata_day(conn, "2026-10-01", written=100)
_kolkata_day(conn, "2026-09-30", written=200, seconds=3600)
by_day = {d["day"]: d for d in readout(conn, ActivitySelection(), NOW).days}
assert by_day["2026-10-01"]["activity_state"] == "so_far"
assert by_day["2026-10-01"]["is_partial"]
assert by_day["2026-09-30"]["activity_state"] == "incomplete"
assert by_day["2026-09-30"]["is_partial"]
def test_shared_local_day_evidence_is_kept_apart_not_prorated(self, conn, kolkata):
_kolkata_day(conn, "2026-09-29", written=7_000_000_000, read=3_000_000_000)
conn.execute(
"INSERT INTO local_day_unallocated_evidence (start_sample_id, "
"end_sample_id, start_local_date, end_local_date, start_tz_name, "
"end_tz_name, started_at, ended_at, bytes_written, bytes_read, "
"reason) VALUES (1,2,'2026-09-29','2026-09-30','Asia/Kolkata',"
"'Asia/Kolkata','2026-09-29T18:00:00+00:00',"
"'2026-09-29T18:40:00+00:00',1000000000,500000000,'local_midnight')"
)
conn.commit()
day = next(
d for d in readout(conn, ActivitySelection(), NOW).days
if d["day"] == "2026-09-29"
)
assert day["total_written"] == 7_000_000_000
assert day["shared_bytes_written"] == 1_000_000_000
assert day["shared_bytes_read"] == 500_000_000
text = day_readout_text(day)
assert "W 7.000 GB known" in text
assert "shared at midnight W 1.000 GB · R 0.500 GB" in text
def test_unrecorded_selected_date_is_an_unavailable_gap(self, conn, kolkata):
selection = ActivitySelection()
selection.select_history_date("2026-06-01")
result = readout(conn, selection, NOW)
assert result.local_date == "2026-06-01"
assert result.local_day is None
picked = [d for d in result.days if d["day"] == "2026-06-01"]
assert picked and picked[0]["is_gap"]
assert "local-day evidence unavailable" in day_readout_text(picked[0])
class TestLocalDayStrip:
def test_current_day_text_has_zone_state_and_habit(self, conn, kolkata):
_kolkata_day(conn, "2026-10-01", written=2_000_000_000, read=1_000_000_000)
_hour(conn, "2026-09-30T19:00:00+00:00", active=3600, tmin=30, tmax=44)
local_date, local = selected_local_day(conn, ActivitySelection(), NOW)
text = local_day_text(local_date, local)
assert "2026-10-01" in text and "Asia/Kolkata +05:30" in text
assert "totals so far" in text
assert "W 2.000 GB known · R 1.000 GB known" in text
assert "active 1h 00m" in text and "30–44°C" in text
def test_missing_day_text_is_unavailable(self, conn, kolkata):
local_date, local = selected_local_day(conn, ActivitySelection(), NOW)
assert local is None
assert "local-day evidence unavailable" in local_day_text(local_date, local)
class TestHours:
def _selected_day(self, conn):
_kolkata_day(conn, "2026-09-29")
selection = ActivitySelection()
selection.select_history_date(
"2026-09-29",
HistoryDayIdentity(
"2026-09-29", "Asia/Kolkata",
"2026-09-28T18:30:00+00:00", "2026-09-29T18:30:00+00:00",
),
)
selection.set_view("day")
return selection
def test_hours_only_in_day_view_with_local_and_secondary_utc_labels(self, conn, kolkata):
selection = self._selected_day(conn)
_hour(conn, "2026-09-28T19:00:00+00:00", written=4_000_000_000,
active=600, idle=2400, off=0, unknown=600, tmin=31, tmax=40,
coverage=0.8)
result = readout(conn, selection, NOW)
first = result.hours[0]
assert first["local_label"] == "00:30"
assert first["timezone_label"] == "Asia/Kolkata +05:30"
assert first["utc_label"] == "2026-09-28 19:00 UTC"
assert first["bytes_written"] == 4_000_000_000
text = hour_readout_text(first, "2026-09-29")
assert "00:30 Asia/Kolkata +05:30" in text
assert text.splitlines()[-1] == "2026-09-28 19:00 UTC"
assert "active 10m · idle 40m · off 0m · unknown 10m" in text
assert "31–40°C" in text
def test_live_view_has_no_hours(self, conn, kolkata):
_kolkata_day(conn, "2026-09-29")
assert readout(conn, ActivitySelection(), NOW).hours == ()
def test_missing_hour_is_gap_and_future_hour_is_not(self, conn, kolkata):
_kolkata_day(conn, "2026-10-01")
selection = ActivitySelection()
selection.select_history_date("2026-10-01")
selection.set_view("day")
result = readout(conn, selection, NOW)
past = next(h for h in result.hours if h["local_label"] == "00:30")
future = next(h for h in result.hours if h["local_label"] == "06:30")
assert past["is_gap"] and not past["is_future"]
assert future["is_future"] and not future["is_gap"]
assert "W unavailable · R unavailable" in hour_readout_text(past, "2026-10-01")
class TestLiveIntervals:
def test_interval_labels_are_local_with_secondary_utc_line(self, conn, kolkata):
_sample(conn, "2026-09-30T19:30:00+00:00", 1000, 500)
_sample(conn, "2026-09-30T19:33:00+00:00", 3000, 900, temp=41)
result = readout(conn, ActivitySelection(), NOW)
point = result.live[0]
assert point["start_label"] == "01:00"
assert point["end_label"] == "01:03"
assert point["timezone_label"] == "Asia/Kolkata +05:30"
assert point["bytes_written"] == 2000
assert point["temperature_c"] == 41
text = interval_readout_text(point)
assert "01:00 → 01:03 Asia/Kolkata +05:30" in text
assert text.splitlines()[-1] == "19:30 → 19:33 UTC"
assert "41°C" in text
def test_drive_warning_is_part_of_the_readout(self, conn, kolkata):
_sample(conn, "2026-09-30T19:30:00+00:00", 1000, 500)
_sample(conn, "2026-09-30T19:33:00+00:00", 3000, 900, critical=4)
point = readout(conn, ActivitySelection(), NOW).live[0]
assert "critical warning" in interval_readout_text(point)
def test_segment_boundary_is_unavailable(self, conn, kolkata):
_sample(conn, "2026-09-30T19:30:00+00:00", 1000, 500, segment=1)
_sample(conn, "2026-09-30T19:33:00+00:00", 5, 5, segment=2)
point = readout(conn, ActivitySelection(), NOW).live[0]
assert "segment boundary" in interval_readout_text(point)
assert "W unavailable · R unavailable" in interval_readout_text(point)
class TestTimezone:
def test_utc_zone_still_carries_a_label(self, conn, monkeypatch):
monkeypatch.setenv("TZ", "UTC")
_sample(conn, "2026-09-30T19:30:00+00:00", 1000, 500)
_sample(conn, "2026-09-30T19:33:00+00:00", 3000, 900)
result = readout(conn, ActivitySelection(), NOW)
assert result.timezone_name == "UTC"
assert result.timezone_label == "UTC +00:00"
assert result.live[0]["start_label"] == "19:30"
assert result.live[0]["timezone_label"] == "UTC +00:00"
def test_unknown_zone_falls_back_to_utc(self, conn, monkeypatch):
monkeypatch.setenv("TZ", "Not/AZone")
assert readout(conn, ActivitySelection(), NOW).timezone_name == "UTC"
def test_health_comes_from_the_latest_sample(self, conn, kolkata):
assert readout(conn, ActivitySelection(), NOW).health["model"] == "unknown"
_sample(conn, "2026-09-30T19:30:00+00:00", 1000, 500, temp=38)
assert readout(conn, ActivitySelection(), NOW).health["temp"] == 38
class TestTimeFormatting:
def test_freshness_age_human_lives_here(self):
assert freshness_age_human(30) == "30s ago"
assert freshness_age_human(None) == "unknown age"
def test_local_timestamp_names_the_zone(self):
when = datetime(2026, 9, 30, 20, 0, tzinfo=timezone.utc)
assert local_timestamp(when, "Asia/Kolkata") == (
"2026-10-01 01:30 Asia/Kolkata +05:30"
)
def test_utc_labels(self):
a = datetime(2026, 9, 30, 19, 30, tzinfo=timezone.utc)
b = datetime(2026, 9, 30, 19, 33, tzinfo=timezone.utc)
assert utc_range_label(a, b) == "19:30 → 19:33 UTC"
assert utc_stamp_label(a) == "2026-09-30 19:30 UTC"
def _counters(conn, ts, poh, cycles, unsafe, media, spare, critical=0):
conn.execute(
"INSERT INTO samples (ts, device, data_units_written, data_units_read, "
"percentage_used, bytes_written, bytes_read, power_on_hours, "
"power_cycles, unsafe_shutdowns, media_errors, available_spare, "
"segment_id, temperature_c, critical_warning) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
(ts, "/dev/nvme0n1", 1, 1, 1, 1, 1, poh, cycles, unsafe, media, spare,
1, 35, critical),
)
conn.commit()
class TestCriticalWarning:
def test_zero_decodes_to_nothing_and_each_bit_to_its_meaning(self):
assert decode_critical_warning(0) == ()
assert decode_critical_warning(None) == ()
assert decode_critical_warning(0x01) == ("available spare below threshold",)
assert decode_critical_warning(0x05) == (
"available spare below threshold", "reliability degraded",
)
assert "temperature outside threshold" in decode_critical_warning(0x02)
assert "media in read-only mode" in decode_critical_warning(0x08)
assert "volatile memory backup failed" in decode_critical_warning(0x10)
assert "persistent memory region read-only" in decode_critical_warning(0x20)
assert decode_critical_warning(0x40) == ("unrecognised bits 0x40",)
def test_health_carries_latest_critical_warning_and_badge_follows_it(self, conn, kolkata):
_counters(conn, "2026-09-30T19:00:00+00:00", 10, 1, 0, 0, 100, critical=0)
assert critical_warning_badge(
readout(conn, ActivitySelection(), NOW).health) == ""
_counters(conn, "2026-09-30T19:30:00+00:00", 11, 1, 0, 0, 100, critical=0x04)
health = readout(conn, ActivitySelection(), NOW).health
assert health["critical_warning"] == 0x04
badge = critical_warning_badge(health)
assert "0x04" in badge and "reliability degraded" in badge
def test_no_samples_means_no_badge(self, conn, kolkata):
assert critical_warning_badge(
readout(conn, ActivitySelection(), NOW).health) == ""
class TestDiagnosticsReadout:
def test_diagnostics_are_absent_until_selected(self, conn, kolkata):
assert readout(conn, ActivitySelection(), NOW).diagnostics is None
selection = ActivitySelection()
selection.toggle_diagnostics()
assert selection.diagnostics
assert readout(conn, selection, NOW).diagnostics is not None
selection.toggle_diagnostics()
assert not selection.diagnostics
def test_live_counters_have_deltas_over_the_live_window(self, conn, kolkata):
_counters(conn, "2026-09-30T16:00:00+00:00", 90, 5, 1, 0, 100) # before window
_counters(conn, "2026-09-30T18:00:00+00:00", 100, 6, 1, 0, 100)
_counters(conn, "2026-09-30T19:50:00+00:00", 103, 8, 3, 2, 98, critical=0x02)
selection = ActivitySelection()
selection.toggle_diagnostics()
diag = readout(conn, selection, NOW).diagnostics
assert diag["window_label"] == "last 3h"
assert diag["critical_warning"] == 0x02
assert diag["critical_warning_flags"] == ("temperature outside threshold",)
counters = diag["counters"]
assert counters["power_on_hours"] == {"value": 103, "delta": 13}
assert counters["power_cycles"] == {"value": 8, "delta": 3}
assert counters["unsafe_shutdowns"] == {"value": 3, "delta": 2}
assert counters["media_errors"] == {"value": 2, "delta": 2}
assert counters["available_spare"] == {"value": 98, "delta": -2}
def test_no_delta_across_a_controller_segment_change(self, conn, kolkata):
_counters(conn, "2026-09-30T18:00:00+00:00", 100, 6, 1, 0, 100)
_counters(conn, "2026-09-30T19:50:00+00:00", 5, 1, 0, 0, 100)
conn.execute("UPDATE samples SET segment_id = 2 WHERE ts > '2026-09-30T19'")
conn.commit()
selection = ActivitySelection()
selection.toggle_diagnostics()
counters = readout(conn, selection, NOW).diagnostics["counters"]
assert counters["power_on_hours"] == {"value": 5, "delta": None}
def test_single_sample_has_no_delta(self, conn, kolkata):
_counters(conn, "2026-09-30T19:50:00+00:00", 103, 8, 3, 2, 98)
selection = ActivitySelection()
selection.toggle_diagnostics()
counters = readout(conn, selection, NOW).diagnostics["counters"]
assert counters["power_on_hours"] == {"value": 103, "delta": None}
def test_day_selection_measures_deltas_across_that_local_day(self, conn, kolkata):
selection = TestHours()._selected_day(conn)
_counters(conn, "2026-09-28T18:00:00+00:00", 50, 2, 0, 0, 100)
_counters(conn, "2026-09-29T06:00:00+00:00", 56, 2, 1, 0, 100)
_counters(conn, "2026-09-29T20:00:00+00:00", 99, 9, 9, 9, 50) # after the day
selection.toggle_diagnostics()
diag = readout(conn, selection, NOW).diagnostics
assert diag["window_label"] == "day 2026-09-29"
assert diag["counters"]["power_on_hours"] == {"value": 56, "delta": 6}
assert diag["counters"]["unsafe_shutdowns"]["delta"] == 1
def test_hours_carry_temperature_average(self, conn, kolkata):
selection = TestHours()._selected_day(conn)
_hour(conn, "2026-09-29T00:00:00+00:00", active=1800, idle=1800,
tmin=31, tavg=36.5, tmax=44)
hour = next(h for h in readout(conn, selection, NOW).hours
if h["hour"] == "2026-09-29T00:00:00+00:00")
assert (hour["temperature_min"], hour["temperature_avg"],
hour["temperature_max"]) == (31, 36.5, 44)
def test_history_days_carry_split_and_temperature_range(self, conn, kolkata):
_kolkata_day(conn, "2026-09-29")
_hour(conn, "2026-09-29T00:00:00+00:00", active=1800, idle=1200, off=600,
tmin=30, tavg=34.0, tmax=40)
_hour(conn, "2026-09-29T01:00:00+00:00", active=0, idle=3000, unknown=600,
tmin=28, tavg=30.0, tmax=33)
selection = ActivitySelection()
selection.set_view("history")
selection.toggle_diagnostics()
days = {d["day"]: d for d in readout(conn, selection, NOW).diagnostics["days"]}
day = days["2026-09-29"]
assert (day["active_seconds"], day["idle_seconds"],
day["powered_off_seconds"], day["unknown_seconds"]) == (1800, 4200, 600, 600)
assert (day["temperature_min"], day["temperature_max"]) == (28, 40)
assert day["temperature_avg"] == pytest.approx(32.0)
assert "2026-09-30" not in days # no evidence, no invented split
class TestDiagnosticsText:
def _diag(self, conn, selection):
selection.toggle_diagnostics()
return readout(conn, selection, NOW)
def test_panel_shows_decoded_warning_counters_and_deltas(self, conn, kolkata):
_counters(conn, "2026-09-30T18:00:00+00:00", 100, 6, 1, 0, 100)
_counters(conn, "2026-09-30T19:50:00+00:00", 103, 8, 3, 2, 98, critical=0x05)
text = diagnostics_text(self._diag(conn, ActivitySelection()))
assert "last 3h" in text
assert "0x05" in text and "reliability degraded" in text
assert "Power-on hours" in text and "103" in text and "+3" in text
assert "Unsafe shutdowns" in text and "+2" in text
assert "Available spare" in text and "98%" in text and "-2" in text
def test_no_warning_says_none_and_missing_delta_says_so(self, conn, kolkata):
_counters(conn, "2026-09-30T19:50:00+00:00", 103, 8, 3, 2, 98)
text = diagnostics_text(self._diag(conn, ActivitySelection()))
assert "Critical warning none" in text
assert "Δ n/a" in text
def test_day_view_lists_each_hour_with_temperature_split_and_coverage(self, conn, kolkata):
selection = TestHours()._selected_day(conn)
_hour(conn, "2026-09-29T00:00:00+00:00", active=1800, idle=1200, off=300,
unknown=300, tmin=31, tavg=36.4, tmax=44, coverage=0.75)
text = diagnostics_text(self._diag(conn, selection))
row = next(line for line in text.splitlines() if line.startswith("05:30"))
assert "31/36/44°C" in row
assert "A30m I20m Off5m ?5m" in row
assert "75%" in row
assert any("gap" in line for line in text.splitlines()
if line.startswith("06:30"))
def test_history_view_lists_each_day_with_split_and_temperature(self, conn, kolkata):
_kolkata_day(conn, "2026-09-29")
_hour(conn, "2026-09-29T00:00:00+00:00", active=7200, idle=3600, off=0,
tmin=30, tavg=34.0, tmax=40)
selection = ActivitySelection()
selection.set_view("history")
text = diagnostics_text(self._diag(conn, selection))
row = next(line for line in text.splitlines() if line.startswith("09-29"))
assert "A2h 00m" in row and "I1h 00m" in row and "30/34/40°C" in row
def test_live_view_summarises_interval_temperature(self, conn, kolkata):
for index, (minute, temp) in enumerate(((0, 30), (3, 36), (6, 42))):
conn.execute(
"INSERT INTO samples (ts, device, bytes_written, bytes_read, "
"segment_id, temperature_c) VALUES (?,?,?,?,?,?)",
("2026-09-30T19:%02d:00+00:00" % (50 + minute), "/dev/n", index, index, 1, temp),
)
conn.commit()
text = diagnostics_text(self._diag(conn, ActivitySelection()))
assert "36/39/42°C" in text and "2 intervals" in text # interval ends
+94
View File
@@ -0,0 +1,94 @@
"""Activity-selection transitions independent from Textual widgets."""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from fenris.activity_selection import ActivitySelection, IntervalIdentity
def _interval(n):
return IntervalIdentity(f"start-{n}", f"end-{n}")
def test_live_selection_starts_and_stays_on_newest_interval():
selection = ActivitySelection()
first = [_interval(1), _interval(2)]
selection.update_live(first)
assert selection.following_live
assert selection.selected_live_interval == first[-1]
second = first + [_interval(3)]
selection.update_live(second)
assert selection.selected_live_interval == second[-1]
def test_keyboard_and_mouse_inspection_pin_interval_identity():
intervals = [_interval(1), _interval(2), _interval(3)]
keyboard = ActivitySelection()
mouse = ActivitySelection()
keyboard.update_live(intervals)
keyboard.move_live(intervals, -1)
mouse.update_live(intervals)
mouse.inspect_live(intervals, intervals[1])
assert keyboard.selected_live_interval == mouse.selected_live_interval == intervals[1]
assert not keyboard.following_live
assert not mouse.following_live
refreshed = [_interval(2), _interval(3), _interval(4)]
keyboard.update_live(refreshed)
mouse.update_live(refreshed)
assert keyboard.selected_live_interval == mouse.selected_live_interval == intervals[1]
def test_expired_pin_explains_expiration_and_resumes_following():
selection = ActivitySelection()
intervals = [_interval(1), _interval(2), _interval(3)]
selection.update_live(intervals)
selection.inspect_live(intervals, intervals[0])
newest = _interval(4)
selection.update_live([_interval(2), _interval(3), newest])
assert selection.following_live
assert selection.selected_live_interval == newest
assert selection.live_interval_expired
def test_today_resets_expired_pin_and_read_write_toggle_keeps_identity():
selection = ActivitySelection()
intervals = [_interval(1), _interval(2)]
selection.update_live(intervals)
selection.inspect_live(intervals, intervals[0])
selection.update_live([intervals[1]])
newest = _interval(3)
selection.update_live([intervals[1], newest])
assert selection.live_interval_expired
selection.toggle_measure()
assert selection.measure == "read"
assert selection.selected_live_interval == newest
selection.select_history_date("2026-09-27")
selection.set_view("history")
selection.set_view("live")
assert selection.browse_date is None
assert selection.following_live
assert not selection.live_interval_expired
def test_live_identity_uses_both_interval_boundaries():
first = IntervalIdentity("2026-09-28T10:00:00Z", "2026-09-28T10:03:00Z")
changed_start = IntervalIdentity("2026-09-28T09:57:00Z", "2026-09-28T10:03:00Z")
selection = ActivitySelection()
selection.update_live([first])
selection.inspect_live([first], first)
selection.update_live([changed_start])
assert selection.following_live
assert selection.selected_live_interval == changed_start
assert selection.live_interval_expired
+165 -7
View File
@@ -8,7 +8,6 @@ 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
@@ -19,10 +18,9 @@ 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
from fenris.collector import run_collection
from fenris.store import init_store, SCHEMA_VERSION
from fenris.monitoring_periods import close_period, get_open_period
# ---------------------------------------------------------------------------
@@ -154,7 +152,7 @@ class TestSchemaMigration:
# Migrate
steps = migrate_to_latest(db)
assert steps == 2 # v1→v2→v3
assert steps == 6 # v1→v2→v3→v4→v5→v6→v7
# Verify data preserved
conn = sqlite3.connect(str(db))
@@ -356,6 +354,167 @@ class TestCollectorDerivation:
assert count_after == 2
class TestCollectionAtomicity:
"""Collection exposes sample and derived evidence as one publication."""
@pytest.mark.asyncio
async def test_collection_is_visible_through_cli_and_tui_readers(
self, config_fixture, smartctl_fixture, sysfs_fixture_tree, monkeypatch
):
"""Ordinary readers observe matching published sample and activity evidence."""
from fenris.status import get_status, read_status
from fenris.tui import FenrisTuiApp
monkeypatch.setenv("TZ", "UTC")
service = {
"boot_enabled": True,
"timer_active": True,
"last_collect_ok": True,
}
monkeypatch.setattr("fenris.status.query_service_state", lambda: service)
now = datetime.now(timezone.utc).replace(
minute=35, second=0, microsecond=0,
)
first = {
**smartctl_fixture,
"nvme_smart_health_information_log": {
**smartctl_fixture["nvme_smart_health_information_log"],
"data_units_written": 12345678,
"data_units_read": 9876543,
},
}
second = {
**smartctl_fixture,
"nvme_smart_health_information_log": {
**smartctl_fixture["nvme_smart_health_information_log"],
"data_units_written": 12345698,
"data_units_read": 9876553,
},
}
sysfs_path = sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0"
assert run_collection(
first, sysfs_path, config_fixture, FakeClock(now - timedelta(minutes=5))
)["ok"] is True
assert run_collection(
second, sysfs_path, config_fixture, FakeClock(now)
)["ok"] is True
store_path = Path(config_fixture["store_path"])
with read_status(store_path, now, query_services=False) as (reader, composition):
assert reader is not None
assert composition.sample_count == 2
assert composition.day_count == 1
utc_day = reader.execute(
"SELECT bytes_written_delta, bytes_read_delta FROM day_aggregates"
).fetchone()
local_day = reader.execute(
"SELECT bytes_written, bytes_read FROM local_days"
).fetchone()
assert tuple(utc_day) == (10_240_000, 5_120_000)
assert tuple(local_day) == (10_240_000, 5_120_000)
cli_output = get_status(
store_path, now, query_services=True, query_journal=False
)
assert "Monitoring" in cli_output
app = FenrisTuiApp(store_path=store_path)
async with app.run_test(size=(100, 30)) as pilot:
await pilot.pause()
live_readout = str(app.query_one("#live-readout").render())
assert "W 0.010 GB" in live_readout
assert "R 0.005 GB" in live_readout
repeated_result = run_collection(
second, sysfs_path, config_fixture, FakeClock(now + timedelta(minutes=5))
)
assert repeated_result["ok"] is True
with read_status(
store_path, now + timedelta(minutes=5), query_services=False
) as (reader, composition):
assert reader is not None
assert composition.sample_count == 3
utc_day = reader.execute(
"SELECT bytes_written_delta, bytes_read_delta FROM day_aggregates"
).fetchone()
local_day = reader.execute(
"SELECT bytes_written, bytes_read FROM local_days"
).fetchone()
assert tuple(utc_day) == (10_240_000, 5_120_000)
assert tuple(local_day) == (10_240_000, 5_120_000)
def test_failed_local_day_publication_keeps_previous_publication(
self, config_fixture, smartctl_fixture, sysfs_fixture_tree, monkeypatch
):
"""A failed final derivation step leaves all prior reader state intact."""
from fenris.status import read_status
monkeypatch.setenv("TZ", "UTC")
now = datetime.now(timezone.utc).replace(second=0, microsecond=0)
first_clock = FakeClock(now)
first = {
**smartctl_fixture,
"nvme_smart_health_information_log": {
**smartctl_fixture["nvme_smart_health_information_log"],
"data_units_written": 12345678,
"data_units_read": 9876543,
},
}
first_result = run_collection(
first,
sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
config_fixture,
first_clock,
)
assert first_result["ok"] is True, first_result
writer = sqlite3.connect(config_fixture["store_path"])
writer.execute(
"CREATE TRIGGER fail_local_day_publication "
"BEFORE INSERT ON local_days "
"BEGIN SELECT RAISE(ABORT, 'injected local-day publication failure'); END"
)
writer.commit()
writer.close()
next_time = now + timedelta(minutes=5)
second = {
**smartctl_fixture,
"nvme_smart_health_information_log": {
**smartctl_fixture["nvme_smart_health_information_log"],
"data_units_written": 12345698,
"data_units_read": 9876548,
},
}
failed_result = run_collection(
second,
sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
config_fixture,
FakeClock(next_time),
)
assert failed_result["ok"] is False
assert "injected local-day publication failure" in failed_result["error"]
with read_status(
Path(config_fixture["store_path"]), next_time, query_services=False
) as (reader, composition):
assert reader is not None
assert composition.sample_count == 1
public_counts = reader.execute(
"SELECT (SELECT COUNT(*) FROM samples), "
"(SELECT COUNT(*) FROM controller_segments), "
"(SELECT COUNT(*) FROM monitoring_periods), "
"(SELECT COUNT(*) FROM hour_observations), "
"(SELECT COUNT(*) FROM day_aggregates), "
"(SELECT COUNT(*) FROM local_days)"
).fetchone()
assert tuple(public_counts) == (1, 1, 1, 0, 0, 0)
# ---------------------------------------------------------------------------
# Display states: awaiting first sample, awaiting another sample
# ---------------------------------------------------------------------------
@@ -412,7 +571,6 @@ class TestDisplayStates:
def test_one_sample_awaiting_another_in_tui(self, tmp_path):
"""One sample → TUI shows awaiting state."""
from fenris.tui import FenrisTuiApp
db = tmp_path / "test.db"
conn = init_store(db)
conn.execute(
+7 -6
View File
@@ -6,13 +6,11 @@ Tests the thinnest complete write path through the system:
Seam: write side of the observation store database file.
"""
import json
import os
import sqlite3
import tempfile
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, Generator
from typing import Any, Dict
import pytest
@@ -20,8 +18,8 @@ import pytest
import sys
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from fenris.collector import run_collection, AcquisitionError, InvariantViolationError
from fenris.store import init_store, get_store_path
from fenris.collector import run_collection
from fenris.store import init_store
# Fixtures
@@ -232,7 +230,7 @@ def test_store_initialization(config_fixture: Dict[str, Any]):
# Initialize store
conn = init_store(store_path)
# Verify all six entities exist
# Verify the observation, derived-history, and metadata tables exist.
cursor = conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
tables = {row[0] for row in cursor.fetchall()}
@@ -249,6 +247,9 @@ def test_store_initialization(config_fixture: Dict[str, Any]):
expected_tables.add("sqlite_sequence")
expected_tables.add("store_metadata")
expected_tables.add("local_days")
expected_tables.add("pending_publications")
expected_tables.add("local_day_unallocated_evidence")
expected_tables.add("local_day_segment_totals")
assert expected_tables == tables
conn.close()
+97 -23
View File
@@ -1,19 +1,20 @@
"""Complete observation day gate tests (issue #94).
"""Complete observation day condition tests (issues #94, #106).
Verifies that the endurance projection is withheld until at least one
complete local calendar day has been observed within a monitoring period.
Originally a gate (issue #94); ADR 0012 turned it into a contributing
fact. The projection is no longer withheld until one complete local
calendar day has been observed within a monitoring period, but it says
so while that condition is unmet.
Seams:
- compute_projection() → gate check via local_days table
- ProjectionResult.contributing_facts → "waiting for a full local observation day"
Acceptance criteria:
- Gate-1: No complete local day → UNSUPPORTED with waiting fact
- Gate-2: One complete local day → Limited confidence (if other conditions met)
- Gate-3: Partial days don't satisfy the gate
- Gate-1: No complete local day → projection renders with waiting fact
- Gate-2: One complete local day → no waiting fact
- Gate-3: Partial days don't satisfy the condition
- Gate-4: CLI and TUI share the same gate via compute_projection()
"""
import sqlite3
from datetime import datetime, timedelta, timezone
from pathlib import Path
@@ -24,8 +25,7 @@ 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.projection import (
compute_projection, ConfidenceState, BaselineTier,
WARMING_COVERAGE_FLOOR,
compute_projection, ConfidenceState,
)
@@ -78,8 +78,8 @@ def _insert_day(conn, day, bw=1024*1024*100, coverage=0.95, samples=24):
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 (?, ?, ?, ?, ?, ?, ?, ?)",
"percentage_used, bytes_written, bytes_read, power_on_hours, segment_id) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1)",
(ts, "/dev/nvme0n1", 1000000, 500000, pu, 512000000000, 256000000000, 8765),
)
conn.commit()
@@ -98,8 +98,8 @@ def _insert_local_day(conn, local_date, tz_name="UTC", tz_offset="+00:00",
conn.execute(
"INSERT INTO local_days "
"(local_date, tz_name, tz_offset, utc_start, utc_end, "
" bytes_written, bytes_read, coverage, sample_count, complete) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
" bytes_written, bytes_read, coverage, sample_count, complete, activity_intervals) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)",
(local_date, tz_name, tz_offset, utc_start, utc_end,
bw, br, coverage, samples, complete),
)
@@ -111,14 +111,14 @@ def _open_period(conn, start="2026-09-01T00:00:00+00:00"):
# ---------------------------------------------------------------------------
# Gate-1: No complete local day → UNSUPPORTED with waiting fact
# Gate-1: No complete local day → projection renders with waiting fact
# ---------------------------------------------------------------------------
class TestGateNoCompleteDay:
"""Projection is unavailable before any complete local observation day."""
"""Projection still renders before any complete local observation day."""
def test_no_local_days_unsupported(self, store):
"""With no local_days entries, projection is UNSUPPORTED."""
def test_no_local_days_states_waiting_fact(self, store):
"""With no local_days entries, the projection renders and says it is waiting."""
_insert_baseline(store)
_insert_segment(store)
_open_period(store)
@@ -128,11 +128,11 @@ class TestGateNoCompleteDay:
_insert_day(store, d, bw=1024*1024*100)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
result = compute_projection(store, _clock())
assert result.confidence_state == ConfidenceState.UNSUPPORTED
assert result.confidence_state != ConfidenceState.UNSUPPORTED
assert any("full local observation day" in f for f in result.contributing_facts)
assert result.headline_remaining_seconds is None
assert result.headline_remaining_seconds is not None
def test_only_partial_local_days_unsupported(self, store):
def test_only_partial_local_days_state_waiting_fact(self, store):
"""Partial (incomplete) local days don't satisfy the gate."""
_insert_baseline(store)
_insert_segment(store)
@@ -146,7 +146,7 @@ class TestGateNoCompleteDay:
d = (datetime(2026, 9, 25) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_local_day(store, d, complete=False, coverage=0.3)
result = compute_projection(store, _clock())
assert result.confidence_state == ConfidenceState.UNSUPPORTED
assert result.confidence_state != ConfidenceState.UNSUPPORTED
assert any("full local observation day" in f for f in result.contributing_facts)
def test_gate_before_warming_check(self, store):
@@ -166,6 +166,80 @@ class TestGateNoCompleteDay:
# The key assertion: gate message should NOT appear when gate IS met
assert not any("full local observation day" in f for f in result.contributing_facts)
def test_complete_legacy_day_without_trusted_activity_keeps_waiting_fact(
self, store,
):
"""A complete flag cannot make unavailable local activity qualify."""
_insert_baseline(store)
_insert_segment(store)
_open_period(store)
for i in range(3):
day = (datetime(2026, 9, 27) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, day, bw=1024 * 1024 * 100)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
_insert_local_day(store, "2026-09-29", complete=True)
store.execute(
"UPDATE local_days SET activity_precision = 'measured', activity_intervals = 0 "
"WHERE local_date = '2026-09-29'"
)
result = compute_projection(store, _clock())
assert result.confidence_state != ConfidenceState.UNSUPPORTED
assert any("full local observation day" in fact
for fact in result.contributing_facts)
def test_day_split_by_deliberate_pause_keeps_waiting_fact(self, store):
"""A complete-looking summary cannot span separate monitoring periods."""
_insert_baseline(store)
_insert_segment(store)
for i in range(20):
day = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, day, bw=1024 * 1024 * 100)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
store.executemany(
"INSERT INTO monitoring_periods (started_at, ended_at, end_cause) "
"VALUES (?, ?, ?)",
[
("2026-09-29T00:00:00+00:00", "2026-09-29T12:00:00+00:00", "user_disabled"),
("2026-09-29T13:00:00+00:00", "2026-09-30T00:00:00+00:00", "user_disabled"),
("2026-09-30T00:00:00+00:00", None, None),
],
)
store.commit()
_insert_local_day(
store,
"2026-09-29",
utc_start="2026-09-29T00:00:00+00:00",
utc_end="2026-09-30T00:00:00+00:00",
complete=True,
)
result = compute_projection(store, _clock())
assert result.confidence_state != ConfidenceState.UNSUPPORTED
assert any("full local observation day" in fact
for fact in result.contributing_facts)
def test_partial_but_trusted_local_activity_opens_gate(self, store):
"""Known local intervals can coexist with an incomplete day total."""
_insert_baseline(store)
_insert_segment(store)
_open_period(store)
for i in range(3):
day = (datetime(2026, 9, 27) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, day, bw=1024 * 1024 * 100)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
_insert_local_day(store, "2026-09-29", complete=True)
store.execute(
"UPDATE local_days SET activity_incomplete = 1 "
"WHERE local_date = '2026-09-29'"
)
result = compute_projection(store, _clock())
assert not any("full local observation day" in fact
for fact in result.contributing_facts)
# ---------------------------------------------------------------------------
# Gate-2: One complete local day → Limited confidence
@@ -216,7 +290,7 @@ class TestGateOneCompleteDay:
class TestGatePartialFirstDay:
"""Starting monitoring at noon means the partial first day doesn't count."""
def test_partial_first_day_not_enough(self, store):
def test_partial_first_day_keeps_waiting_fact(self, store):
"""A single incomplete local day (started at noon) doesn't open the gate."""
_insert_baseline(store)
_insert_segment(store, opened_at="2026-09-29T12:00:00+00:00")
@@ -229,7 +303,7 @@ class TestGatePartialFirstDay:
_insert_local_day(store, "2026-09-29", complete=False, coverage=0.5)
_insert_local_day(store, "2026-09-30", complete=False, coverage=0.5)
result = compute_projection(store, _clock())
assert result.confidence_state == ConfidenceState.UNSUPPORTED
assert result.confidence_state != ConfidenceState.UNSUPPORTED
assert any("full local observation day" in f for f in result.contributing_facts)
+1 -3
View File
@@ -6,7 +6,7 @@ shared boundary evidence, not duplicated into both days.
Seam: derive._add_unattributed_bytes() → day_aggregates.unattributed_bytes_*
"""
import sqlite3
from datetime import datetime, timedelta, timezone
from datetime import datetime, timezone
from pathlib import Path
import pytest
@@ -14,8 +14,6 @@ import sys
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from fenris.collector import run_collection
from fenris.store import init_store
from fenris.monitoring_periods import ensure_period_open
def _make_smartctl(duw: int, dur: int):
+206
View File
@@ -0,0 +1,206 @@
"""User-facing navigation, zoom, and plotted volume in the redesigned dashboard."""
from datetime import datetime, timedelta, timezone
from xml.etree import ElementTree
import pytest
from test_tui import (
_insert_baseline,
_insert_day,
_insert_local_day,
_insert_segment,
_open_period,
)
from fenris.store import init_store
from fenris.tui import FenrisTuiApp
NOW = datetime(2026, 9, 19, 12, tzinfo=timezone.utc)
@pytest.fixture
def dashboard(tmp_path, monkeypatch):
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "prefs"))
monkeypatch.setenv("TZ", "UTC")
class Clock(datetime):
@classmethod
def now(cls, tz=None):
return NOW
monkeypatch.setattr("fenris.tui.datetime", Clock)
monkeypatch.setattr("fenris.status.query_service_state", lambda: {
"boot_enabled": True, "timer_active": True, "last_collect_ok": True,
"last_collect_age_s": 0, "last_collect_reason": None,
})
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
_insert_baseline(conn)
_open_period(conn)
for offset in range(18):
date = (NOW - timedelta(days=offset)).date().isoformat()
_insert_day(conn, date, bw=(offset + 1) * 1_000_000_000)
_insert_local_day(conn, date, bw=(offset + 1) * 1_000_000_000, br=2_000_000_000)
for index in range(61):
conn.execute(
"INSERT INTO samples (ts, device, bytes_written, bytes_read, segment_id) VALUES (?, ?, ?, ?, ?)",
((NOW - timedelta(minutes=(60 - index) * 3)).isoformat(),
"/dev/test", index * 1_000_000, index * 2_000_000, 1),
)
conn.execute(
"INSERT INTO hour_observations (hour, bytes_written_delta, bytes_read_delta, coverage, sample_count) "
"VALUES (?, ?, ?, ?, ?)", ("2026-09-18T12:00:00+00:00", 4_000_000, 8_000_000, 1, 20),
)
conn.commit()
conn.close()
return FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
def visible(app):
return " ".join("".join(ElementTree.fromstring(app.export_screenshot()).itertext()).split())
@pytest.mark.asyncio
@pytest.mark.parametrize("size", [(140, 44), (80, 24)])
async def test_live_plot_inspection_zoom_refresh_and_resize(dashboard, size):
app = dashboard
async with app.run_test(size=size) as pilot:
app.on_refresh_tick()
await pilot.pause()
assert app.theme == "fenris-chalktone"
graph = app.query_one("#live-activity")
assert app.focused is graph
await pilot.press("left", "w")
selected = str(app.query_one("#live-readout").render())
assert "11:54" in selected and "W 0.001 GB" in selected and "R 0.002 GB" in selected
assert "Reads" in str(app.query_one("#live-legend").render())
await pilot.press("z")
app.on_refresh_tick()
await pilot.pause()
assert str(app.query_one("#live-readout").render()) == selected
assert app.query_one("#activity-panel").region.width == size[0]
for fact in ("Freshness:", "Last collect:", "Boot:", "Timer:", "q Quit TUI"):
assert fact in visible(app)
await pilot.resize_terminal(100, 30)
await pilot.press("escape")
assert str(app.query_one("#live-readout").render()) == selected
assert app._zoomed_panel is None
assert app.query_one("#action-rail").region.bottom <= 30
@pytest.mark.asyncio
async def test_tabs_date_entry_and_hourly_inspection_keep_context(dashboard):
app = dashboard
async with app.run_test(size=(100, 36)) as pilot:
await pilot.click("#view-history")
await pilot.press("left", "enter")
assert app.query_one("#activity-tabs").active == "view-day"
assert "2026-09-18" in str(app.query_one("#local-day").render())
# Hour 23 is selected initially; move to the known hour 12.
await pilot.press(*(["left"] * 11))
readout = str(app.query_one("#bar-readout").render())
assert "12:00 UTC" in readout and "W 0.004 GB" in readout
await pilot.press("z", "w")
app.on_refresh_tick()
await pilot.pause()
assert str(app.query_one("#bar-readout").render()) == readout
assert "Reads" in str(app.query_one("#bar-legend").render())
await pilot.press("g")
await pilot.press(*list("2026-09-17"))
await pilot.press("escape")
assert str(app.query_one("#bar-readout").render()) == readout
assert app._zoomed_panel == "activity-panel"
await pilot.press("escape", "t")
assert app.query_one("#activity-tabs").active == "view-live"
@pytest.mark.asyncio
async def test_mouse_inspection_matches_time_axis(dashboard):
app = dashboard
async with app.run_test(size=(100, 36)) as pilot:
await pilot.pause()
graph = app.query_one("#live-activity")
x = graph._point_columns[0]
await pilot.click("#live-render", offset=(x, 1))
text = str(app.query_one("#live-readout").render())
assert "09:00 → 09:03 UTC" in text
@pytest.mark.asyncio
async def test_live_readout_shows_local_time_with_secondary_utc_line(dashboard, monkeypatch):
monkeypatch.setenv("TZ", "Asia/Kolkata")
app = dashboard
async with app.run_test(size=(100, 36)) as pilot:
await pilot.pause()
await pilot.press("left")
text = str(app.query_one("#live-readout").render())
assert "17:24 → 17:27 Asia/Kolkata +05:30" in text
assert text.splitlines()[-1] == "11:54 → 11:57 UTC"
assert "local time" in str(app.query_one("#live-legend").render())
@pytest.mark.asyncio
async def test_theme_control_persists_choice_and_quit_never_pauses(dashboard, monkeypatch):
calls = []
monkeypatch.setattr(dashboard, "_run_helper", lambda *args: calls.append(args))
async with dashboard.run_test(size=(80, 24)) as pilot:
await pilot.press("s")
assert dashboard.theme == "fenris-amber"
await pilot.press("q")
assert calls == []
from fenris.preferences import load_preferences
assert load_preferences()["theme"] == "amber"
@pytest.mark.asyncio
@pytest.mark.parametrize("size", [(100, 36), (70, 20)])
async def test_unallocated_volume_survives_day_with_missing_coverage(dashboard, size):
async with dashboard.run_test(size=size) as pilot:
await pilot.click("#view-history")
graph = dashboard.query_one("#usage-history")
graph.set_data([{
"day": "2026-09-18", "local_label": "2026-09-18",
"timezone_label": "UTC +00:00", "identity": None,
"total_bytes": None, "total_written": None, "total_read": None,
"shared_bytes_written": 2_000_000_000, "shared_bytes_read": 0,
"unallocated_bytes_written": 2_000_000_000,
"unallocated_bytes_read": 0, "activity_state": "incomplete",
"is_gap": True, "is_partial": True,
}])
readout = str(dashboard.query_one("#bar-readout").render())
assert "unallocated W 2.000 GB" in readout
assert "W unavailable · R unavailable" in readout
if size[0] >= 80:
graph.set_data([{
"day": "2026-09-18", "local_label": "2026-09-18",
"timezone_label": "UTC +00:00", "identity": None,
"total_bytes": 2_000_000_000, "total_written": 2_000_000_000,
"total_read": None, "shared_bytes_written": 2_000_000_000,
"shared_bytes_read": 0, "unallocated_bytes_written": 0,
"unallocated_bytes_read": 0, "activity_state": "incomplete",
"is_gap": False, "is_partial": True,
}])
plot = str(dashboard.query_one("#bar-render").render())
assert any(0x2801 <= ord(c) <= 0x28ff for c in plot)
graph.measure = "read"
graph._refresh()
plot = str(dashboard.query_one("#bar-render").render())
assert not any(0x2801 <= ord(c) <= 0x28ff for c in plot)
@pytest.mark.asyncio
@pytest.mark.parametrize("state", ["gap", "future"])
async def test_small_terminal_hour_readout_does_not_invent_zero(dashboard, state):
async with dashboard.run_test(size=(70, 20)) as pilot:
await pilot.click("#view-day")
graph = dashboard.query_one("#usage-history")
graph.set_hour_data([{
"hour": "2026-09-18T12:00:00+00:00", "local_label": "12:00",
"timezone_label": "UTC +00:00",
"utc_label": "2026-09-18 12:00 UTC",
"is_gap": state == "gap", "is_future": state == "future",
}])
graph._hourly_selected = 0
graph._refresh_hourly()
readout = str(dashboard.query_one("#bar-readout").render())
assert "W unavailable · R unavailable" in readout
assert state in readout and "12:00 UTC" in readout
+3 -4
View File
@@ -7,9 +7,8 @@ From spec §5.4, §3.3, ST-4, ST-5:
whose classification is known
- No absent hour ever interpolated/estimated/fabricated (FL-3)
"""
import sqlite3
import sys
from datetime import datetime, timezone, timedelta
from datetime import datetime, timezone
from pathlib import Path
import pytest
@@ -18,8 +17,8 @@ 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.day_aggregate import derive_day, derive_all_days, DayAggregate
from fenris.hour_classify import HourSplit, ACTIVE_THRESHOLD_BYTES
from fenris.day_aggregate import derive_day, derive_all_days
from fenris.hour_classify import HourSplit
@pytest.fixture
+1 -2
View File
@@ -11,11 +11,10 @@ Four splits sum to exactly 3600s. Disabled time is never an hour state.
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from fenris.hour_classify import classify_hour, HourSplit, ACTIVE_THRESHOLD_BYTES
from fenris.hour_classify import classify_hour, ACTIVE_THRESHOLD_BYTES
HOUR_SECONDS = 3600
-1
View File
@@ -7,7 +7,6 @@ From spec §2.3:
Padded and unpadded renderings of the same field yield byte-identical stored values.
"""
import pytest
import sys
from pathlib import Path
+19 -9
View File
@@ -9,12 +9,9 @@ Covers:
Spec: ADR 0008, §8.4, §8.5, §8.6, §8.7, §8.8
"""
import os
import sqlite3
import tempfile
from datetime import datetime, timedelta, timezone
from pathlib import Path
from unittest.mock import patch, MagicMock, PropertyMock
from unittest.mock import patch, MagicMock
import pytest
@@ -24,7 +21,6 @@ sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from fenris.init_system import (
InitSystem,
detect_init_system,
get_init_system,
reset_init_system_cache,
_systemd_enable,
_systemd_disable,
@@ -41,8 +37,6 @@ from fenris.init_system import (
collect_now,
query_service_state,
journal_hint,
FENRIS_SV_DIR,
FENRIS_SERVICE_LINK,
COLLECT_TIMEOUT_S,
)
from fenris.store import init_store
@@ -634,6 +628,14 @@ class TestMonitorIntegration:
cmd_enable(args)
mock_enable.assert_called_once_with(True)
conn = sqlite3.connect(store_path)
try:
assert conn.execute(
"SELECT COUNT(*) FROM monitoring_periods WHERE ended_at IS NULL"
).fetchone()[0] == 1
finally:
conn.close()
def test_cmd_disable_uses_init_system(self, tmp_path):
"""cmd_disable uses init_system.disable_timer."""
from fenris.monitor import cmd_disable
@@ -677,11 +679,19 @@ class TestEdgeCases:
def test_runit_collect_missing_script(self):
"""Runit collect exits when fenris-collect script not found."""
with patch("fenris.init_system.Path") as mock_path:
mock_path.return_value.exists.return_value = False
with patch("fenris.init_system.subprocess") as mock_sub, \
patch("fenris.init_system.Path") as mock_path:
missing = MagicMock()
missing.exists.return_value = False
# Fallback path is built via Path(...).parent... / "src" / ...
missing.parent = missing
missing.__truediv__.return_value = missing
mock_path.return_value = missing
mock_sub.run.return_value = MagicMock(returncode=1)
with pytest.raises(SystemExit) as exc_info:
_runit_collect()
assert exc_info.value.code == 1
mock_sub.run.assert_not_called()
def test_systemd_query_state_timeout(self):
"""Systemd query state handles subprocess timeout."""
+341
View File
@@ -0,0 +1,341 @@
"""Normal-collection retention acceptance tests for issue #100."""
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.collector import run_collection
from fenris.local_day import query_local_day_summary
from fenris.pruning import prune_old_samples
from fenris.status import read_status
from fenris.store import init_store
@pytest.fixture
def sysfs_controller(tmp_path):
controller = tmp_path / "sys" / "class" / "nvme" / "nvme0"
controller.mkdir(parents=True)
(controller / "subsysnqn").write_text("nqn.example:drive-1\n")
(controller / "model").write_text("Fenris Test Drive\n")
(controller / "serial").write_text("drive-1\n")
(controller / "firmware_rev").write_text("1.0\n")
transport = controller / "transport"
transport.mkdir()
(transport / "address").write_text("0000:00:01.0\n")
(transport / "trstring").write_text("pcie\n")
return controller
class FakeClock:
def __init__(self, now):
self.now = now
def utcnow(self):
return self.now
def _smartctl(written, read):
return {
"nvme_smart_health_information_log": {
"critical_warning": 0,
"temperature": 35,
"available_spare": 100,
"percentage_used": 1,
"data_units_written": written,
"data_units_read": read,
"power_on_hours": 100,
"power_cycles": 1,
"unsafe_shutdowns": 0,
"media_errors": 0,
},
"user_capacity": {"bytes": 1_000_000_000_000},
"model_name": "Fenris Test Drive",
"serial_number": "drive-1",
"firmware_version": "1.0",
}
def _collect(config, controller, now, written, read):
return run_collection(
smartctl_data=_smartctl(written, read),
sysfs_path=controller,
config=config,
clock=FakeClock(now),
)
def test_normal_collection_expires_detail_and_keeps_local_day_evidence(
tmp_path, sysfs_controller, monkeypatch,
):
monkeypatch.setenv("TZ", "UTC")
store_path = tmp_path / "observations.db"
config = {"device": "/dev/nvme0", "store_path": str(store_path)}
first_time = datetime(2026, 9, 1, 0, 0, tzinfo=timezone.utc)
second_time = first_time + timedelta(days=15)
first = _collect(config, sysfs_controller, first_time, 1000, 2000)
assert first["ok"] is True, first
second = _collect(config, sysfs_controller, second_time, 1001, 2003)
assert second["ok"] is True, second
with sqlite3.connect(store_path) as conn:
assert conn.execute("SELECT ts FROM samples ORDER BY id").fetchall() == [
(second_time.isoformat(),)
]
assert conn.execute(
"SELECT bytes_written, bytes_read, reason "
"FROM local_day_unallocated_evidence"
).fetchall() == [(512_000, 1_536_000, "local_midnight")]
assert conn.execute("SELECT COUNT(*) FROM pending_publications").fetchone()[0] == 0
assert conn.execute("SELECT COUNT(*) FROM controller_segments").fetchone()[0] == 1
assert conn.execute("SELECT COUNT(*) FROM day_aggregates").fetchone()[0] == 15
monkeypatch.setenv("TZ", "Asia/Kolkata")
with read_status(store_path, second_time, query_services=False) as (reader, state):
assert reader is not None
assert state.store_fault is None
old_day = query_local_day_summary(
reader, "2026-09-01", "UTC", second_time,
)
assert old_day["utc_start"] == "2026-09-01T00:00:00+00:00"
assert old_day["utc_end"] == "2026-09-02T00:00:00+00:00"
assert old_day["bytes_written"] is None
assert old_day["shared_bytes_written"] == 512_000
assert old_day["shared_bytes_read"] == 1_536_000
assert old_day["shared_evidence_count"] == 1
def test_pruning_keeps_samples_when_replacement_evidence_is_missing(tmp_path):
store_path = tmp_path / "incomplete.db"
conn = init_store(store_path)
now = datetime(2026, 9, 30, tzinfo=timezone.utc)
old_time = now - timedelta(days=20)
next_time = now - timedelta(days=1)
conn.execute(
"INSERT INTO controller_segments (id, opened_at, identity_key) "
"VALUES (1, ?, 'test')",
(old_time.isoformat(),),
)
conn.execute(
"INSERT INTO samples (ts, device, bytes_written, bytes_read, segment_id, local_tz) "
"VALUES (?, '/dev/nvme0', 100, 100, 1, 'UTC')",
(old_time.isoformat(),),
)
conn.execute(
"INSERT INTO samples (ts, device, bytes_written, bytes_read, segment_id, local_tz) "
"VALUES (?, '/dev/nvme0', 200, 200, 1, 'UTC')",
(next_time.isoformat(),),
)
conn.commit()
assert prune_old_samples(conn, now) == 0
assert conn.execute("SELECT COUNT(*) FROM samples").fetchone()[0] == 2
conn.close()
def test_later_same_day_activity_does_not_replace_gap_interval(tmp_path):
store_path = tmp_path / "unrelated-local-activity.db"
conn = init_store(store_path)
gap_start = datetime(2026, 9, 1, 0, 0, tzinfo=timezone.utc)
gap_end = gap_start + timedelta(minutes=10)
later_end = gap_start + timedelta(minutes=20)
now = datetime(2026, 9, 30, tzinfo=timezone.utc)
conn.execute(
"INSERT INTO controller_segments (id, opened_at, identity_key) "
"VALUES (1, ?, 'test')",
(gap_start.isoformat(),),
)
for sample_time, written, read in (
(gap_start, 1000, 100),
(gap_end, 2000, 200),
(later_end, 2100, 210),
):
conn.execute(
"INSERT INTO samples (ts, device, bytes_written, bytes_read, segment_id, local_tz) "
"VALUES (?, '/dev/nvme0', ?, ?, 1, 'UTC')",
(sample_time.isoformat(), written, read),
)
# UTC aggregates can replace the old counter pair, but the local-day
# summary contains only the later B->C interval, after monitoring resumed.
conn.execute(
"INSERT INTO hour_observations (hour, bytes_written_delta, bytes_read_delta) "
"VALUES ('2026-09-01T00:00:00+00:00', 1000, 100)"
)
conn.execute(
"INSERT INTO day_aggregates (day) VALUES ('2026-09-01')"
)
conn.execute(
"INSERT INTO monitoring_periods (started_at, ended_at) VALUES (?, ?)",
(gap_end.isoformat(), later_end.isoformat()),
)
last_sample_id = conn.execute("SELECT MAX(id) FROM samples").fetchone()[0]
conn.execute(
"INSERT INTO local_days "
"(local_date, tz_name, tz_offset, utc_start, utc_end, bytes_written, "
" bytes_read, activity_intervals, activity_precision, last_sample_id) "
"VALUES ('2026-09-01', 'UTC', '+00:00', ?, ?, 100, 10, 1, 'measured', ?)",
(
gap_start.isoformat(),
(gap_start + timedelta(days=1)).isoformat(),
last_sample_id,
),
)
local_day_id = conn.execute("SELECT MAX(id) FROM local_days").fetchone()[0]
conn.execute(
"INSERT INTO local_day_segment_totals "
"(local_day_id, segment_id, bytes_written, bytes_read, activity_intervals) "
"VALUES (?, 1, 100, 10, 1)",
(local_day_id,),
)
conn.commit()
assert prune_old_samples(conn, now) == 0
assert conn.execute("SELECT COUNT(*) FROM samples").fetchone()[0] == 3
conn.close()
def test_pruning_keeps_oldest_unpaired_sample_as_successor_anchor(tmp_path):
store_path = tmp_path / "anchor.db"
conn = init_store(store_path)
now = datetime(2026, 9, 30, tzinfo=timezone.utc)
old_time = now - timedelta(days=20)
conn.execute(
"INSERT INTO controller_segments (id, opened_at, identity_key) "
"VALUES (1, ?, 'test')",
(old_time.isoformat(),),
)
conn.execute(
"INSERT INTO samples (ts, device, bytes_written, bytes_read, segment_id, local_tz) "
"VALUES (?, '/dev/nvme0', 100, 100, 1, 'UTC')",
(old_time.isoformat(),),
)
conn.commit()
assert prune_old_samples(conn, now) == 0
assert conn.execute("SELECT ts FROM samples").fetchone()[0] == old_time.isoformat()
conn.close()
def test_pruning_never_expires_pending_publications(tmp_path):
store_path = tmp_path / "pending.db"
conn = init_store(store_path)
old_time = datetime(2026, 9, 1, tzinfo=timezone.utc)
conn.execute(
"INSERT INTO pending_publications (sample_ts, payload) VALUES (?, '{}')",
(old_time.isoformat(),),
)
conn.commit()
assert prune_old_samples(
conn, datetime(2026, 9, 30, tzinfo=timezone.utc),
) == 0
assert conn.execute(
"SELECT sample_ts FROM pending_publications"
).fetchone()[0] == old_time.isoformat()
conn.close()
def test_pruning_preserves_unparseable_legacy_timestamp(tmp_path):
store_path = tmp_path / "legacy.db"
conn = init_store(store_path)
conn.execute(
"INSERT INTO samples (ts, device, bytes_written, bytes_read) "
"VALUES ('legacy timestamp', '/dev/nvme0', 100, 100)"
)
conn.commit()
assert prune_old_samples(conn, datetime(2026, 9, 30, tzinfo=timezone.utc)) == 0
assert conn.execute("SELECT ts FROM samples").fetchone()[0] == "legacy timestamp"
conn.close()
def test_collection_defers_interrupted_pruning_and_retries_next_run(
tmp_path, sysfs_controller, monkeypatch,
):
monkeypatch.setenv("TZ", "UTC")
store_path = tmp_path / "interrupted.db"
config = {"device": "/dev/nvme0", "store_path": str(store_path)}
first_time = datetime(2026, 9, 1, 0, 0, tzinfo=timezone.utc)
second_time = first_time + timedelta(days=15)
assert _collect(config, sysfs_controller, first_time, 1000, 2000)["ok"] is True
writer = sqlite3.connect(store_path)
writer.execute(
"CREATE TRIGGER stop_retention BEFORE DELETE ON samples "
"BEGIN SELECT RAISE(ABORT, 'simulated retention interruption'); END"
)
writer.commit()
writer.close()
second = _collect(config, sysfs_controller, second_time, 1001, 2001)
assert second["ok"] is True, second
assert "simulated retention interruption" in second["retention_error"]
with sqlite3.connect(store_path) as conn:
assert conn.execute("SELECT COUNT(*) FROM samples").fetchone()[0] == 2
assert conn.execute("SELECT COUNT(*) FROM pending_publications").fetchone()[0] == 0
assert conn.execute("SELECT COUNT(*) FROM local_day_unallocated_evidence").fetchone()[0] == 1
writer = sqlite3.connect(store_path)
writer.execute("DROP TRIGGER stop_retention")
writer.commit()
writer.close()
third_time = second_time + timedelta(days=15)
third = _collect(config, sysfs_controller, third_time, 1002, 2002)
assert third["ok"] is True, third
assert third["retention_error"] is None
with sqlite3.connect(store_path) as conn:
assert conn.execute("SELECT ts FROM samples ORDER BY id").fetchall() == [
(third_time.isoformat(),)
]
def test_collection_recovers_old_pending_work_before_pruning(
tmp_path, sysfs_controller, monkeypatch,
):
monkeypatch.setenv("TZ", "UTC")
store_path = tmp_path / "pending-recovery.db"
config = {"device": "/dev/nvme0", "store_path": str(store_path)}
first_time = datetime(2026, 9, 1, 0, 0, tzinfo=timezone.utc)
second_time = first_time + timedelta(days=15)
assert _collect(config, sysfs_controller, first_time, 1000, 2000)["ok"] is True
writer = sqlite3.connect(store_path)
writer.execute(
"CREATE TRIGGER interrupt_publication "
"BEFORE INSERT ON local_day_unallocated_evidence "
"BEGIN SELECT RAISE(ABORT, 'simulated publication interruption'); END"
)
writer.commit()
writer.close()
failed = _collect(config, sysfs_controller, second_time, 1001, 2001)
assert failed["ok"] is False
assert "simulated publication interruption" in failed["error"]
with sqlite3.connect(store_path) as conn:
assert conn.execute("SELECT COUNT(*) FROM samples").fetchone()[0] == 1
assert conn.execute("SELECT COUNT(*) FROM pending_publications").fetchone()[0] == 1
writer = sqlite3.connect(store_path)
writer.execute("DROP TRIGGER interrupt_publication")
writer.commit()
writer.close()
third_time = second_time + timedelta(minutes=5)
recovered = _collect(config, sysfs_controller, third_time, 1002, 2002)
assert recovered["ok"] is True, recovered
assert recovered["retention_error"] is None
with sqlite3.connect(store_path) as conn:
assert conn.execute("SELECT COUNT(*) FROM pending_publications").fetchone()[0] == 0
assert conn.execute("SELECT ts FROM samples ORDER BY id").fetchall() == [
(second_time.isoformat(),),
(third_time.isoformat(),),
]
assert conn.execute(
"SELECT COUNT(*) FROM local_day_unallocated_evidence"
).fetchone()[0] == 1
+257
View File
@@ -0,0 +1,257 @@
"""Bound pending-publication admission before device acquisition."""
import json
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 import collector
class FakeClock:
def __init__(self, now):
self.now = now
def utcnow(self):
return self.now
@pytest.fixture
def sysfs_controller(tmp_path):
controller_path = tmp_path / "sys" / "class" / "nvme" / "nvme0"
controller_path.mkdir(parents=True)
(controller_path / "subsysnqn").write_text("nqn.test:drive\n")
(controller_path / "model").write_text("Test NVMe\n")
(controller_path / "serial").write_text("test-serial\n")
(controller_path / "firmware_rev").write_text("1.0\n")
transport = controller_path / "transport"
transport.mkdir()
(transport / "trstring").write_text("pcie\n")
return controller_path
def _smartctl(units_written):
return {
"nvme_smart_health_information_log": {
"critical_warning": 0,
"temperature": 35,
"available_spare": 100,
"percentage_used": 5,
"data_units_written": units_written,
"data_units_read": 500,
"power_on_hours": 100,
},
"user_capacity": {"bytes": 1_024_000_000_000},
"model_name": "Test NVMe",
"serial_number": "test-serial",
"firmware_version": "1.0",
}
def _collect(config, controller, now, units_written):
return collector.run_collection(
config=config,
clock=FakeClock(now),
acquire=lambda: (_smartctl(units_written), controller),
)
def test_partial_recovery_frees_admission_and_keeps_pending_order(
tmp_path, sysfs_controller, monkeypatch,
):
"""A freed slot admits one observation behind older pending evidence."""
assert collector.PENDING_PUBLICATION_LIMIT == 6_720
monkeypatch.setenv("TZ", "UTC")
monkeypatch.setattr(collector, "PENDING_PUBLICATION_LIMIT", 2)
store_path = tmp_path / "observations.db"
config = {"device": "/dev/nvme0", "store_path": str(store_path)}
old_time = datetime(2026, 8, 1, tzinfo=timezone.utc)
controller = sysfs_controller
assert _collect(config, controller, old_time, 1000)["ok"] is True
original_derive = collector.derive_hours_from_interval
def unavailable_derivation(*_args):
raise RuntimeError("derivation unavailable")
monkeypatch.setattr(collector, "derive_hours_from_interval", unavailable_derivation)
first_pending = _collect(
config, controller, old_time + timedelta(minutes=5), 1010,
)
assert first_pending["ok"] is False
second_pending = _collect(
config, controller, old_time + timedelta(minutes=10), 1020,
)
assert second_pending["ok"] is False
with sqlite3.connect(store_path) as conn:
assert conn.execute(
"SELECT COUNT(*) FROM pending_publications"
).fetchone()[0] == 2
called = False
def must_not_acquire():
nonlocal called
called = True
return _smartctl(1030), controller
full = collector.run_collection(
config=config,
clock=FakeClock(old_time + timedelta(days=40)),
acquire=must_not_acquire,
)
assert full["ok"] is False
assert "capacity full (2 observations)" in full["error"]
assert called is False
with sqlite3.connect(store_path) as conn:
assert conn.execute(
"SELECT COUNT(*), MIN(sample_ts) FROM pending_publications"
).fetchone() == (
2,
(old_time + timedelta(minutes=5)).isoformat(),
)
assert conn.execute("SELECT COUNT(*) FROM samples").fetchone()[0] == 1
def fail_second_pending(conn, previous, current):
if current["data_units_written"] == 1020:
raise RuntimeError("second observation still blocked")
return original_derive(conn, previous, current)
monkeypatch.setattr(collector, "derive_hours_from_interval", fail_second_pending)
acquired = False
def acquire_after_partial_recovery():
nonlocal acquired
acquired = True
return _smartctl(1030), controller
partial = collector.run_collection(
config=config,
clock=FakeClock(old_time + timedelta(days=40, minutes=5)),
acquire=acquire_after_partial_recovery,
)
assert partial["ok"] is False
assert acquired is True, partial
assert "no new observation acquired" not in partial["error"]
with sqlite3.connect(store_path) as conn:
assert conn.execute(
"SELECT data_units_written FROM samples ORDER BY id"
).fetchall() == [(1000,), (1010,)]
queued_units = [
json.loads(row[0])["sample"]["data_units_written"]
for row in conn.execute(
"SELECT payload FROM pending_publications ORDER BY id"
)
]
assert queued_units == [1020, 1030]
assert conn.execute("SELECT COUNT(*) FROM monitoring_periods").fetchone()[0] == 1
recovered_in_order = []
def record_recovery_order(conn, previous, current):
recovered_in_order.append(current["data_units_written"])
return original_derive(conn, previous, current)
monkeypatch.setattr(collector, "derive_hours_from_interval", record_recovery_order)
recovered = collector.run_collection(
config=config,
clock=FakeClock(old_time + timedelta(days=40, minutes=10)),
acquire=lambda: (_smartctl(1040), controller),
)
assert recovered["ok"] is True, recovered
assert recovered_in_order == [1020, 1030, 1040]
with sqlite3.connect(store_path) as conn:
assert conn.execute(
"SELECT data_units_written FROM samples ORDER BY id DESC LIMIT 1"
).fetchone() == (1040,)
assert conn.execute("SELECT COUNT(*) FROM pending_publications").fetchone()[0] == 0
def test_actual_pending_limit_fails_before_acquisition_and_keeps_old_evidence(
tmp_path, sysfs_controller, monkeypatch,
):
"""The production limit retains every old row and refuses device acquisition."""
monkeypatch.setenv("TZ", "UTC")
store_path = tmp_path / "full-observations.db"
config = {"device": "/dev/nvme0", "store_path": str(store_path)}
controller = sysfs_controller
old_time = datetime(2026, 7, 1, tzinfo=timezone.utc)
assert _collect(config, controller, old_time, 1000)["ok"] is True
def unavailable_derivation(*_args):
raise RuntimeError("derivation unavailable")
monkeypatch.setattr(collector, "derive_hours_from_interval", unavailable_derivation)
failed = _collect(
config, controller, old_time + timedelta(minutes=5), 1010,
)
assert failed["ok"] is False
with sqlite3.connect(store_path) as conn:
payload_row = conn.execute(
"SELECT payload FROM pending_publications ORDER BY id LIMIT 1"
).fetchone()
assert payload_row is not None
base = json.loads(payload_row[0])
base_observed_at = datetime.fromisoformat(base["sample"]["ts"])
base_units = base["sample"]["data_units_written"]
rows = []
for offset in range(1, collector.PENDING_PUBLICATION_LIMIT):
observation = json.loads(payload_row[0])
units_written = base_units + offset
observed_at = base_observed_at + timedelta(minutes=5 * offset)
observation["sample"]["ts"] = observed_at.isoformat()
observation["sample"]["data_units_written"] = units_written
observation["sample"]["bytes_written"] = units_written * 512_000
rows.append((observed_at.isoformat(), json.dumps(observation)))
conn.executemany(
"INSERT INTO pending_publications (sample_ts, payload) VALUES (?, ?)",
rows,
)
conn.commit()
called = False
def must_not_acquire():
nonlocal called
called = True
return _smartctl(9000), controller
result = collector.run_collection(
config=config,
clock=FakeClock(old_time + timedelta(days=40)),
acquire=must_not_acquire,
)
assert result["ok"] is False
assert f"capacity full ({collector.PENDING_PUBLICATION_LIMIT} observations)" in result[
"error"
]
assert called is False
with sqlite3.connect(store_path) as conn:
pending_rows = conn.execute(
"SELECT sample_ts, payload FROM pending_publications ORDER BY id"
).fetchall()
assert len(pending_rows) == collector.PENDING_PUBLICATION_LIMIT
assert (pending_rows[0][0], pending_rows[-1][0]) == (
(old_time + timedelta(minutes=5)).isoformat(),
(
old_time
+ timedelta(minutes=5 * collector.PENDING_PUBLICATION_LIMIT)
).isoformat(),
)
assert [
json.loads(payload)["sample"]["data_units_written"]
for _, payload in pending_rows
] == list(range(1010, 1010 + collector.PENDING_PUBLICATION_LIMIT))
assert conn.execute("SELECT COUNT(*) FROM samples").fetchone()[0] == 1
assert conn.execute("SELECT COUNT(*) FROM monitoring_periods").fetchone()[0] == 1
+251
View File
@@ -0,0 +1,251 @@
"""Additional smartctl diagnostics (issue #111).
Field list fixed from real `smartctl -a -j` output (smartctl 7.5, NVMe 1.4):
nvme_smart_health_information_log carries warning_temp_time, critical_comp_time,
num_err_log_entries, host_reads, host_writes and controller_busy_time. The
thermal-management transition fields (thm_temp1/2_trans_count, thm_temp1/2_total_time)
appear only on drives that implement them, so they stay nullable.
"""
import sqlite3
import sys
from datetime import datetime, timezone
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from fenris.activity_readout import diagnostics_text, readout
from fenris.activity_selection import ActivitySelection
from fenris.collector import acquire_from_smartctl, run_collection
from fenris.store import SCHEMA_VERSION, init_store, migrate_to_latest
NEW_COLUMNS = (
"host_reads", "host_writes", "controller_busy_time", "num_err_log_entries",
"warning_temp_time", "critical_comp_time",
"thm_temp1_trans_count", "thm_temp1_total_time",
"thm_temp2_trans_count", "thm_temp2_total_time",
)
def _smartctl(**extra_log):
"""Shape of real smartctl -a -j output for an NVMe drive."""
log = {
"critical_warning": 0, "temperature": 37, "available_spare": 100,
"available_spare_threshold": 5, "percentage_used": 52,
"data_units_read": 207986339, "data_units_written": 202978899,
"host_reads": 3543447796, "host_writes": 2458441042,
"controller_busy_time": 61626, "power_cycles": 5324,
"power_on_hours": 11906, "unsafe_shutdowns": 159, "media_errors": 0,
"num_err_log_entries": 0, "warning_temp_time": 0, "critical_comp_time": 0,
"temperature_sensors": [37],
}
log.update(extra_log)
return {
"nvme_smart_health_information_log": log,
"user_capacity": {"bytes": 512110190592},
"model_name": "Drive", "serial_number": "SN1", "firmware_version": "FW1",
}
class TestAcquisition:
def test_real_fields_are_captured(self):
got = acquire_from_smartctl(_smartctl())
assert got["host_reads"] == 3543447796
assert got["host_writes"] == 2458441042
assert got["controller_busy_time"] == 61626
assert got["num_err_log_entries"] == 0
assert got["warning_temp_time"] == 0
assert got["critical_comp_time"] == 0
def test_absent_thermal_management_fields_are_none_not_zero(self):
got = acquire_from_smartctl(_smartctl())
for key in ("thm_temp1_trans_count", "thm_temp1_total_time",
"thm_temp2_trans_count", "thm_temp2_total_time"):
assert got[key] is None
def test_thermal_management_fields_captured_when_reported(self):
got = acquire_from_smartctl(_smartctl(
thm_temp1_trans_count=3, thm_temp1_total_time=120,
thm_temp2_trans_count=1, thm_temp2_total_time=45))
assert (got["thm_temp1_trans_count"], got["thm_temp1_total_time"]) == (3, 120)
assert (got["thm_temp2_trans_count"], got["thm_temp2_total_time"]) == (1, 45)
def test_missing_optional_counters_do_not_fail_the_run(self):
data = _smartctl()
for key in ("host_reads", "host_writes", "controller_busy_time",
"num_err_log_entries", "warning_temp_time", "critical_comp_time"):
del data["nvme_smart_health_information_log"][key]
got = acquire_from_smartctl(data)
assert got["host_reads"] is None and got["warning_temp_time"] is None
@pytest.fixture
def sysfs(tmp_path):
ctrl = tmp_path / "nvme0"
ctrl.mkdir()
for name, value in (("subsysnqn", "nqn.test"), ("model", "Drive"),
("serial", "SN1"), ("firmware_rev", "FW1")):
(ctrl / name).write_text(value + "\n")
return ctrl
class _Clock:
def utcnow(self):
return datetime(2026, 9, 1, 12, 0, tzinfo=timezone.utc)
def test_collection_persists_new_fields(tmp_path, sysfs):
config = {"device": "/dev/nvme0", "store_path": str(tmp_path / "o.db")}
result = run_collection(smartctl_data=_smartctl(thm_temp1_trans_count=3),
sysfs_path=sysfs, config=config, clock=_Clock())
assert result["ok"], result
conn = sqlite3.connect(config["store_path"])
conn.row_factory = sqlite3.Row
row = conn.execute("SELECT * FROM samples").fetchone()
assert row["host_reads"] == 3543447796
assert row["controller_busy_time"] == 61626
assert row["thm_temp1_trans_count"] == 3
assert row["thm_temp2_trans_count"] is None
def _legacy_store(path):
"""A populated schema-6 store, as shipped before this change."""
conn = init_store(path)
for column in reversed(NEW_COLUMNS):
conn.execute(f"ALTER TABLE samples DROP COLUMN {column}")
conn.execute("PRAGMA user_version=6")
for i in range(3):
conn.execute(
"INSERT INTO samples (ts, device, bytes_written, bytes_read, "
"power_on_hours, critical_warning, segment_id) VALUES (?,?,?,?,?,?,1)",
("2026-09-30T1%d:00:00+00:00" % i, "/dev/nvme0", 10 + i, 5, 100 + i, 0))
conn.commit()
conn.close()
class TestMigration:
def test_schema_version_is_seven(self):
assert SCHEMA_VERSION == 7
def test_populated_legacy_store_keeps_rows_and_gains_null_columns(self, tmp_path):
db = tmp_path / "o.db"
_legacy_store(db)
before = sqlite3.connect(db).execute(
"SELECT id, ts, device, bytes_written, power_on_hours FROM samples "
"ORDER BY id").fetchall()
assert migrate_to_latest(db) == 1
conn = sqlite3.connect(db)
assert conn.execute("PRAGMA user_version").fetchone()[0] == 7
assert conn.execute(
"SELECT id, ts, device, bytes_written, power_on_hours FROM samples "
"ORDER BY id").fetchall() == before
cols = {r[1] for r in conn.execute("PRAGMA table_info(samples)")}
assert set(NEW_COLUMNS) <= cols
nulls = conn.execute(
"SELECT %s FROM samples" % " OR ".join(c + " IS NOT NULL" for c in NEW_COLUMNS)
).fetchall()
assert nulls == [(0,)] * 3 # legacy rows stay unknown, never zero
def test_migration_is_idempotent_and_init_store_applies_it(self, tmp_path):
db = tmp_path / "o.db"
_legacy_store(db)
conn = init_store(db)
assert conn.execute("PRAGMA user_version").fetchone()[0] == 7
conn.close()
assert migrate_to_latest(db) == 0
def test_pending_payload_without_new_fields_still_publishes(self, tmp_path):
"""Observations staged by the previous version lack the new keys."""
db = tmp_path / "o.db"
conn = init_store(db)
from fenris.collector import write_sample
sample = {k: v for k, v in acquire_from_smartctl(_smartctl()).items()
if k not in NEW_COLUMNS}
sample.update(ts="2026-09-01T12:00:00+00:00", device="/dev/nvme0")
write_sample(sample, {"subnqn": "nqn.test"}, conn,
datetime(2026, 9, 1, 12, tzinfo=timezone.utc))
assert conn.execute("SELECT host_reads FROM samples").fetchone() == (None,)
def test_rollback_notes_procedure_restores_prior_schema(self, tmp_path):
"""Documented rollback: drop the columns, set user_version back to 6."""
db = tmp_path / "o.db"
_legacy_store(db)
migrate_to_latest(db)
conn = sqlite3.connect(db)
conn.execute(
"UPDATE samples SET host_reads = 1 WHERE id = 1")
for column in NEW_COLUMNS:
conn.execute(f"ALTER TABLE samples DROP COLUMN {column}")
conn.execute("PRAGMA user_version=6")
conn.commit()
assert conn.execute("SELECT COUNT(*) FROM samples").fetchone() == (3,)
cols = {r[1] for r in conn.execute("PRAGMA table_info(samples)")}
assert not set(NEW_COLUMNS) & cols
NOW = datetime(2026, 9, 30, 20, 0, tzinfo=timezone.utc)
def _diag_sample(conn, ts, **values):
cols = ["ts", "device", "bytes_written", "bytes_read", "segment_id"] + list(values)
conn.execute(
"INSERT INTO samples (%s) VALUES (%s)" % (
",".join(cols), ",".join("?" * len(cols))),
[ts, "/dev/nvme0n1", 1, 1, 1] + list(values.values()))
conn.commit()
class TestDiagnosticsPanel:
@pytest.fixture
def conn(self, tmp_path):
c = init_store(tmp_path / "o.db")
yield c
c.close()
def _text(self, conn):
selection = ActivitySelection()
selection.toggle_diagnostics()
return diagnostics_text(readout(conn, selection, NOW))
def test_new_counters_show_value_and_delta(self, conn):
_diag_sample(conn, "2026-09-30T18:00:00+00:00", host_reads=1000,
host_writes=500, controller_busy_time=60,
num_err_log_entries=0, warning_temp_time=2,
critical_comp_time=0)
_diag_sample(conn, "2026-09-30T19:50:00+00:00", host_reads=1300,
host_writes=550, controller_busy_time=65,
num_err_log_entries=1, warning_temp_time=2,
critical_comp_time=0)
text = self._text(conn)
assert "Host reads 1300 Δ +300" in text
assert "Host writes 550 Δ +50" in text
assert "Controller busy time 65 min Δ +5" in text
assert "Error log entries 1 Δ +1" in text
assert "Warning temperature time 2 min Δ ±0" in text
assert "Critical temperature time 0 min Δ ±0" in text
def test_legacy_rows_read_n_a_and_absent_thermal_management_is_hidden(self, conn):
_diag_sample(conn, "2026-09-30T19:50:00+00:00", power_on_hours=5)
text = self._text(conn)
assert "Host reads n/a" in text
assert "Thermal management" not in text
def test_thermal_management_shown_when_the_drive_reports_it(self, conn):
_diag_sample(conn, "2026-09-30T19:50:00+00:00", thm_temp1_trans_count=3,
thm_temp1_total_time=120, thm_temp2_trans_count=1,
thm_temp2_total_time=45)
text = self._text(conn)
assert "Thermal management 1 transitions 3 Δ n/a" in text
assert "Thermal management 1 time 120 s Δ n/a" in text
assert "Thermal management 2 transitions 1 Δ n/a" in text
def test_unmigrated_store_still_renders_diagnostics(self, tmp_path):
"""The dashboard may open a store before the upgrade migrates it."""
db = tmp_path / "legacy.db"
_legacy_store(db)
conn = sqlite3.connect(db)
selection = ActivitySelection()
selection.toggle_diagnostics()
text = diagnostics_text(readout(conn, selection, NOW))
assert "Host reads n/a" in text and "Power-on hours" in text
+8 -6
View File
@@ -7,11 +7,9 @@ Covers:
- TPH-#63: Horizon anchoring at evidence endpoint
- TPH-#64: Burst/habit checks with unknown daily shares
"""
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"))
@@ -19,8 +17,8 @@ from fenris.store import init_store
from fenris.monitoring_periods import ensure_period_open, close_period
from fenris.derive import derive_hours_from_interval
from fenris.day_aggregate import derive_day
from fenris.projection import compute_projection, ConfidenceState, STALENESS_HOURS
from fenris.tui import _query_live_graph_data, _query_daily_graph_data
from fenris.projection import compute_projection
from fenris.activity_readout import _query_live_graph_data
# ---------------------------------------------------------------------------
@@ -90,8 +88,8 @@ def _insert_local_day(conn, local_date, tz_name="UTC", tz_offset="+00:00",
conn.execute(
"INSERT INTO local_days "
"(local_date, tz_name, tz_offset, utc_start, utc_end, "
" bytes_written, bytes_read, coverage, sample_count, complete) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
" bytes_written, bytes_read, coverage, sample_count, complete, activity_intervals) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)",
(local_date, tz_name, tz_offset, utc_start, utc_end,
bw, br, coverage, samples, complete),
)
@@ -211,6 +209,8 @@ class TestStalenessFact:
_insert_baseline(conn)
base = datetime(2026, 9, 15, 12, 0, 0, tzinfo=timezone.utc)
ensure_period_open(conn, base)
conn.commit()
for i in range(14):
day = (base + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, day, bw=10*1024*1024*1024)
@@ -234,6 +234,8 @@ class TestStalenessFact:
_insert_baseline(conn)
base = datetime(2026, 9, 18, 12, 0, 0, tzinfo=timezone.utc)
ensure_period_open(conn, base)
conn.commit()
for i in range(14):
day = (base + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, day, bw=10*1024*1024*1024)
+4 -6
View File
@@ -6,7 +6,6 @@ These tests verify that:
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
@@ -15,10 +14,9 @@ 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.monitoring_periods import ensure_period_open
from fenris.projection import (
compute_projection, ConfidenceState, BaselineTier,
WARMING_MIN_DAYS, WARMING_COVERAGE_FLOOR, WARMING_MAX_LOW_COVERAGE,
compute_projection, ConfidenceState,
)
@@ -98,8 +96,8 @@ def _insert_local_day(conn, local_date, tz_name="UTC", tz_offset="+00:00",
conn.execute(
"INSERT INTO local_days "
"(local_date, tz_name, tz_offset, utc_start, utc_end, "
" bytes_written, bytes_read, coverage, sample_count, complete) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
" bytes_written, bytes_read, coverage, sample_count, complete, activity_intervals) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)",
(local_date, tz_name, tz_offset, utc_start, utc_end,
bw, br, coverage, samples, complete),
)
+25 -26
View File
@@ -5,7 +5,6 @@ Covers:
- 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
@@ -93,9 +92,9 @@ class TestFenrisIdentity:
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
async with app.run_test(size=(120, 24)):
str(app.query_one("#headline-band").render())
assert "🐺 Fenris by Bongbetic" in str(app.query_one("#identity").render())
@pytest.mark.asyncio
async def test_titlebox_fallback_without_wolf(self, tmp_path):
@@ -105,10 +104,10 @@ class TestFenrisIdentity:
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())
async with app.run_test(size=(60, 24)):
str(app.query_one("#headline-band").render())
# Either shows wolf or fallback - both are acceptable
assert "Fenris by Bongbetic" in headline
assert "Fenris by Bongbetic" in str(app.query_one("#identity").render())
@pytest.mark.asyncio
async def test_wolf_never_shows_tofu(self, tmp_path):
@@ -117,7 +116,7 @@ class TestFenrisIdentity:
store_path=tmp_path / "nonexistent.db",
refresh_interval_s=0.2,
)
async with app.run_test(size=(120, 24)) as pilot:
async with app.run_test(size=(120, 24)):
headline = str(app.query_one("#headline-band").render())
# No replacement character (U+FFFD) should appear
assert "\ufffd" not in headline.lower()
@@ -137,10 +136,10 @@ class TestFenrisIdentity:
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(120, 24)) as pilot:
async with app.run_test(size=(120, 24)):
headline = str(app.query_one("#headline-band").render())
# Identity appears once, lifespan is separate data
assert "🐺 Fenris by Bongbetic" in headline
assert "🐺 Fenris by Bongbetic" in str(app.query_one("#identity").render())
assert "remaining" in headline.lower() or "projection" in headline.lower()
@@ -160,7 +159,7 @@ class TestSingleMakerCredit:
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(120, 24)) as pilot:
async with app.run_test(size=(120, 24)):
strip = str(app.query_one("#service-strip").render())
# "by Bongbetic" should NOT appear in service strip
assert "by Bongbetic" not in strip
@@ -172,15 +171,15 @@ class TestSingleMakerCredit:
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
async with app.run_test(size=(120, 24)):
str(app.query_one("#headline-band").render())
assert "Fenris by Bongbetic" in str(app.query_one("#identity").render())
@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:
async with app.run_test(size=(120, 24)):
strip = str(app.query_one("#service-strip").render())
assert "by Bongbetic" not in strip
@@ -191,7 +190,7 @@ class TestSingleMakerCredit:
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:
async with app.run_test(size=(120, 24)):
strip = str(app.query_one("#service-strip").render())
assert "by Bongbetic" not in strip
@@ -212,7 +211,7 @@ class TestDriveHealthVendorWear:
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(120, 24)) as pilot:
async with app.run_test(size=(120, 24)):
health = str(app.query_one("#drive-health").render())
# Vendor wear should be present with health context
assert "vendor wear" in health.lower()
@@ -243,7 +242,7 @@ class TestDriveHealthVendorWear:
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(120, 24)) as pilot:
async with app.run_test(size=(120, 24)):
health = str(app.query_one("#drive-health").render())
# Should show 0% used or honest zero, not crash
assert "vendor wear" in health.lower()
@@ -265,7 +264,7 @@ class TestPreservedBehavior:
"last_collect_ok": True, "last_collect_age_s": 60,
"last_collect_reason": None,
}):
async with app.run_test(size=(120, 24)) as pilot:
async with app.run_test(size=(120, 24)):
strip = str(app.query_one("#service-strip").render())
assert "continuity" in strip.lower()
assert "monitoring" in strip.lower()
@@ -274,8 +273,8 @@ class TestPreservedBehavior:
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())
async with app.run_test(size=(120, 24)):
rail = str(app.query_one("#action-rail").render())
assert "q Quit TUI" in rail
@pytest.mark.asyncio
@@ -286,12 +285,12 @@ class TestPreservedBehavior:
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()
str(app.query_one("#headline-band").render())
assert "polkit" in str(app.query_one("#auth-notice").render()).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()
str(app.query_one("#headline-band").render())
assert not app.query_one("#auth-notice").display
@pytest.mark.asyncio
async def test_deliberate_pause_block_preserved(self, tmp_path):
@@ -312,7 +311,7 @@ class TestPreservedBehavior:
"last_collect_ok": None, "last_collect_age_s": None,
"last_collect_reason": None,
}):
async with app.run_test(size=(120, 24)) as pilot:
async with app.run_test(size=(120, 24)):
banner = str(app.query_one("#paused-banner").render())
assert "paused" in banner.lower()
assert "deliberate" in banner.lower()
+15 -20
View File
@@ -8,7 +8,6 @@ Covers:
- 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
@@ -21,15 +20,12 @@ 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.themes import get_graph_colors, THEME_NAMES
from fenris.status_composition import (
StatusState,
compose_status,
render_status_tui,
)
@@ -93,16 +89,16 @@ 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):
async def test_tui_applies_chalktone_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:
async with app.run_test():
# Theme should be fenris-amber
assert app.theme == "fenris-amber"
assert app.theme == "fenris-chalktone"
@pytest.mark.asyncio
async def test_tui_applies_nord_theme_from_prefs(self, tmp_path):
@@ -113,7 +109,7 @@ class TestPresetLoading:
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:
async with app.run_test():
assert app.theme == "fenris-nord"
@pytest.mark.asyncio
@@ -125,20 +121,19 @@ class TestPresetLoading:
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:
async with app.run_test():
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:
async with app.run_test():
# The app should have reduced_motion set
assert app._reduced_motion is True
@@ -180,7 +175,7 @@ class TestKeyBindings:
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app.run_test() as pilot:
# Start at amber
assert app.theme == "fenris-amber"
assert app.theme == "fenris-chalktone"
# Enter drill mode on the graph
graph = app.query_one("#usage-history")
@@ -237,7 +232,7 @@ class TestPersistence:
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
# First run: change theme via preferences API
app1 = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app1.run_test() as pilot:
async with app1.run_test():
save_preferences(theme="nord", reduced_motion=False)
app1._current_theme_name = "nord"
app1.theme = "fenris-nord"
@@ -246,7 +241,7 @@ class TestPersistence:
# Second run: theme should persist
app2 = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app2.run_test() as pilot:
async with app2.run_test():
assert app2.theme == theme_after
@pytest.mark.asyncio
@@ -334,7 +329,7 @@ class TestReducedMotion:
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:
async with app.run_test():
# The app should check for reduced motion on mount
assert hasattr(app, '_reduced_motion')
@@ -380,9 +375,9 @@ class TestSafePersistence:
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:
async with app.run_test():
# TUI should start with default theme
assert app.theme == "fenris-amber"
assert app.theme == "fenris-chalktone"
# Dashboard should be functional
headline = str(app.query_one("#headline-band").render())
assert headline is not None
@@ -399,9 +394,9 @@ class TestSafePersistence:
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:
async with app.run_test():
# TUI should start without crash
assert app.theme in ("fenris-amber", "fenris-nord", "fenris-high-contrast")
assert app.theme in ("fenris-chalktone", "fenris-amber", "fenris-nord", "fenris-high-contrast")
# ---------------------------------------------------------------------------
+314 -73
View File
@@ -10,7 +10,6 @@ Covers:
- AC92-7: Keyboard actions verified from normal launch with headless driver
- AC92-8: Equivalent clickable actions at 80x24 and constrained widths
"""
import sqlite3
from datetime import datetime, timedelta, timezone
from pathlib import Path
from unittest.mock import patch
@@ -24,13 +23,12 @@ from fenris.store import init_store
from fenris.monitoring_periods import ensure_period_open
from fenris.local_day import (
LocalDaySummary,
local_day_boundaries,
persist_local_day,
query_local_day_summary,
)
from fenris.tz_util import detect_system_tz
from fenris.tui import (
FenrisTuiApp,
DailyBarGraph,
_RANGE_OPTIONS,
_RANGE_DEFAULT,
)
@@ -70,13 +68,18 @@ def _open_period(conn, start="2026-09-01T00:00:00+00:00"):
def _insert_local_day(conn, local_date, tz_name="UTC", tz_offset="+00:00",
bw=300, br=130, complete=True):
bw=300, br=130, complete=True,
utc_start=None, utc_end=None):
if utc_start is None:
utc_start = f"{local_date}T00:00:00+00:00"
if utc_end is None:
utc_end = f"{(datetime.fromisoformat(local_date) + timedelta(days=1)).strftime('%Y-%m-%d')}T00:00:00+00:00"
summary = LocalDaySummary(
local_date=local_date,
tz_name=tz_name,
tz_offset=tz_offset,
utc_start=f"{local_date}T00:00:00+00:00",
utc_end=f"{(datetime.fromisoformat(local_date) + timedelta(days=1)).strftime('%Y-%m-%d')}T00:00:00+00:00",
utc_start=utc_start,
utc_end=utc_end,
bytes_written=bw,
bytes_read=br,
coverage=0.95,
@@ -107,15 +110,15 @@ def _make_app(tmp_path, clock=None):
"""
if clock is None:
clock = _clock()
from unittest.mock import patch
import fenris.tui as tui_mod
from datetime import datetime as _real_datetime
mock_dt = type("MockDatetime", (), {
"now": staticmethod(lambda tz=None: clock),
"strptime": staticmethod(lambda *a, **kw: _real_datetime.strptime(*a, **kw)),
"date": staticmethod(lambda: clock.date()),
})()
class MockDatetime(_real_datetime):
@classmethod
def now(cls, tz=None):
return clock
mock_dt = MockDatetime
patcher = patch.object(tui_mod, "datetime", mock_dt)
patcher.start()
@@ -123,6 +126,10 @@ def _make_app(tmp_path, clock=None):
return app, patcher
def _visible_selected_date(app):
return str(app.query_one("#local-day").render())[:10]
# ---------------------------------------------------------------------------
# AC92-1: Keyboard bindings exist
# ---------------------------------------------------------------------------
@@ -177,14 +184,13 @@ class TestBracketNavigation:
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
async with app.run_test(size=(100, 40)) as pilot:
await pilot.pause()
graph = app.query_one("#usage-history")
# Move to an older day first so ] can advance
for _ in range(3):
await pilot.press("left_square_bracket")
day_before = graph._day_data[graph.selected_index].get("day")
day_before = _visible_selected_date(app)
await pilot.press("right_square_bracket")
day_after = graph._day_data[graph.selected_index].get("day")
day_after = _visible_selected_date(app)
# ] should move to a newer day (closer to today)
assert day_after > day_before
@@ -197,11 +203,10 @@ class TestBracketNavigation:
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
async with app.run_test(size=(100, 40)) as pilot:
await pilot.pause()
graph = app.query_one("#usage-history")
day_before = graph._day_data[graph.selected_index].get("day")
day_before = _visible_selected_date(app)
await pilot.press("left_square_bracket")
day_after = graph._day_data[graph.selected_index].get("day")
day_after = _visible_selected_date(app)
# [ should move to an older day (further from today)
assert day_after < day_before
@@ -223,9 +228,8 @@ class TestBracketNavigation:
for _ in range(29):
await pilot.press("left_square_bracket")
# Graph should now show a range that includes the oldest day
assert graph.selected_index >= 0
selected_day = graph._day_data[graph.selected_index].get("day")
# Visible readout identifies oldest selected date.
selected_day = _visible_selected_date(app)
oldest_day = (app_clock - timedelta(days=29)).strftime("%Y-%m-%d")
assert selected_day == oldest_day
finally:
@@ -240,11 +244,10 @@ class TestBracketNavigation:
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
async with app.run_test(size=(100, 40)) as pilot:
await pilot.pause()
graph = app.query_one("#usage-history")
initial_idx = graph.selected_index
initial_date = _visible_selected_date(app)
await pilot.press("right_square_bracket")
assert graph.selected_index == initial_idx
assert _visible_selected_date(app) == initial_date
# ---------------------------------------------------------------------------
@@ -261,35 +264,54 @@ class TestArrowInspection:
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
async with app.run_test(size=(100, 40)) as pilot:
await pilot.pause()
graph = app.query_one("#usage-history")
# Focus the graph widget explicitly
graph.focus()
await pilot.press("v", "v")
app.query_one("#usage-history").focus()
await pilot.pause()
day_before = graph._day_data[graph.selected_index].get("day")
day_before = _visible_selected_date(app)
await pilot.press("left")
day_after = graph._day_data[graph.selected_index].get("day")
day_after = _visible_selected_date(app)
assert day_after != day_before
@pytest.mark.asyncio
async def test_arrow_keys_inspect_hours_in_drill(self, tmp_path):
"""Arrow keys navigate hours when in hourly drill-down."""
conn, clock = _setup_store(tmp_path, n_days=14)
local_date = datetime.now().astimezone().date().isoformat()
timezone_name = detect_system_tz()
utc_start, utc_end, tz_offset = local_day_boundaries(local_date, timezone_name)
_insert_local_day(
conn, local_date, tz_name=timezone_name, tz_offset=tz_offset,
utc_start=utc_start.isoformat(), utc_end=utc_end.isoformat(),
)
hour = utc_start.replace(minute=0, second=0, microsecond=0)
if hour < utc_start:
hour += timedelta(hours=1)
while hour + timedelta(hours=1) <= utc_end:
conn.execute(
"INSERT INTO hour_observations "
"(hour, bytes_written_delta, bytes_read_delta, coverage, sample_count) "
"VALUES (?, 1000000, 2000000, 1, 1)",
(hour.isoformat(),),
)
hour += timedelta(hours=1)
conn.commit()
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
async with app.run_test(size=(100, 40)) as pilot:
await pilot.pause()
graph = app.query_one("#usage-history")
await pilot.press("v", "v")
await pilot.click("#usage-history")
# Enter drill-down
await pilot.press("enter")
assert graph.view_mode == "hourly"
assert app.query_one("#activity-tabs").active == "view-day"
# Arrow keys navigate hours
readout_before = str(app.query_one("#bar-readout").render())
await pilot.press("left")
assert graph._hourly_selected >= 0
assert str(app.query_one("#bar-readout").render()) != readout_before
# ---------------------------------------------------------------------------
@@ -306,18 +328,17 @@ class TestTodayBinding:
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
async with app.run_test(size=(100, 40)) as pilot:
await pilot.pause()
graph = app.query_one("#usage-history")
# Browse to an older day using [
for _ in range(5):
await pilot.press("left_square_bracket")
browsed_day = graph._day_data[graph.selected_index].get("day")
today = clock.strftime("%Y-%m-%d")
browsed_day = _visible_selected_date(app)
today = datetime.now().astimezone().date().isoformat()
assert browsed_day != today
# Press t to return to today
await pilot.press("t")
assert graph.selected_index == len(graph._day_data) - 1
assert app.query_one("#activity-tabs").active == "view-live"
@pytest.mark.asyncio
async def test_t_exits_hourly_drill(self, tmp_path):
@@ -328,16 +349,16 @@ class TestTodayBinding:
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
async with app.run_test(size=(100, 40)) as pilot:
await pilot.pause()
graph = app.query_one("#usage-history")
await pilot.press("v", "v")
await pilot.click("#usage-history")
# Enter drill
await pilot.press("enter")
assert graph.view_mode == "hourly"
assert app.query_one("#activity-tabs").active == "view-day"
# t returns to daily
await pilot.press("t")
assert graph.view_mode == "daily"
assert app.query_one("#activity-tabs").active == "view-live"
# ---------------------------------------------------------------------------
@@ -357,7 +378,7 @@ class TestDateEntry:
await pilot.press("g")
await pilot.pause()
# Date picker should be pushed as a screen
assert len(app.screen_stack) > 1
assert app.screen.query_one("#date-input") is not None
@pytest.mark.asyncio
async def test_valid_date_navigates(self, tmp_path):
@@ -372,13 +393,12 @@ class TestDateEntry:
await pilot.pause()
target_date = (app_clock - timedelta(days=5)).strftime("%Y-%m-%d")
# Simulate the date picker callback directly
app._go_to_date_callback(target_date)
await pilot.press("g")
await pilot.press(*target_date)
await pilot.press("enter")
await pilot.pause()
graph = app.query_one("#usage-history")
selected_day = graph._day_data[graph.selected_index].get("day")
assert selected_day == target_date
assert _visible_selected_date(app) == target_date
finally:
patcher.stop()
@@ -397,7 +417,8 @@ class TestDateEntry:
await pilot.press("enter")
await pilot.pause()
# Should remain on date picker (error shown, not dismissed)
assert len(app.screen_stack) > 1
error = str(app.screen.query_one("#date-error").render())
assert "Invalid date format" in error
@pytest.mark.asyncio
async def test_cancel_returns_to_prior(self, tmp_path):
@@ -408,8 +429,7 @@ class TestDateEntry:
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
async with app.run_test(size=(100, 40)) as pilot:
await pilot.pause()
graph = app.query_one("#usage-history")
prior_idx = graph.selected_index
prior_readout = str(app.query_one("#bar-readout").render())
await pilot.press("g")
await pilot.pause()
@@ -417,7 +437,7 @@ class TestDateEntry:
await pilot.pause()
# Should be back to the graph with the same selection
assert graph.selected_index == prior_idx
assert str(app.query_one("#bar-readout").render()) == prior_readout
# ---------------------------------------------------------------------------
@@ -439,8 +459,6 @@ class TestLocalDayEvidence:
try:
async with app.run_test(size=(100, 40)) as pilot:
await pilot.pause()
graph = app.query_one("#usage-history")
# Navigate to the target day
for _ in range(4):
await pilot.press("left_square_bracket")
@@ -486,12 +504,10 @@ class TestBrowsingStability:
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=0.1)
async with app.run_test(size=(100, 40)) as pilot:
await pilot.pause()
graph = app.query_one("#usage-history")
# Browse to a specific day
for _ in range(3):
await pilot.press("right_square_bracket")
browsed_day = graph._day_data[graph.selected_index].get("day")
await pilot.press("left_square_bracket")
browsed_day = _visible_selected_date(app)
# Wait for at least one refresh tick
import asyncio
@@ -499,8 +515,7 @@ class TestBrowsingStability:
await pilot.pause()
# Selection should be preserved
assert graph.selected_index >= 0
assert graph._day_data[graph.selected_index].get("day") == browsed_day
assert _visible_selected_date(app) == browsed_day
@pytest.mark.asyncio
async def test_navigate_away_does_not_jump_on_refresh(self, tmp_path):
@@ -511,14 +526,12 @@ class TestBrowsingStability:
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=0.1)
async with app.run_test(size=(100, 40)) as pilot:
await pilot.pause()
graph = app.query_one("#usage-history")
# Browse to older day using [
for _ in range(5):
await pilot.press("left_square_bracket")
browsed_day = graph._day_data[graph.selected_index].get("day")
today = clock.strftime("%Y-%m-%d")
browsed_day = _visible_selected_date(app)
today = datetime.now().astimezone().date().isoformat()
assert browsed_day != today
# Wait for refresh
@@ -527,8 +540,8 @@ class TestBrowsingStability:
await pilot.pause()
# Should NOT jump back to today
selected_day = graph._day_data[graph.selected_index].get("day")
assert selected_day != today
assert _visible_selected_date(app) == browsed_day
assert _visible_selected_date(app) != today
# ---------------------------------------------------------------------------
@@ -545,16 +558,16 @@ class TestDrillDownData:
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
async with app.run_test(size=(100, 40)) as pilot:
await pilot.pause()
graph = app.query_one("#usage-history")
await pilot.press("v", "v")
await pilot.click("#usage-history")
# Enter drill-down
await pilot.press("enter")
assert graph.view_mode == "hourly"
assert app.query_one("#activity-tabs").active == "view-day"
# After drill callback, data should be populated
# (empty since we have no hour_observations)
assert graph.view_mode == "hourly"
assert "hourly evidence unavailable" in str(
app.query_one("#bar-readout").render()
)
# ---------------------------------------------------------------------------
@@ -571,11 +584,10 @@ class TestConstrainedWidth:
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
async with app.run_test(size=(80, 24)) as pilot:
await pilot.pause()
graph = app.query_one("#usage-history")
day_before = graph._day_data[graph.selected_index].get("day")
day_before = _visible_selected_date(app)
await pilot.press("left_square_bracket")
day_after = graph._day_data[graph.selected_index].get("day")
day_after = _visible_selected_date(app)
assert day_after < day_before
@pytest.mark.asyncio
@@ -589,4 +601,233 @@ class TestConstrainedWidth:
await pilot.pause()
await pilot.press("g")
await pilot.pause()
assert len(app.screen_stack) > 1
assert app.screen.query_one("#date-input") is not None
class TestClickableNavigation:
@pytest.mark.asyncio
@pytest.mark.parametrize("size", [(80, 24), (70, 20)])
async def test_click_and_keyboard_select_same_local_date(self, tmp_path, size):
"""Clickable and keyboard navigation share date-entry transitions."""
app_clock = _clock()
conn, _ = _setup_store(tmp_path, n_days=14, today=app_clock)
conn.close()
app, patcher = _make_app(tmp_path, clock=app_clock)
expected = (app_clock - timedelta(days=1)).strftime("%Y-%m-%d")
direct = (app_clock - timedelta(days=5)).strftime("%Y-%m-%d")
try:
async with app.run_test(size=size) as pilot:
await pilot.press("left_square_bracket")
keyboard_date = _visible_selected_date(app)
assert keyboard_date == expected
await pilot.press("t")
await pilot.click("#activity-tools", offset=(2, 0))
assert _visible_selected_date(app) == keyboard_date
await pilot.press("g")
await pilot.press(*direct)
await pilot.press("enter")
await pilot.pause()
assert _visible_selected_date(app) == direct
finally:
patcher.stop()
class TestHistoricalSelectionIdentity:
@pytest.mark.asyncio
async def test_unavailable_entered_date_stays_visible_after_refresh(self, tmp_path):
"""An unavailable requested date remains selected and explained."""
conn, clock = _setup_store(tmp_path, n_days=30)
conn.close()
app, patcher = _make_app(tmp_path, clock=clock)
target = (clock - timedelta(days=120)).strftime("%Y-%m-%d")
try:
async with app.run_test(size=(100, 40)) as pilot:
await pilot.press("g")
await pilot.press(*target)
await pilot.press("enter")
await pilot.pause()
local_day = str(app.query_one("#local-day").render())
assert target in local_day
assert "unavailable" in local_day
app.on_refresh_tick()
await pilot.pause()
local_day = str(app.query_one("#local-day").render())
assert target in local_day
assert "unavailable" in local_day
finally:
patcher.stop()
@pytest.mark.asyncio
async def test_retained_summary_outside_recent_window_remains_available(self, tmp_path):
"""Direct date entry can inspect durable local summaries beyond 90 days."""
clock = _clock()
conn, _ = _setup_store(tmp_path, n_days=30, today=clock)
target = (clock - timedelta(days=120)).strftime("%Y-%m-%d")
utc_start, utc_end, tz_offset = local_day_boundaries(
target, "Asia/Kolkata",
)
_insert_local_day(
conn,
target,
tz_name="Asia/Kolkata",
tz_offset=tz_offset,
bw=2_000_000_000,
br=1_000_000_000,
utc_start=utc_start.isoformat(),
utc_end=utc_end.isoformat(),
)
conn.close()
app, patcher = _make_app(tmp_path, clock=clock)
try:
async with app.run_test(size=(100, 40)) as pilot:
await pilot.press("g")
await pilot.press(*target)
await pilot.press("enter")
await pilot.pause()
await pilot.press("v")
await pilot.pause()
readout = str(app.query_one("#bar-readout").render())
assert target in readout
assert "Asia/Kolkata +05:30" in readout
assert "W 2.000 GB known · R 1.000 GB known" in readout
finally:
patcher.stop()
@pytest.mark.asyncio
async def test_constrained_history_keeps_known_totals_and_evidence_state(self, tmp_path):
"""Small textual view retains selected day, totals, and shared evidence."""
clock = _clock()
conn, _ = _setup_store(tmp_path, n_days=14, today=clock)
target = (clock - timedelta(days=2)).strftime("%Y-%m-%d")
utc_start, utc_end, tz_offset = local_day_boundaries(
target, "Asia/Kolkata",
)
_insert_local_day(
conn,
target,
tz_name="Asia/Kolkata",
tz_offset=tz_offset,
bw=1_500_000_000,
br=500_000_000,
utc_start=utc_start.isoformat(),
utc_end=utc_end.isoformat(),
)
conn.execute(
"INSERT INTO local_day_unallocated_evidence "
"(start_sample_id, end_sample_id, start_local_date, end_local_date, "
" start_tz_name, end_tz_name, started_at, ended_at, bytes_written, "
" bytes_read, reason) VALUES (1, 2, ?, ?, ?, ?, ?, ?, ?, ?, 'local_midnight')",
(target, "2026-09-29", "Asia/Kolkata", "Asia/Kolkata",
utc_start.isoformat(), utc_start.isoformat(), 200_000_000, 300_000_000),
)
conn.commit()
conn.close()
app, patcher = _make_app(tmp_path, clock=clock)
try:
async with app.run_test(size=(70, 20)) as pilot:
await pilot.press("g")
await pilot.press(*target)
await pilot.press("enter")
await pilot.press("v")
await pilot.pause()
readout = str(app.query_one("#bar-readout").render())
assert target in readout
assert "Asia/Kolkata +05:30" in readout
assert "incomplete" in readout
assert "W 1.500 GB known · R 0.500 GB known" in readout
assert "shared at midnight W 0.200 GB · R 0.300 GB" in readout
finally:
patcher.stop()
@pytest.mark.asyncio
async def test_date_picker_keystrokes_do_not_toggle_measurement(self, tmp_path):
"""Typing in date entry does not trigger dashboard shortcuts."""
conn, _ = _setup_store(tmp_path, n_days=14)
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
async with app.run_test(size=(100, 40)) as pilot:
await pilot.pause()
await pilot.press("g")
await pilot.press("w")
await pilot.press("escape")
await pilot.pause()
header = str(app.query_one("#live-header").render())
assert "Writes" in header
@pytest.mark.asyncio
async def test_historical_hour_uses_recorded_timezone_and_utc_identity(self, tmp_path):
"""Repeated local clock labels retain distinct UTC-hour identities."""
clock = _clock(2026, 11, 2, 12)
conn, _ = _setup_store(tmp_path, n_days=30, today=clock)
target = "2026-11-01"
_insert_local_day(
conn,
target,
tz_name="America/New_York",
tz_offset="-04:00",
utc_start="2026-11-01T04:00:00+00:00",
utc_end="2026-11-02T05:00:00+00:00",
)
conn.execute(
"INSERT INTO local_day_unallocated_evidence "
"(start_sample_id, end_sample_id, start_local_date, end_local_date, "
" start_tz_name, end_tz_name, started_at, ended_at, bytes_written, "
" bytes_read, reason) VALUES (1, 2, ?, ?, ?, ?, ?, ?, ?, ?, 'local_midnight')",
(target, "2026-11-02", "America/New_York", "America/New_York",
"2026-11-02T03:59:00+00:00", "2026-11-02T04:01:00+00:00",
2_000_000_000, 3_000_000_000),
)
for index in range(25):
hour = datetime(2026, 11, 1, 4, tzinfo=timezone.utc) + timedelta(hours=index)
conn.execute(
"INSERT INTO hour_observations "
"(hour, bytes_written_delta, bytes_read_delta, coverage, sample_count) "
"VALUES (?, ?, ?, 1, 1)",
(hour.isoformat(), (index + 1) * 1_000_000_000,
(index + 1) * 2_000_000_000),
)
conn.execute(
"UPDATE hour_observations SET coverage = 0.5, unknown_seconds = 1800 "
"WHERE hour = ?",
("2026-11-01T04:00:00+00:00",),
)
conn.commit()
conn.close()
app, patcher = _make_app(tmp_path, clock=clock)
try:
async with app.run_test(size=(100, 40)) as pilot:
await pilot.click("#view-history")
await pilot.press("g")
await pilot.press(*target)
await pilot.press("enter")
await pilot.pause()
local_day = str(app.query_one("#local-day").render())
assert "America/New_York -04:00" in local_day
assert "incomplete" in local_day
assert "shared at midnight W 2.000 GB · R 3.000 GB" in local_day
# Move from final local hour to the second 01:00 occurrence.
await pilot.press(*(["left"] * 22))
second_occurrence = str(app.query_one("#bar-readout").render())
assert "01:00 America/New_York -05:00" in second_occurrence
assert "06:00 UTC" in second_occurrence
await pilot.press("left")
first_occurrence = str(app.query_one("#bar-readout").render())
assert "01:00 America/New_York -04:00" in first_occurrence
assert "05:00 UTC" in first_occurrence
await pilot.press("left")
incomplete_hour = str(app.query_one("#bar-readout").render())
assert "00:00 America/New_York -04:00" in incomplete_hour
assert "50% coverage · incomplete" in incomplete_hour
finally:
patcher.stop()
+16 -23
View File
@@ -11,7 +11,6 @@ Seams:
- Read side: query_local_day_history() returns entries with evidence flags
- Repair: repair_derivation() does not touch existing local_days
"""
import sqlite3
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Dict
@@ -21,7 +20,7 @@ import sys
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from fenris.store import init_store, SCHEMA_VERSION
from fenris.store import init_store
from fenris.local_day import (
derive_local_day_summary,
persist_local_day,
@@ -29,11 +28,9 @@ from fenris.local_day import (
query_local_day_history,
LocalDaySummary,
LocalDayHistoryEntry,
_is_detail_available,
)
from fenris.pruning import prune_old_samples, RAW_SAMPLE_RETENTION_DAYS
from fenris.pruning import prune_old_samples
from fenris.repair import repair_derivation
from fenris.monitoring_periods import ensure_period_open, close_period
# ---------------------------------------------------------------------------
@@ -151,8 +148,8 @@ class TestDetailExpiresSummariesRemain:
# Run pruning
pruned = prune_old_samples(conn, now, retention_days=14)
# Old samples should be pruned, recent ones retained
assert pruned >= 1
# The handcrafted totals lack replacement provenance, so retain samples.
assert pruned == 0
# Local-day summaries must still be queryable
old_result = query_local_day_summary(conn, "2026-09-16")
@@ -226,7 +223,7 @@ class TestBoundaryPreservation:
_insert_sample(conn, "2026-09-16T23:55:00+00:00", bw=100)
_insert_sample(conn, "2026-09-17T12:30:00+00:00", bw=200)
pruned = prune_old_samples(conn, now, retention_days=14)
prune_old_samples(conn, now, retention_days=14)
# Boundary anchor should be retained
cursor = conn.execute(
@@ -277,7 +274,7 @@ class TestTimezonePreservation:
now = datetime(2026, 10, 1, 12, 0, 0, tzinfo=timezone.utc)
old = LocalDaySummary(
local_date="2026-09-16", tz_name="US/Eastern", tz_offset="-04:00",
local_date="2026-09-16", tz_name="America/New_York", tz_offset="-04:00",
utc_start="2026-09-16T04:00:00+00:00",
utc_end="2026-09-17T04:00:00+00:00",
bytes_written=5000, bytes_read=2000,
@@ -288,7 +285,7 @@ class TestTimezonePreservation:
entries = query_local_day_history(conn, "2026-09-16", "2026-09-16", now)
assert len(entries) == 1
entry = entries[0]
assert entry.tz_name == "US/Eastern"
assert entry.tz_name == "America/New_York"
assert entry.tz_offset == "-04:00"
assert entry.utc_start == "2026-09-16T04:00:00+00:00"
assert entry.detail_available is False
@@ -305,7 +302,6 @@ class TestLegacyDataHandling:
def test_incomplete_legacy_labelled_correctly(self, tmp_path):
"""Legacy summary without full evidence is labelled incomplete."""
conn = init_store(tmp_path / "obs.db")
now = datetime(2026, 10, 1, 12, 0, 0, tzinfo=timezone.utc)
# Simulate a legacy summary that can't establish a full local-day total
legacy = LocalDaySummary(
@@ -389,10 +385,10 @@ class TestIdempotencySafety:
# ---------------------------------------------------------------------------
class TestVolumeConservation:
"""AC6: Summary volume plus unallocated evidence conserves each delta once."""
"""AC6: UTC-hour totals never become fabricated local-day totals."""
def test_midnight_spanning_not_double_counted(self, tmp_path):
"""Midnight-spanning interval appears once, not in both days."""
def test_hourly_bytes_are_unavailable_at_local_precision(self, tmp_path):
"""UTC-day hour bytes cannot be split across local-day boundaries."""
conn = init_store(tmp_path / "obs.db")
# Insert hour observations that span midnight
@@ -409,13 +405,10 @@ class TestVolumeConservation:
summary_17 = derive_local_day_summary(conn, "UTC", clock_17)
assert summary_17 is not None
# Each day should only have its own hour's bytes
# Day 16 has the 23:00 hour (100 bw)
# Day 17 has the 00:00 hour (200 bw)
assert summary_16.bytes_written == 100
assert summary_17.bytes_written == 200
# Total is conserved: 100 + 200 = 300
assert summary_16.bytes_written + summary_17.bytes_written == 300
assert summary_16.bytes_written == 0
assert summary_17.bytes_written == 0
assert summary_16.activity_precision == "unavailable"
assert summary_17.activity_precision == "unavailable"
conn.close()
@@ -437,10 +430,10 @@ class TestExistingEntryPoints:
_insert_sample(conn, ts, bw=days_ago * 100)
pruned = prune_old_samples(conn, now, retention_days=14)
assert pruned == 15
assert pruned == 0
cursor = conn.execute("SELECT COUNT(*) FROM samples")
assert cursor.fetchone()[0] == 14
assert cursor.fetchone()[0] == 29
conn.close()
def test_repair_with_real_temp_store(self, tmp_path):
+294
View File
@@ -0,0 +1,294 @@
"""Pending publication behavior from issue #97."""
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 import collector
from fenris.projection import compute_projection
from fenris.status import _query_drive_facts, get_status, read_status
from fenris.status_composition import render_status_tui
from fenris.store import SCHEMA_VERSION, init_store
class FakeClock:
def __init__(self, now):
self.now = now
def utcnow(self):
return self.now
@pytest.fixture
def sysfs_fixture_tree(tmp_path):
ctrl_dir = tmp_path / "sys" / "class" / "nvme" / "nvme0"
ctrl_dir.mkdir(parents=True)
(ctrl_dir / "subsysnqn").write_text("nqn.test:drive\n")
(ctrl_dir / "model").write_text("Test NVMe\n")
(ctrl_dir / "serial").write_text("test-serial\n")
(ctrl_dir / "firmware_rev").write_text("1.0\n")
transport_dir = ctrl_dir / "transport"
transport_dir.mkdir()
(transport_dir / "trstring").write_text("pcie\n")
return tmp_path
def _smartctl(unit_written, unit_read=500):
return {
"nvme_smart_health_information_log": {
"critical_warning": 0,
"temperature": 35,
"available_spare": 100,
"percentage_used": 5,
"data_units_written": unit_written,
"data_units_read": unit_read,
"power_on_hours": 100,
},
"user_capacity": {"bytes": 1_024_000_000_000},
"model_name": "Test NVMe",
"serial_number": "test-serial",
"firmware_version": "1.0",
}
def _acquirer(sysfs_path, unit_written, unit_read=500):
return lambda: (_smartctl(unit_written, unit_read), sysfs_path)
def _public_snapshot(store_path, clock_now):
with read_status(store_path, clock_now) as (conn, comp):
assert conn is not None
return {
"state": comp.state,
"freshness": comp.freshness,
"sample_count": comp.sample_count,
"day_count": comp.day_count,
"boot_enabled": comp.boot_enabled,
"timer_active": comp.timer_active,
"last_collect_ok": comp.last_collect_ok,
"latest_sample": tuple(conn.execute(
"SELECT ts, subnqn, mn, sn, fr, capacity_bytes, temperature_c, "
"available_spare, data_units_written, bytes_written, critical_warning, "
"media_errors, unsafe_shutdowns "
"FROM samples ORDER BY id DESC LIMIT 1"
).fetchone()),
"drive_facts": tuple(_query_drive_facts(conn)),
"projection": repr(compute_projection(conn, clock_now)),
"segments": conn.execute("SELECT COUNT(*) FROM controller_segments").fetchone()[0],
"periods": conn.execute("SELECT COUNT(*) FROM monitoring_periods").fetchone()[0],
"hours": tuple(conn.execute(
"SELECT hour, bytes_written_delta, bytes_read_delta, sample_count "
"FROM hour_observations ORDER BY hour"
).fetchall()),
"days": tuple(conn.execute(
"SELECT day, bytes_written_delta, bytes_read_delta "
"FROM day_aggregates ORDER BY day"
).fetchall()),
"local_days": tuple(conn.execute(
"SELECT local_date, bytes_written, bytes_read, sample_count "
"FROM local_days ORDER BY local_date, tz_name"
).fetchall()),
"pending": comp.pending_publication_count,
}
def test_failed_publication_survives_restart_and_readers_keep_last_consistent_view(
tmp_path, sysfs_fixture_tree, monkeypatch
):
"""A failed derivation stays private, visible as pending, and replays in order."""
monkeypatch.setenv("TZ", "UTC")
service = {"boot_enabled": True, "timer_active": True, "last_collect_ok": True}
monkeypatch.setattr("fenris.status.query_service_state", lambda: service)
now = datetime.now(timezone.utc).replace(
minute=35, second=0, microsecond=0,
)
store_path = tmp_path / "observations.db"
config = {"device": "/dev/nvme0", "store_path": str(store_path)}
sysfs_path = sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0"
for units, at in ((1000, now - timedelta(minutes=10)), (1010, now - timedelta(minutes=5))):
result = collector.run_collection(
config=config,
clock=FakeClock(at),
acquire=_acquirer(sysfs_path, units),
)
assert result["ok"] is True, result
previous = _public_snapshot(store_path, now)
baseline_cli = get_status(store_path, now, query_services=False, query_journal=False)
original_derive = collector.derive_hours_from_interval
reader_during_write = {}
def fail_derivation(conn, previous_sample, next_sample):
# Read while writer has inserted the unpublished sample but not committed.
with read_status(store_path, now, query_services=False) as (reader, comp):
assert reader is not None
reader_during_write["samples"] = reader.execute(
"SELECT COUNT(*) FROM samples"
).fetchone()[0]
reader_during_write["pending"] = comp.pending_publication_count
reader_during_write["latest"] = tuple(reader.execute(
"SELECT data_units_written FROM samples ORDER BY id DESC LIMIT 1"
).fetchone())
raise RuntimeError("injected non-invariant derivation failure")
monkeypatch.setattr(collector, "derive_hours_from_interval", fail_derivation)
failed = collector.run_collection(
config=config,
clock=FakeClock(now),
acquire=_acquirer(sysfs_path, 1025),
)
assert failed["ok"] is False
assert "injected non-invariant derivation failure" in failed["error"]
assert reader_during_write == {"samples": 2, "pending": 1, "latest": (1010,)}
after_failure = _public_snapshot(store_path, now)
assert {**after_failure, "pending": previous["pending"]} == previous
assert after_failure["pending"] == 1
cli_text = get_status(store_path, now, query_services=False, query_journal=False)
pending_fact = " · pending publication: 1 observation retained for retry"
assert pending_fact in cli_text
assert cli_text.replace(pending_fact, "", 1) == baseline_cli
with read_status(store_path, now) as (_, comp):
assert comp.state == previous["state"]
assert comp.boot_enabled is True
assert comp.timer_active is True
assert "pending publication: 1 observation retained for retry" in render_status_tui(comp).lower()
called = False
def should_wait_for_recovery():
nonlocal called
called = True
return _smartctl(1030), sysfs_path
monkeypatch.setattr(collector, "PENDING_PUBLICATION_LIMIT", 1)
blocked = collector.run_collection(
config=config,
clock=FakeClock(now + timedelta(minutes=5)),
acquire=should_wait_for_recovery,
)
assert blocked["ok"] is False
assert called is False
assert _public_snapshot(store_path, now)["pending"] == 1
monkeypatch.setattr(collector, "derive_hours_from_interval", original_derive)
monkeypatch.setenv("TZ", "Asia/Kolkata")
recovered = collector.run_collection(
config=config,
clock=FakeClock(now + timedelta(minutes=5)),
acquire=_acquirer(sysfs_path, 1040),
)
assert recovered["ok"] is True, recovered
conn = sqlite3.connect(store_path)
try:
assert conn.execute(
"SELECT data_units_written FROM samples ORDER BY id"
).fetchall() == [(1000,), (1010,), (1025,), (1040,)]
assert conn.execute("SELECT COUNT(*) FROM pending_publications").fetchone()[0] == 0
assert conn.execute(
"SELECT SUM(bytes_written_delta) FROM day_aggregates"
).fetchone()[0] == (1040 - 1000) * 512_000
assert conn.execute(
"SELECT sample_count FROM local_days WHERE tz_name = 'UTC'"
).fetchone()[0] == 4
assert conn.execute(
"SELECT COUNT(*) FROM local_days WHERE tz_name = 'Asia/Kolkata'"
).fetchone()[0] == 1
finally:
conn.close()
def test_invalid_sample_writes_no_sample_segment_period_or_pending_row(tmp_path, sysfs_fixture_tree):
"""Sample invariants run before staging or public monitoring changes."""
now = datetime.now(timezone.utc).replace(second=0, microsecond=0)
store_path = tmp_path / "observations.db"
config = {"device": "/dev/nvme0", "store_path": str(store_path)}
sysfs_path = sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0"
result = collector.run_collection(
config=config,
clock=FakeClock(now),
acquire=_acquirer(sysfs_path, -1),
)
assert result["ok"] is False
assert result["error_type"] == "InvariantViolationError"
conn = sqlite3.connect(store_path)
try:
assert conn.execute("SELECT COUNT(*) FROM samples").fetchone()[0] == 0
assert conn.execute("SELECT COUNT(*) FROM controller_segments").fetchone()[0] == 0
assert conn.execute("SELECT COUNT(*) FROM monitoring_periods").fetchone()[0] == 0
assert conn.execute("SELECT COUNT(*) FROM pending_publications").fetchone()[0] == 0
finally:
conn.close()
def test_pending_capacity_retries_before_skipping_acquisition(tmp_path, sysfs_fixture_tree, monkeypatch):
now = datetime.now(timezone.utc).replace(second=0, microsecond=0)
store_path = tmp_path / "observations.db"
config = {"device": "/dev/nvme0", "store_path": str(store_path)}
sysfs_path = sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0"
first = collector.run_collection(
config=config,
clock=FakeClock(now - timedelta(minutes=5)),
acquire=_acquirer(sysfs_path, 1000),
)
assert first["ok"] is True, first
monkeypatch.setattr(collector, "PENDING_PUBLICATION_LIMIT", 1)
def fail_derivation(*_):
raise RuntimeError("derivation still unavailable")
monkeypatch.setattr(collector, "derive_hours_from_interval", fail_derivation)
failed = collector.run_collection(
config=config,
clock=FakeClock(now),
acquire=_acquirer(sysfs_path, 1010),
)
assert failed["ok"] is False
assert "capacity full (1 observations)" in failed["error"]
called = False
def must_not_acquire():
nonlocal called
called = True
return _smartctl(1020), sysfs_path
retry = collector.run_collection(
config=config,
clock=FakeClock(now + timedelta(minutes=5)),
acquire=must_not_acquire,
)
assert retry["ok"] is False
assert "capacity full (1 observations)" in retry["error"]
assert called is False
def test_v3_readers_ignore_pending_table_until_store_migrates(tmp_path):
store_path = tmp_path / "observations.db"
conn = init_store(store_path)
conn.execute("DROP TABLE pending_publications")
conn.execute("PRAGMA user_version=3")
conn.commit()
conn.close()
with read_status(store_path, datetime.now(timezone.utc), query_services=False) as (reader, comp):
assert reader is not None
assert comp.store_fault is None
assert comp.pending_publication_count == 0
migrated = init_store(store_path)
try:
assert migrated.execute("PRAGMA user_version").fetchone()[0] == SCHEMA_VERSION
assert migrated.execute("SELECT COUNT(*) FROM pending_publications").fetchone()[0] == 0
finally:
migrated.close()
+520
View File
@@ -0,0 +1,520 @@
"""Historical local-day migration and repair acceptance tests for issue #99."""
import sqlite3
import sys
from datetime import datetime, timezone
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from fenris.local_day import (
query_local_day_summary,
record_local_activity_interval,
)
from fenris.status import read_status
from fenris.store import init_store
SOURCE_SCHEMA_VERSION = 5
LOCAL_DATE = "2026-09-02"
TIMEZONE = "Asia/Kolkata"
UTC_START = "2026-09-01T18:30:00+00:00"
UTC_END = "2026-09-02T18:30:00+00:00"
def _legacy_store(path: Path) -> sqlite3.Connection:
"""Build a v5 store with one old coarse local-day summary."""
conn = init_store(path)
conn.execute(
"INSERT INTO local_days "
"(local_date, tz_name, tz_offset, utc_start, utc_end, bytes_written, "
" bytes_read, coverage, sample_count, complete, activity_incomplete, "
" activity_precision) "
"VALUES (?, ?, '+05:30', ?, ?, 999999, 888888, 1.0, 24, 1, 0, 'legacy')",
(LOCAL_DATE, TIMEZONE, UTC_START, UTC_END),
)
conn.execute(
"INSERT INTO monitoring_periods (started_at, ended_at) VALUES (?, ?)",
(UTC_START, "2026-09-03T00:00:00+00:00"),
)
conn.execute(
"INSERT INTO controller_segments "
"(opened_at, identity_key, identity_degraded) VALUES (?, ?, 0)",
(UTC_START, "controller-a"),
)
conn.execute(
"INSERT INTO hour_observations "
"(hour, bytes_written_delta, bytes_read_delta, sample_count, coverage) "
"VALUES ('2026-09-02T00:00:00+00:00', 123, 45, 2, 1.0)"
)
conn.execute(
"INSERT INTO day_aggregates "
"(day, bytes_written_delta, bytes_read_delta, coverage) "
"VALUES ('2026-09-02', 678, 90, 1.0)"
)
conn.execute(
"INSERT INTO samples (ts, device, bytes_written, bytes_read, power_on_hours, "
" segment_id, local_tz) VALUES (?, '/dev/nvme0', 1000, 2000, 100, 1, ?)",
("2026-09-01T19:00:00+00:00", TIMEZONE),
)
conn.execute(
"INSERT INTO samples (ts, device, bytes_written, bytes_read, power_on_hours, "
" segment_id, local_tz) VALUES (?, '/dev/nvme0', 11000, 5000, 115, 1, ?)",
("2026-09-02T10:00:00+00:00", TIMEZONE),
)
conn.execute(f"PRAGMA user_version={SOURCE_SCHEMA_VERSION}")
conn.commit()
return conn
def test_upgrade_rebuilds_legacy_day_from_samples_and_keeps_recorded_bounds(
tmp_path, monkeypatch,
):
db = tmp_path / "legacy.db"
conn = _legacy_store(db)
conn.close()
monkeypatch.setenv("TZ", "UTC")
conn = init_store(db)
conn.close()
with read_status(
db, datetime(2026, 9, 4, tzinfo=timezone.utc), query_services=False,
) as (reader, composition):
assert reader is not None
assert composition.store_fault is None
summary = query_local_day_summary(
reader, LOCAL_DATE, TIMEZONE,
datetime(2026, 9, 4, tzinfo=timezone.utc),
)
assert summary is not None
assert summary["local_date"] == LOCAL_DATE
assert summary["tz_name"] == TIMEZONE
assert summary["tz_offset"] == "+05:30"
assert summary["utc_start"] == UTC_START
assert summary["utc_end"] == UTC_END
assert summary["bytes_written"] == 10_000
assert summary["bytes_read"] == 3_000
assert summary["activity_precision"] == "measured"
assert summary["segments"] == [{
"segment_id": 1,
"bytes_written": 10_000,
"bytes_read": 3_000,
"activity_seconds": 54_000,
"activity_intervals": 1,
}]
assert summary["activity_state"] == "incomplete"
assert reader.execute(
"SELECT bytes_written_delta FROM hour_observations"
).fetchone()[0] == 123
assert reader.execute(
"SELECT bytes_written_delta FROM day_aggregates"
).fetchone()[0] == 678
conn = init_store(db)
conn.close()
with read_status(
db, datetime(2026, 9, 4, tzinfo=timezone.utc), query_services=False,
) as (reader, _composition):
assert reader is not None
summary = query_local_day_summary(
reader, LOCAL_DATE, TIMEZONE,
datetime(2026, 9, 4, tzinfo=timezone.utc),
)
assert summary["bytes_written"] == 10_000
assert summary["bytes_read"] == 3_000
def test_repair_rebuilds_legacy_local_day_once_across_restarts(tmp_path):
from fenris.repair import repair_derivation
db = tmp_path / "repair.db"
conn = _legacy_store(db)
first = repair_derivation(conn)
assert first.ok
summary = query_local_day_summary(
conn, LOCAL_DATE, TIMEZONE,
datetime(2026, 9, 4, tzinfo=timezone.utc),
)
assert summary["bytes_written"] == 10_000
assert summary["bytes_read"] == 3_000
conn.close()
conn = sqlite3.connect(db)
second = repair_derivation(conn)
assert second.ok
summary = query_local_day_summary(
conn, LOCAL_DATE, TIMEZONE,
datetime(2026, 9, 4, tzinfo=timezone.utc),
)
assert summary["bytes_written"] == 10_000
assert summary["bytes_read"] == 3_000
assert conn.execute(
"SELECT bytes_written, bytes_read, activity_intervals "
"FROM local_day_segment_totals"
).fetchall() == [(10_000, 3_000, 1)]
conn.close()
def test_interrupted_repair_keeps_local_day_rebuild_retryable(tmp_path):
from fenris.repair import repair_derivation
db = tmp_path / "repair-interrupted.db"
conn = _legacy_store(db)
conn.execute(
"CREATE TRIGGER interrupt_repair_local_rebuild "
"BEFORE INSERT ON local_day_segment_totals "
"BEGIN SELECT RAISE(ABORT, 'simulated repair interruption'); END"
)
conn.commit()
failed = repair_derivation(conn)
assert not failed.ok
assert query_local_day_summary(
conn, LOCAL_DATE, TIMEZONE,
datetime(2026, 9, 4, tzinfo=timezone.utc),
)["bytes_written"] is None
assert conn.execute(
"SELECT activity_precision, bytes_written FROM local_days"
).fetchone() == ("legacy", 999_999)
conn.execute("DROP TRIGGER interrupt_repair_local_rebuild")
conn.commit()
conn.close()
conn = sqlite3.connect(db)
retried = repair_derivation(conn)
assert retried.ok
summary = query_local_day_summary(
conn, LOCAL_DATE, TIMEZONE,
datetime(2026, 9, 4, tzinfo=timezone.utc),
)
assert summary["bytes_written"] == 10_000
assert summary["bytes_read"] == 3_000
conn.close()
def test_stale_repair_marker_does_not_block_crash_retry(tmp_path):
from fenris.repair import repair_derivation
conn = _legacy_store(tmp_path / "stale-repair.db")
conn.execute(
"INSERT OR REPLACE INTO store_metadata (key, value) "
"VALUES ('repair_in_progress', 'true')"
)
conn.commit()
result = repair_derivation(conn)
assert result.ok
assert conn.execute(
"SELECT value FROM store_metadata WHERE key = 'repair_in_progress'"
).fetchone() == ("false",)
assert query_local_day_summary(
conn, LOCAL_DATE, TIMEZONE,
datetime(2026, 9, 4, tzinfo=timezone.utc),
)["bytes_written"] == 10_000
conn.close()
def test_upgrade_uses_whole_utc_hours_but_skips_midnight_straddling_hours(
tmp_path,
):
db = tmp_path / "coarse.db"
conn = _legacy_store(db)
conn.execute("DELETE FROM samples")
conn.execute(
"INSERT INTO hour_observations "
"(hour, bytes_written_delta, bytes_read_delta, sample_count, "
" active_seconds, coverage) "
"VALUES ('2026-09-01T18:00:00+00:00', 99999, 9999, 2, 3600, 1.0)"
)
conn.execute(
"INSERT INTO hour_observations "
"(hour, bytes_written_delta, bytes_read_delta, sample_count, "
" active_seconds, coverage) "
"VALUES ('2026-09-02T18:00:00+00:00', 88888, 8888, 2, 3600, 1.0)"
)
conn.commit()
conn.close()
conn = init_store(db)
summary = query_local_day_summary(
conn, LOCAL_DATE, TIMEZONE,
datetime(2026, 9, 4, tzinfo=timezone.utc),
)
assert summary["bytes_written"] == 123
assert summary["bytes_read"] == 45
assert summary["activity_precision"] == "coarse"
assert summary["activity_state"] == "incomplete"
assert summary["utc_start"] == UTC_START
assert summary["utc_end"] == UTC_END
conn.execute(
"INSERT INTO samples "
"(id, ts, device, bytes_written, bytes_read, segment_id, local_tz) "
"VALUES (10, '2026-09-02T12:00:00+00:00', '/dev/nvme0', 1000, 2000, 1, ?), "
" (11, '2026-09-02T12:05:00+00:00', '/dev/nvme0', 2000, 2500, 1, ?)",
(TIMEZONE, TIMEZONE),
)
conn.commit()
assert record_local_activity_interval(
conn,
{
"id": 10, "ts": "2026-09-02T12:00:00+00:00",
"bytes_written": 1_000, "bytes_read": 2_000,
"local_tz": TIMEZONE,
},
{
"id": 11, "ts": "2026-09-02T12:05:00+00:00",
"bytes_written": 2_000, "bytes_read": 2_500,
"local_tz": TIMEZONE,
},
start_sample_id=10, end_sample_id=11, segment_id=1,
) == "known"
summary = query_local_day_summary(
conn, LOCAL_DATE, TIMEZONE,
datetime(2026, 9, 4, tzinfo=timezone.utc),
)
assert summary["bytes_written"] == 1_000
assert summary["bytes_read"] == 500
assert summary["activity_precision"] == "measured"
assert summary["segments"] == [{
"segment_id": 1,
"bytes_written": 1_000,
"bytes_read": 500,
"activity_seconds": 300,
"activity_intervals": 1,
}]
conn.close()
def test_upgrade_does_not_double_count_coarse_hours_in_overlapping_days(tmp_path):
db = tmp_path / "overlapping-coarse-days.db"
conn = _legacy_store(db)
conn.execute("DELETE FROM samples")
conn.execute(
"INSERT INTO local_days "
"(local_date, tz_name, tz_offset, utc_start, utc_end, bytes_written, "
" bytes_read, coverage, sample_count, complete, activity_precision) "
"VALUES ('2026-09-02', 'UTC', '+00:00', "
"'2026-09-02T00:00:00+00:00', '2026-09-03T00:00:00+00:00', "
"999999, 888888, 1.0, 24, 1, 'legacy')"
)
conn.commit()
conn.close()
conn = init_store(db)
for tz_name in (TIMEZONE, "UTC"):
summary = query_local_day_summary(
conn, LOCAL_DATE, tz_name,
datetime(2026, 9, 4, tzinfo=timezone.utc),
)
assert summary["bytes_written"] is None
assert summary["bytes_read"] is None
assert summary["activity_precision"] == "unavailable"
assert summary["activity_state"] == "unavailable"
assert conn.execute(
"SELECT bytes_written_delta, bytes_read_delta FROM hour_observations "
"WHERE hour = '2026-09-02T00:00:00+00:00'"
).fetchone() == (123, 45)
conn.close()
def test_upgrade_retains_local_midnight_interval_once_as_shared_evidence(tmp_path):
db = tmp_path / "shared.db"
conn = _legacy_store(db)
conn.execute(
"INSERT INTO local_days "
"(local_date, tz_name, tz_offset, utc_start, utc_end, bytes_written, "
" bytes_read, coverage, sample_count, complete, activity_precision) "
"VALUES ('2026-09-01', ?, '+05:30', "
"'2026-08-31T18:30:00+00:00', '2026-09-01T18:30:00+00:00', "
"500, 600, 1.0, 24, 1, 'legacy')",
(TIMEZONE,),
)
conn.execute(
"UPDATE monitoring_periods SET started_at = '2026-08-31T18:00:00+00:00'"
)
conn.execute(
"UPDATE controller_segments SET opened_at = '2026-08-31T18:00:00+00:00'"
)
conn.execute(
"UPDATE samples SET ts = '2026-09-01T18:25:00+00:00', "
"bytes_written = 1000, bytes_read = 2000 WHERE id = 1"
)
conn.execute(
"UPDATE samples SET ts = '2026-09-01T18:35:00+00:00', "
"bytes_written = 1100, bytes_read = 2050 WHERE id = 2"
)
conn.commit()
conn.close()
conn = init_store(db)
shared = conn.execute(
"SELECT COUNT(*), SUM(bytes_written), SUM(bytes_read) "
"FROM local_day_unallocated_evidence"
).fetchone()
assert shared == (1, 100, 50)
for local_date in ("2026-09-01", "2026-09-02"):
summary = query_local_day_summary(
conn, local_date, TIMEZONE,
datetime(2026, 9, 4, tzinfo=timezone.utc),
)
assert summary["bytes_written"] is None
assert summary["shared_bytes_written"] == 100
assert summary["shared_bytes_read"] == 50
assert summary["activity_state"] == "incomplete"
from fenris.repair import repair_derivation
assert repair_derivation(conn).ok
assert conn.execute(
"SELECT COUNT(*), SUM(bytes_written), SUM(bytes_read) "
"FROM local_day_unallocated_evidence"
).fetchone() == (1, 100, 50)
conn.close()
def test_interrupted_upgrade_keeps_legacy_day_retryable(tmp_path):
db = tmp_path / "interrupted.db"
conn = _legacy_store(db)
conn.execute(
"CREATE TRIGGER interrupt_local_rebuild "
"BEFORE INSERT ON local_day_segment_totals "
"BEGIN SELECT RAISE(ABORT, 'simulated interruption'); END"
)
conn.commit()
conn.close()
with pytest.raises(sqlite3.IntegrityError, match="simulated interruption"):
init_store(db)
conn = sqlite3.connect(db)
assert conn.execute("PRAGMA user_version").fetchone() == (SOURCE_SCHEMA_VERSION,)
assert conn.execute(
"SELECT activity_precision, bytes_written FROM local_days"
).fetchone() == ("legacy", 999_999)
conn.execute("DROP TRIGGER interrupt_local_rebuild")
conn.commit()
conn.close()
conn = init_store(db)
summary = query_local_day_summary(
conn, LOCAL_DATE, TIMEZONE,
datetime(2026, 9, 4, tzinfo=timezone.utc),
)
assert summary["bytes_written"] == 10_000
assert summary["bytes_read"] == 3_000
conn.close()
def test_prior_migration_step_commits_before_local_day_repair(tmp_path):
db = tmp_path / "migration-steps.db"
conn = _legacy_store(db)
conn.execute("PRAGMA user_version=4")
conn.execute(
"CREATE TRIGGER interrupt_local_rebuild BEFORE UPDATE OF bytes_written "
"ON local_days BEGIN SELECT RAISE(ABORT, 'simulated interruption'); END"
)
conn.commit()
conn.close()
with pytest.raises(sqlite3.IntegrityError, match="simulated interruption"):
init_store(db)
conn = sqlite3.connect(db)
assert conn.execute("PRAGMA user_version").fetchone() == (5,)
assert conn.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' "
"AND name='local_day_segment_totals'"
).fetchone() is not None
assert conn.execute(
"SELECT bytes_written FROM local_days WHERE local_date=?",
(LOCAL_DATE,),
).fetchone() == (999_999,)
conn.close()
def test_upgrade_does_not_allocate_unzoned_interval_to_overlapping_timezones(
tmp_path,
):
db = tmp_path / "overlapping-timezones.db"
conn = _legacy_store(db)
conn.execute(
"INSERT INTO local_days "
"(local_date, tz_name, tz_offset, utc_start, utc_end, bytes_written, "
" bytes_read, coverage, sample_count, complete, activity_precision) "
"VALUES ('2026-09-02', 'UTC', '+00:00', "
"'2026-09-02T00:00:00+00:00', '2026-09-03T00:00:00+00:00', "
"999999, 888888, 1.0, 24, 1, 'legacy')"
)
conn.execute(
"UPDATE samples SET ts = '2026-09-02T01:00:00+00:00', local_tz = NULL "
"WHERE id = 1"
)
conn.execute(
"UPDATE samples SET ts = '2026-09-02T10:00:00+00:00', local_tz = NULL "
"WHERE id = 2"
)
conn.commit()
conn.close()
conn = init_store(db)
for tz_name in (TIMEZONE, "UTC"):
summary = query_local_day_summary(
conn, LOCAL_DATE, tz_name,
datetime(2026, 9, 4, tzinfo=timezone.utc),
)
assert summary["bytes_written"] is None
assert summary["bytes_read"] is None
assert summary["activity_precision"] == "unavailable"
assert summary["activity_state"] == "unavailable"
conn.close()
def test_upgrade_rejects_interval_before_recorded_controller_segment(
tmp_path,
):
db = tmp_path / "segment-boundary.db"
conn = _legacy_store(db)
conn.execute(
"UPDATE controller_segments "
"SET opened_at = '2026-09-01T20:00:00+00:00' WHERE id = 1"
)
conn.commit()
conn.close()
conn = init_store(db)
summary = query_local_day_summary(
conn, LOCAL_DATE, TIMEZONE,
datetime(2026, 9, 4, tzinfo=timezone.utc),
)
assert summary["bytes_written"] is None
assert summary["bytes_read"] is None
assert summary["activity_precision"] == "unavailable"
conn.close()
def test_sample_at_local_midnight_does_not_hide_coarse_reconstruction(tmp_path):
db = tmp_path / "midnight-sample.db"
conn = _legacy_store(db)
conn.execute(
"UPDATE samples SET ts = '2026-09-01T17:00:00+00:00' WHERE id = 1"
)
conn.execute(
"UPDATE samples SET ts = ?, local_tz = ? WHERE id = 2",
(UTC_END, TIMEZONE),
)
conn.commit()
conn.close()
conn = init_store(db)
summary = query_local_day_summary(
conn, LOCAL_DATE, TIMEZONE,
datetime(2026, 9, 4, tzinfo=timezone.utc),
)
assert summary["bytes_written"] == 123
assert summary["bytes_read"] == 45
assert summary["activity_precision"] == "coarse"
assert summary["activity_state"] == "incomplete"
conn.close()
+1 -4
View File
@@ -4,9 +4,6 @@ Tests the idempotent, interruption-safe import of history.jsonl into the
observation store.
"""
import json
import os
import sqlite3
import tempfile
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict
@@ -17,7 +14,7 @@ import pytest
import sys
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from fenris.legacy import import_legacy_history, is_legacy_imported, _parse_history_line
from fenris.legacy import import_legacy_history, _parse_history_line
from fenris.store import init_store
+113 -13
View File
@@ -6,26 +6,21 @@ Covers:
- Three-minute cadence constants
- TUI integration with live graph
"""
import sys
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.activity_selection import IntervalIdentity
from fenris.monitoring_periods import ensure_period_open
from fenris.tui import (
FenrisTuiApp,
LiveActivityGraph,
_query_live_graph_data,
LIVE_WINDOW_H,
_query_daily_graph_data,
_RANGE_OPTIONS,
)
from fenris.status import CADENCE_DEFAULT_S, FRESH_THRESHOLD_S, ACCURACY_SEC
from fenris.status import ACCURACY_SEC, CADENCE_DEFAULT_S, FRESH_THRESHOLD_S
from fenris.store import init_store
from fenris.timefmt import local_clock, tz_label, utc_range_label
from fenris.activity_readout import LIVE_WINDOW_H, _query_live_graph_data
from fenris.tui import FenrisTuiApp, LiveActivityGraph
# ---------------------------------------------------------------------------
# Helpers
@@ -63,6 +58,29 @@ def _open_period(conn, start="2026-09-01T00:00:00+00:00"):
ensure_period_open(conn, datetime.fromisoformat(start))
def _live_intervals(count=4):
first = datetime(2026, 9, 28, 12, 0, tzinfo=timezone.utc)
intervals = []
for index in range(count):
start = first + timedelta(minutes=3 * index)
end = start + timedelta(minutes=3)
intervals.append({
"start_ts": start.isoformat(),
"end_ts": end.isoformat(),
"start_label": local_clock(start, "UTC"),
"end_label": local_clock(end, "UTC"),
"timezone_label": tz_label(end, "UTC"),
"utc_label": utc_range_label(start, end),
"bytes_written": (index + 1) * 1000,
"bytes_read": (index + 1) * 500,
"elapsed_s": 180,
"is_gap": False,
"is_zero": False,
"is_segment_boundary": False,
})
return intervals
# ---------------------------------------------------------------------------
# Cadence constants (issue #91 AC1)
# ---------------------------------------------------------------------------
@@ -263,7 +281,7 @@ class TestLiveActivityGraph:
await pilot.pause()
header = str(graph.query_one("#live-header").render())
assert "1 intervals" in header
assert "w Toggle W/R" in header
assert "w Toggle read/write" in str(app.query_one("#activity-tools").render())
# ---------------------------------------------------------------------------
@@ -322,3 +340,85 @@ class TestTUILiveIntegration:
await pilot.pause()
main_grid = app.query_one("#main-grid")
assert main_grid.has_class("constrained")
@pytest.mark.asyncio
@pytest.mark.parametrize("size", [(100, 40), (70, 20)])
async def test_follow_pin_expiry_and_today_use_visible_readout(self, tmp_path, size):
"""Live selection follows, pins, expires, and resets at both layout sizes."""
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
intervals = _live_intervals()
async with app.run_test(size=size) as pilot:
await pilot.pause()
graph = app.query_one("#live-activity")
graph.set_data(intervals[:3])
readout = str(app.query_one("#live-readout").render())
assert "12:09 UTC" in readout
assert graph.selection.following_live
assert graph.selection.selected_live_interval == IntervalIdentity(
intervals[2]["start_ts"], intervals[2]["end_ts"]
)
# Refresh while following selects the newly measured newest point.
graph.set_data(intervals)
assert "12:12 UTC" in str(app.query_one("#live-readout").render())
# Live graph receives focus at launch; first arrow pins immediately.
await pilot.press("left")
pinned = graph.selection.selected_live_interval
readout = str(app.query_one("#live-readout").render())
assert "12:09 UTC" in readout
assert not graph.selection.following_live
graph.set_data(intervals)
readout = str(app.query_one("#live-readout").render())
assert "12:09 UTC" in readout
assert graph.selection.selected_live_interval == pinned
# Read/write changes presentation only; pinned point stays stable.
await pilot.press("w")
graph.set_data(intervals)
assert graph.measure == "read"
assert graph.selection.selected_live_interval == pinned
assert "12:09 UTC" in str(app.query_one("#live-readout").render())
# Remove pinned interval from rolling window. Explain expiration.
graph.set_data(intervals[3:])
readout = str(app.query_one("#live-readout").render())
assert "Inspected interval expired; following live" in readout
assert "12:12 UTC" in readout
assert graph.selection.following_live
# Today action clears expiration and resumes newest-point following.
await pilot.press("t")
assert graph.selection.following_live
assert not graph.selection.live_interval_expired
graph.set_data(intervals)
readout = str(app.query_one("#live-readout").render())
assert "12:12 UTC" in readout
assert "Inspected interval expired" not in readout
assert graph.selection.following_live
@pytest.mark.asyncio
async def test_mouse_inspection_pins_same_interval_identity(self, tmp_path):
"""Mouse inspection pins the interval at its visible graph location."""
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
intervals = _live_intervals()
async with app.run_test(size=(100, 40)) as pilot:
await pilot.pause()
graph = app.query_one("#live-activity")
graph.set_data(intervals[:3])
await pilot.pause()
render = app.query_one("#live-render")
await pilot.click(
"#live-render",
offset=(8, max(0, render.content_size.height // 2)),
)
await pilot.pause()
expected = IntervalIdentity(
intervals[0]["start_ts"], intervals[0]["end_ts"]
)
assert graph.selection.selected_live_interval == expected
assert not graph.selection.following_live
assert "12:03 UTC" in str(app.query_one("#live-readout").render())
+523 -14
View File
@@ -25,6 +25,7 @@ from fenris.store import init_store, SCHEMA_VERSION
from fenris.local_day import (
derive_local_day_summary,
persist_local_day,
record_local_activity_interval,
query_local_day_summary,
query_current_local_day,
LocalDaySummary,
@@ -135,13 +136,56 @@ class TestSchemaMigration:
assert count == 0
conn.close()
def test_v4_totals_keep_values_but_are_not_treated_as_local_precision(
self, tmp_path,
):
db = tmp_path / "v4.db"
conn = sqlite3.connect(db)
conn.execute(
"CREATE TABLE samples (id INTEGER PRIMARY KEY, ts TEXT, device TEXT)"
)
conn.execute(
"CREATE TABLE pending_publications "
"(id INTEGER PRIMARY KEY, sample_ts TEXT, payload TEXT)"
)
conn.execute(
"CREATE TABLE local_days ("
"id INTEGER PRIMARY KEY, local_date TEXT NOT NULL, tz_name TEXT NOT NULL, "
"tz_offset TEXT NOT NULL, utc_start TEXT NOT NULL, utc_end TEXT NOT NULL, "
"bytes_written INTEGER DEFAULT 0, bytes_read INTEGER DEFAULT 0, "
"coverage REAL DEFAULT 0, sample_count INTEGER DEFAULT 0, "
"complete BOOLEAN DEFAULT 0, UNIQUE(local_date, tz_name))"
)
conn.execute(
"INSERT INTO local_days VALUES "
"(1, '2026-09-01', 'UTC', '+00:00', "
"'2026-09-01T00:00:00+00:00', '2026-09-02T00:00:00+00:00', "
"5120000, 2048000, 0.5, 10, 0)"
)
conn.execute("PRAGMA user_version=4")
conn.commit()
conn.close()
conn = init_store(db)
stored = conn.execute(
"SELECT bytes_written, activity_precision FROM local_days"
).fetchone()
assert stored == (5_120_000, "unavailable")
visible = query_local_day_summary(conn, "2026-09-01", "UTC")
assert visible["bytes_written"] is None
assert visible["activity_state"] == "unavailable"
assert "local_tz" in {
row[1] for row in conn.execute("PRAGMA table_info(samples)")
}
conn.close()
# ---------------------------------------------------------------------------
# Local-day derivation from UTC hours
# Local-day coverage derivation from UTC hours
# ---------------------------------------------------------------------------
class TestDeriveLocalDay:
"""Derive local-day summaries from UTC hour observations."""
"""Derive coverage from UTC hours without allocating local-day bytes."""
def test_utc_plus_zero(self, tmp_path):
"""UTC timezone: local day boundaries = UTC day boundaries."""
@@ -158,8 +202,9 @@ class TestDeriveLocalDay:
assert summary.local_date == "2026-09-01"
assert summary.tz_name == "UTC"
assert summary.tz_offset == "+00:00"
assert summary.bytes_written == 300
assert summary.bytes_read == 130
assert summary.bytes_written == 0
assert summary.bytes_read == 0
assert summary.activity_precision == "unavailable"
assert summary.complete is False # not all 24 hours covered
conn.close()
@@ -179,12 +224,13 @@ class TestDeriveLocalDay:
assert summary.local_date == "2026-09-01"
assert summary.tz_name == "Asia/Kolkata"
assert summary.tz_offset == "+05:30"
assert summary.bytes_written == 300
assert summary.bytes_read == 130
assert summary.bytes_written == 0
assert summary.bytes_read == 0
assert summary.activity_precision == "unavailable"
conn.close()
def test_midnight_spanning_hour_included(self, tmp_path):
"""UTC hour straddling local midnight is included in the local day."""
def test_hourly_bytes_cannot_be_allocated_to_local_midnight(self, tmp_path):
"""UTC-hour totals do not establish exact local-day byte totals."""
conn = init_store(tmp_path / "obs.db")
# For UTC+5:30, local day 2026-09-01 starts at 2026-08-31T18:30 UTC
@@ -196,9 +242,9 @@ class TestDeriveLocalDay:
summary = derive_local_day_summary(conn, "Asia/Kolkata", clock)
assert summary is not None
# The midnight-spanning hour (18:00) is included
assert summary.bytes_written == 300
assert summary.bytes_read == 130
assert summary.bytes_written == 0
assert summary.bytes_read == 0
assert summary.activity_precision == "unavailable"
conn.close()
def test_no_hours_returns_none(self, tmp_path):
@@ -387,6 +433,414 @@ class TestCollectorIntegration:
assert row[1] == 30 * 512000 # reads
conn.close()
def test_interval_crossing_utc_hour_stays_wholly_in_local_day(
self, tmp_path, sysfs_tree, monkeypatch,
):
"""A measured interval inside one local date keeps its full delta."""
from fenris.collector import run_collection
monkeypatch.setenv("TZ", "Asia/Kolkata")
store = str(tmp_path / "obs.db")
cfg = {"device": "/dev/nvme0", "store_path": store}
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
first_at = datetime(2026, 9, 1, 12, 59, tzinfo=timezone.utc)
second_at = datetime(2026, 9, 1, 13, 1, tzinfo=timezone.utc)
first = run_collection(
_make_smartctl(10_000_000, 8_000_000),
sysfs_nvme,
cfg,
_Clock(first_at),
)
second = run_collection(
_make_smartctl(10_000_010, 8_000_004),
sysfs_nvme,
cfg,
_Clock(second_at),
)
assert first["ok"] and second["ok"]
conn = sqlite3.connect(store)
summary = query_local_day_summary(conn, "2026-09-01")
assert summary is not None
assert summary["bytes_written"] == 5_120_000
assert summary["bytes_read"] == 2_048_000
assert summary["segments"] == [{
"segment_id": 1,
"bytes_written": 5_120_000,
"bytes_read": 2_048_000,
"activity_seconds": 120,
"activity_intervals": 1,
}]
segment_totals = conn.execute(
"SELECT segment_id, bytes_written, bytes_read, activity_intervals "
"FROM local_day_segment_totals"
).fetchone()
assert segment_totals == (1, 5_120_000, 2_048_000, 1)
samples = conn.execute(
"SELECT id, ts, bytes_written, bytes_read, local_tz "
"FROM samples ORDER BY id"
).fetchall()
first_sample, second_sample = samples
assert record_local_activity_interval(
conn,
dict(zip(("id", "ts", "bytes_written", "bytes_read", "local_tz"), first_sample)),
dict(zip(("id", "ts", "bytes_written", "bytes_read", "local_tz"), second_sample)),
start_sample_id=first_sample[0],
end_sample_id=second_sample[0],
segment_id=1,
) == "known"
repeated = query_local_day_summary(conn, "2026-09-01", "Asia/Kolkata")
assert repeated["bytes_written"] == 5_120_000
assert repeated["bytes_read"] == 2_048_000
assert conn.execute(
"SELECT SUM(bytes_written), SUM(bytes_read), SUM(activity_intervals) "
"FROM local_day_segment_totals"
).fetchone() == (5_120_000, 2_048_000, 1)
conn.close()
@pytest.mark.parametrize(
(
"first_at", "second_at", "local_date", "expected_hours",
"expected_start", "expected_end", "expected_offset",
),
[
(
datetime(2026, 3, 8, 6, 30, tzinfo=timezone.utc),
datetime(2026, 3, 8, 7, 30, tzinfo=timezone.utc),
"2026-03-08", 23,
"2026-03-08T05:00:00+00:00",
"2026-03-09T04:00:00+00:00", "-05:00",
),
(
datetime(2026, 11, 1, 5, 30, tzinfo=timezone.utc),
datetime(2026, 11, 1, 6, 30, tzinfo=timezone.utc),
"2026-11-01", 25,
"2026-11-01T04:00:00+00:00",
"2026-11-02T05:00:00+00:00", "-04:00",
),
],
ids=("spring-forward", "fall-back-repeated-hour"),
)
def test_collector_retains_exact_interval_on_dst_local_day(
self,
tmp_path,
sysfs_tree,
monkeypatch,
first_at,
second_at,
local_date,
expected_hours,
expected_start,
expected_end,
expected_offset,
):
"""Collection preserves exact activity on short and long local days."""
from fenris.collector import run_collection
zone = "America/New_York"
monkeypatch.setenv("TZ", zone)
store = str(tmp_path / "obs.db")
cfg = {"device": "/dev/nvme0", "store_path": store}
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
assert run_collection(
_make_smartctl(10_000_000, 8_000_000),
sysfs_nvme,
cfg,
_Clock(first_at),
)["ok"]
assert run_collection(
_make_smartctl(10_000_001, 8_000_002),
sysfs_nvme,
cfg,
_Clock(second_at),
)["ok"]
conn = sqlite3.connect(store)
summary = query_local_day_summary(
conn, local_date, zone, second_at + timedelta(minutes=1)
)
assert summary is not None
assert summary["bytes_written"] == 512_000
assert summary["bytes_read"] == 1_024_000
assert summary["activity_seconds"] == 3_600
assert summary["utc_start"] == expected_start
assert summary["utc_end"] == expected_end
assert summary["tz_offset"] == expected_offset
assert (
datetime.fromisoformat(summary["utc_end"])
- datetime.fromisoformat(summary["utc_start"])
).total_seconds() == expected_hours * 3_600
conn.close()
@pytest.mark.asyncio
async def test_local_midnight_interval_is_shared_once(
self, tmp_path, sysfs_tree, monkeypatch,
):
"""A real counter interval crossing local midnight stays unallocated."""
from fenris.collector import run_collection
monkeypatch.setenv("TZ", "Asia/Kolkata")
store = str(tmp_path / "obs.db")
cfg = {"device": "/dev/nvme0", "store_path": store}
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
first_at = datetime(2026, 9, 1, 18, 25, tzinfo=timezone.utc)
second_at = datetime(2026, 9, 1, 18, 35, tzinfo=timezone.utc)
first = run_collection(
_make_smartctl(10_000_000, 8_000_000),
sysfs_nvme,
cfg,
_Clock(first_at),
)
second = run_collection(
_make_smartctl(10_000_100, 8_000_060),
sysfs_nvme,
cfg,
_Clock(second_at),
)
assert first["ok"] and second["ok"]
conn = sqlite3.connect(store)
evidence = conn.execute(
"SELECT COUNT(*), SUM(bytes_written), SUM(bytes_read) "
"FROM local_day_unallocated_evidence"
).fetchone()
assert evidence == (1, 51_200_000, 30_720_000)
assert conn.execute(
"SELECT SUM(bytes_written), SUM(bytes_read) FROM local_days"
).fetchone() == (0, 0)
for local_date in ("2026-09-01", "2026-09-02"):
summary = query_local_day_summary(
conn, local_date, "Asia/Kolkata", second_at
)
assert summary is not None
assert summary["bytes_written"] is None
assert summary["shared_bytes_written"] == 51_200_000
assert summary["shared_bytes_read"] == 30_720_000
assert summary["activity_state"] in ("incomplete", "so_far")
conn.close()
from fenris.activity_readout import selected_local_day
from fenris.status import read_status
from fenris.tui import FenrisTuiApp
app = FenrisTuiApp(store_path=Path(store), refresh_interval_s=999)
async with app.run_test(size=(100, 32)):
app._browse_date = "2026-09-02"
with read_status(
Path(store), second_at, query_services=False
) as (reader, _):
assert reader is not None
app._render_local_day(*selected_local_day(
reader, app._activity_selection, second_at,
))
visible = str(app.query_one("#local-day").render())
assert "2026-09-02" in visible
assert "Asia/Kolkata +05:30" in visible
assert "W unavailable" in visible
assert "shared at midnight W 0.051 GB" in visible
def test_synthetic_shared_bytes_conserve_one_hundred_bytes(self, tmp_path):
"""Shared evidence conserves byte values before display rounding."""
conn = init_store(tmp_path / "obs.db")
first_at = datetime(2026, 9, 1, 18, 25, tzinfo=timezone.utc)
second_at = datetime(2026, 9, 3, 18, 35, tzinfo=timezone.utc)
ensure_period_open(conn, first_at)
conn.execute(
"INSERT INTO samples (ts, device, bytes_written, bytes_read, local_tz) "
"VALUES (?, ?, ?, ?, ?)",
(first_at.isoformat(), "/dev/nvme0", 1_000, 2_000, "Asia/Kolkata"),
)
first_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
conn.execute(
"INSERT INTO samples (ts, device, bytes_written, bytes_read, local_tz) "
"VALUES (?, ?, ?, ?, ?)",
(second_at.isoformat(), "/dev/nvme0", 1_100, 2_050, "Asia/Kolkata"),
)
second_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
result = record_local_activity_interval(
conn,
{
"id": first_id,
"ts": first_at.isoformat(),
"bytes_written": 1_000,
"bytes_read": 2_000,
"local_tz": "Asia/Kolkata",
},
{
"id": second_id,
"ts": second_at.isoformat(),
"bytes_written": 1_100,
"bytes_read": 2_050,
"local_tz": "Asia/Kolkata",
},
start_sample_id=first_id,
end_sample_id=second_id,
segment_id=1,
)
assert result == "local_midnight"
stored = conn.execute(
"SELECT COUNT(*), SUM(bytes_written), SUM(bytes_read) "
"FROM local_day_unallocated_evidence"
).fetchone()
assert stored == (1, 100, 50)
record_local_activity_interval(
conn,
{
"id": first_id,
"ts": first_at.isoformat(),
"bytes_written": 1_000,
"bytes_read": 2_000,
"local_tz": "Asia/Kolkata",
},
{
"id": second_id,
"ts": second_at.isoformat(),
"bytes_written": 1_100,
"bytes_read": 2_050,
"local_tz": "Asia/Kolkata",
},
start_sample_id=first_id,
end_sample_id=second_id,
segment_id=1,
)
assert conn.execute(
"SELECT COUNT(*), SUM(bytes_written), SUM(bytes_read) "
"FROM local_day_unallocated_evidence"
).fetchone() == (1, 100, 50)
assert conn.execute(
"SELECT SUM(bytes_written), SUM(bytes_read) FROM local_days"
).fetchone() == (0, 0)
middle_day = query_local_day_summary(
conn, "2026-09-03", "Asia/Kolkata", second_at + timedelta(days=1)
)
assert middle_day["shared_bytes_written"] == 100
assert middle_day["shared_bytes_read"] == 50
assert middle_day["shared_evidence_count"] == 1
conn.close()
def test_timezone_change_keeps_interval_unallocated(
self, tmp_path, sysfs_tree, monkeypatch,
):
from fenris.collector import run_collection
store = str(tmp_path / "obs.db")
cfg = {"device": "/dev/nvme0", "store_path": store}
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
first_at = datetime(2026, 9, 1, 22, 0, tzinfo=timezone.utc)
second_at = datetime(2026, 9, 1, 22, 5, tzinfo=timezone.utc)
monkeypatch.setenv("TZ", "UTC")
assert run_collection(
_make_smartctl(10_000_000, 8_000_000),
sysfs_nvme,
cfg,
_Clock(first_at),
)["ok"]
monkeypatch.setenv("TZ", "Asia/Kolkata")
assert run_collection(
_make_smartctl(10_000_010, 8_000_004),
sysfs_nvme,
cfg,
_Clock(second_at),
)["ok"]
conn = sqlite3.connect(store)
event = conn.execute(
"SELECT COUNT(*), SUM(bytes_written), MIN(reason) "
"FROM local_day_unallocated_evidence"
).fetchone()
assert event == (1, 5_120_000, "timezone_change")
old_zone = query_local_day_summary(conn, "2026-09-01", "UTC")
new_zone = query_local_day_summary(conn, "2026-09-02", "Asia/Kolkata")
assert old_zone["bytes_written"] is None
assert new_zone["bytes_written"] is None
assert old_zone["unallocated_bytes_written"] == 5_120_000
assert new_zone["unallocated_bytes_written"] == 5_120_000
conn.close()
def test_pause_boundary_keeps_interval_unallocated(
self, tmp_path, sysfs_tree, monkeypatch,
):
from fenris.collector import run_collection
monkeypatch.setenv("TZ", "UTC")
store = str(tmp_path / "obs.db")
cfg = {"device": "/dev/nvme0", "store_path": store}
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
first_at = datetime(2026, 9, 1, 12, 0, tzinfo=timezone.utc)
second_at = datetime(2026, 9, 1, 12, 10, tzinfo=timezone.utc)
assert run_collection(
_make_smartctl(10_000_000, 8_000_000),
sysfs_nvme,
cfg,
_Clock(first_at),
)["ok"]
conn = sqlite3.connect(store)
close_period(conn, first_at + timedelta(minutes=5), "user_disabled")
conn.close()
assert run_collection(
_make_smartctl(10_000_010, 8_000_004),
sysfs_nvme,
cfg,
_Clock(second_at),
)["ok"]
conn = sqlite3.connect(store)
reason = conn.execute(
"SELECT reason FROM local_day_unallocated_evidence"
).fetchone()
summary = query_local_day_summary(conn, "2026-09-01", "UTC")
assert reason == ("monitoring_period",)
assert summary["bytes_written"] is None
assert summary["unallocated_bytes_written"] == 5_120_000
assert summary["shared_bytes_written"] == 0
conn.close()
def test_controller_segment_boundary_marks_local_activity_incomplete(
self, tmp_path, sysfs_tree, monkeypatch,
):
from fenris.collector import run_collection
monkeypatch.setenv("TZ", "UTC")
store = str(tmp_path / "obs.db")
cfg = {"device": "/dev/nvme0", "store_path": store}
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
first_at = datetime(2026, 9, 1, 12, 0, tzinfo=timezone.utc)
second_at = datetime(2026, 9, 1, 12, 5, tzinfo=timezone.utc)
assert run_collection(
_make_smartctl(10_000_000, 8_000_000),
sysfs_nvme,
cfg,
_Clock(first_at),
)["ok"]
assert run_collection(
_make_smartctl(10, 8_000_004),
sysfs_nvme,
cfg,
_Clock(second_at),
)["ok"]
conn = sqlite3.connect(store)
summary = query_local_day_summary(
conn, "2026-09-01", "UTC", second_at
)
assert summary is not None
assert summary["bytes_written"] is None
assert summary["activity_state"] == "incomplete"
assert conn.execute(
"SELECT COUNT(*) FROM local_day_unallocated_evidence"
).fetchone()[0] == 0
conn.close()
# ---------------------------------------------------------------------------
# DST handling (23/25-hour days)
@@ -402,17 +856,72 @@ class TestDSTHandling:
# Simulate a 23-hour day in a timezone with DST
# For simplicity, just verify the summary records the correct UTC range
clock = datetime(2026, 3, 8, 12, 0, 0, tzinfo=timezone.utc)
# US/Eastern springs forward on 2026-03-08
# New York springs forward on 2026-03-08
# Local day 2026-03-08 is 23 hours: UTC [07:00, 06:00+1d)
_insert_hour(conn, "2026-03-08T08:00:00+00:00", bw=100, active=3600)
_insert_hour(conn, "2026-03-08T12:00:00+00:00", bw=200, active=3600)
summary = derive_local_day_summary(conn, "US/Eastern", clock)
summary = derive_local_day_summary(conn, "America/New_York", clock)
assert summary is not None
assert summary.local_date == "2026-03-08"
# UTC range should be approximately 23 hours
utc_start = datetime.fromisoformat(summary.utc_start)
utc_end = datetime.fromisoformat(summary.utc_end)
duration = (utc_end - utc_start).total_seconds()
assert 22 * 3600 <= duration <= 24 * 3600 # ~23h ± tolerance
assert duration == 23 * 3600
conn.close()
def test_long_day_25_hours(self):
from fenris.local_day import local_day_boundaries
start, end, offset = local_day_boundaries(
"2026-11-01", "America/New_York"
)
assert (end - start).total_seconds() == 25 * 3600
assert offset == "-04:00"
def test_half_hour_offset_records_exact_local_boundaries(self):
from fenris.local_day import local_day_boundaries
start, end, offset = local_day_boundaries(
"2026-09-01", "Asia/Kolkata"
)
assert start.isoformat() == "2026-08-31T18:30:00+00:00"
assert end.isoformat() == "2026-09-01T18:30:00+00:00"
assert offset == "+05:30"
def test_repeated_local_clock_labels_remain_one_recorded_day(
self, tmp_path, sysfs_tree, monkeypatch,
):
from fenris.collector import run_collection
monkeypatch.setenv("TZ", "America/New_York")
store = str(tmp_path / "obs.db")
cfg = {"device": "/dev/nvme0", "store_path": store}
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
# Both endpoints display as 01:30 locally, on opposite UTC offsets.
first_at = datetime(2026, 11, 1, 5, 30, tzinfo=timezone.utc)
second_at = datetime(2026, 11, 1, 6, 30, tzinfo=timezone.utc)
assert run_collection(
_make_smartctl(10_000_000, 8_000_000),
sysfs_nvme,
cfg,
_Clock(first_at),
)["ok"]
assert run_collection(
_make_smartctl(10_000_001, 8_000_002),
sysfs_nvme,
cfg,
_Clock(second_at),
)["ok"]
conn = sqlite3.connect(store)
summary = query_local_day_summary(
conn, "2026-11-01", "America/New_York", second_at
)
assert summary is not None
assert summary["bytes_written"] == 512_000
assert summary["bytes_read"] == 1_024_000
assert summary["utc_start"] == "2026-11-01T04:00:00+00:00"
assert summary["utc_end"] == "2026-11-02T05:00:00+00:00"
conn.close()
+1 -5
View File
@@ -21,10 +21,8 @@ import sys
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from fenris.collector import run_collection
from fenris.store import init_store
from fenris.day_aggregate import derive_day
from fenris.monitoring_periods import ensure_period_open
from fenris.tui import _query_daily_graph_data, _query_hourly_graph_data
from fenris.activity_readout import _query_daily_graph_data, _query_hourly_graph_data
# ---------------------------------------------------------------------------
@@ -247,7 +245,6 @@ class TestCrossHourAttribution:
for day in ("2026-09-01",):
agg = derive_day(conn, day)
assert agg is not None
total_accounted = agg.bytes_written_delta + agg.bytes_read_delta
unattributed_w = conn.execute(
"SELECT unattributed_bytes_written FROM day_aggregates WHERE day = ?",
(day,)
@@ -483,7 +480,6 @@ class TestCrossDayBoundary:
# The cross-day delta is unattributed at the day level
sep1 = day_map["2026-09-01"]
sep2 = day_map["2026-09-02"]
# Both days may show the unattributed bytes
# (the delta is added to both days' unattributed totals as evidence)
total_w = sep1["allocated_bytes"] + sep1["unallocated_bytes"]
+25 -7
View File
@@ -3,7 +3,6 @@
Spec: §8.4, §8.5, §8.6, §8.7
"""
import json
import sqlite3
from datetime import datetime, timezone
from pathlib import Path
from unittest.mock import patch, MagicMock
@@ -56,7 +55,7 @@ class TestEnableIdempotentMatrix:
"""A fresh package install has a store directory but no database yet."""
args = MagicMock(now=False, store_path=store_path)
with patch("fenris.monitor.enable_timer") as mock_enable:
with patch("fenris.monitor.enable_timer"):
cmd_enable(args)
conn = init_store(store_path)
@@ -73,7 +72,7 @@ class TestEnableIdempotentMatrix:
args = MagicMock(now=False, store_path=store_path)
with patch("fenris.monitor.enable_timer") as mock_enable:
with patch("fenris.monitor.enable_timer"):
cmd_enable(args)
# Period should be open
@@ -84,6 +83,25 @@ class TestEnableIdempotentMatrix:
assert cursor.fetchone() is not None
conn.close()
def test_enable_reports_period_start_in_local_time(
self, store_path, monkeypatch, capsys,
):
"""The opened-at message is local time with its timezone label."""
monkeypatch.setenv("TZ", "Asia/Kolkata")
args = MagicMock(now=True, store_path=store_path)
conn = init_store(store_path)
conn.execute("DELETE FROM monitoring_periods")
conn.commit()
conn.close()
with patch("fenris.monitor.enable_timer"):
cmd_enable(args)
out = capsys.readouterr().out
assert "Monitoring period opened at" in out
assert "Asia/Kolkata +05:30" in out
assert "+00:00" not in out
def test_resume_with_open_period_noop(self, store_path):
"""Resume with open period: no-op (gap stays inside as unknown)."""
# Initialize store and open a period
@@ -98,7 +116,7 @@ class TestEnableIdempotentMatrix:
args = MagicMock(now=True, store_path=store_path)
with patch("fenris.monitor.enable_timer") as mock_enable:
with patch("fenris.monitor.enable_timer"):
cmd_enable(args)
# Should still have exactly one open period
@@ -122,7 +140,7 @@ class TestEnableIdempotentMatrix:
args = MagicMock(now=True, store_path=store_path)
with patch("fenris.monitor.enable_timer") as mock_enable:
with patch("fenris.monitor.enable_timer"):
cmd_enable(args)
# Should have a new open period
@@ -151,7 +169,7 @@ class TestDisableIdempotentMatrix:
args = MagicMock(now=True, store_path=store_path)
with patch("fenris.monitor.disable_timer") as mock_disable:
with patch("fenris.monitor.disable_timer"):
cmd_disable(args)
# Period should be closed with user_disabled
@@ -169,7 +187,7 @@ class TestDisableIdempotentMatrix:
args = MagicMock(now=True, store_path=store_path)
with patch("fenris.monitor.disable_timer") as mock_disable:
with patch("fenris.monitor.disable_timer"):
cmd_disable(args)
# No periods should exist
+1 -2
View File
@@ -6,9 +6,8 @@ From spec §5.2, §8.6, §9.8:
- Powered-off time stays inside a period; disabled time does not
- End causes: user_disabled, migrated, unknown_gap
"""
import sqlite3
import sys
from datetime import datetime, timezone, timedelta
from datetime import datetime, timezone
from pathlib import Path
import pytest
+43
View File
@@ -0,0 +1,43 @@
"""Package directory permissions must not inherit a builder's private umask."""
import os
import shutil
import subprocess
import sys
from pathlib import Path
import pytest
@pytest.mark.parametrize("mask", ["077", "022"])
def test_stage_is_publicly_traversable_under_any_builder_umask(tmp_path, mask):
root = Path(__file__).resolve().parent.parent
for directory in ("packaging", "scripts", "src", "units", "polkit"):
shutil.copytree(root / directory, tmp_path / directory,
ignore=shutil.ignore_patterns("__pycache__", "*.egg-info"))
for filename in ("LICENSE", "requirements.txt"):
shutil.copy(root / filename, tmp_path / filename)
(tmp_path / "dist").mkdir()
(tmp_path / "dist/fenris-9.9.9-py3-none-any.whl").touch()
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
# Avoid downloading dependencies: emulate pip's target output while
# exercising the real staging script and its package directory layout.
pip_stub = bin_dir / "python3"
pip_stub.write_text(
f"#!{sys.executable}\n"
"import sys\nfrom pathlib import Path\n"
"target = Path(sys.argv[sys.argv.index('--target') + 1]) / 'fenris'\n"
"target.mkdir(parents=True)\n"
"(target / '__init__.py').write_text('')\n"
)
pip_stub.chmod(0o755)
subprocess.run(
["bash", "-c", f"umask {mask}; exec bash packaging/stage.sh 9.9.9"],
cwd=tmp_path, env={**os.environ, "PATH": str(bin_dir) + os.pathsep + os.environ["PATH"]},
check=True, capture_output=True, text=True,
)
stage = tmp_path / "build/stage"
for directory in (stage, *(p for p in stage.rglob("*") if p.is_dir())):
assert directory.stat().st_mode & 0o777 == 0o755, directory
assert (stage / "usr/bin/fenris").stat().st_mode & 0o777 == 0o755
assert (stage / "opt/fenris/vendor/fenris/__init__.py").stat().st_mode & 0o444 == 0o444
+9 -13
View File
@@ -3,18 +3,18 @@
Covers:
- Preference load/save with safe defaults
- XDG_CONFIG_HOME user-scoped persistence
- Amber default theme, normal-motion default
- Chalktone 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
from datetime import datetime, timezone
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"))
@@ -72,9 +72,9 @@ class TestPreferencePath:
class TestDefaults:
"""When no preference file exists, defaults are returned."""
def test_default_theme_is_amber(self):
def test_default_theme_is_chalktone(self):
"""Amber is the default theme preset."""
assert DEFAULT_THEME == "amber"
assert DEFAULT_THEME == "chalktone"
def test_default_reduced_motion_is_false(self):
"""Normal motion is the default."""
@@ -82,13 +82,13 @@ class TestDefaults:
def test_valid_themes_are_all_presets(self):
"""Three valid presets exist."""
assert VALID_THEMES == {"amber", "nord", "high_contrast"}
assert VALID_THEMES == {"chalktone", "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["theme"] == "chalktone"
assert prefs["reduced_motion"] is False
@@ -151,7 +151,7 @@ class TestSafeFailures:
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
prefs = load_preferences()
assert prefs["theme"] == "amber"
assert prefs["theme"] == "chalktone"
assert prefs["reduced_motion"] is False
def test_unknown_theme_returns_default(self, tmp_path):
@@ -165,7 +165,7 @@ class TestSafeFailures:
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
prefs = load_preferences()
assert prefs["theme"] == "amber"
assert prefs["theme"] == "chalktone"
def test_missing_keys_get_defaults(self, tmp_path):
"""Partial preference file fills in missing keys."""
@@ -218,7 +218,7 @@ class TestSafeFailures:
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
prefs = load_preferences()
assert prefs["theme"] == "amber"
assert prefs["theme"] == "chalktone"
def test_wrong_type_for_reduced_motion(self, tmp_path):
"""Non-boolean reduced_motion falls back to default."""
@@ -295,7 +295,3 @@ class TestCLIIsolation:
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
+124 -40
View File
@@ -9,7 +9,6 @@ Covers acceptance criteria:
- PR-13: Baseline provenance and validation per register
- PR-17: Arithmetic exactly E_rated = TBW * 10^12, E_implied = 100*W/p, projected = max(E-W,0)/rate
"""
import sqlite3
from datetime import datetime, timedelta, timezone
from pathlib import Path
@@ -18,11 +17,10 @@ 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.monitoring_periods import ensure_period_open
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,
TBW_TO_BYTES, DISCLOSURES,
)
@@ -74,12 +72,13 @@ def _insert_day(conn, day, bw=1024*1024*100, coverage=0.95, samples=24):
conn.commit()
def _insert_sample(conn, ts, pu=5):
def _insert_sample(conn, ts, pu=5, bytes_written=512000000000, segment_id=1):
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),
"percentage_used, bytes_written, bytes_read, power_on_hours, segment_id) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(ts, "/dev/nvme0n1", bytes_written // 512000, 500000, pu, bytes_written,
256000000000, 8765, segment_id),
)
conn.commit()
@@ -100,8 +99,8 @@ def _insert_local_day(conn, local_date, tz_name="UTC", tz_offset="+00:00",
conn.execute(
"INSERT INTO local_days "
"(local_date, tz_name, tz_offset, utc_start, utc_end, "
" bytes_written, bytes_read, coverage, sample_count, complete) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
" bytes_written, bytes_read, coverage, sample_count, complete, activity_intervals) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)",
(local_date, tz_name, tz_offset, utc_start, utc_end,
bw, br, coverage, samples, complete),
)
@@ -223,6 +222,88 @@ class TestImpliedBaseline:
result = compute_projection(store, _clock())
assert result.baseline_tier == BaselineTier.VERIFIED
def _setup_implied(self, store, pus):
"""No override baseline; one sample per PU value inside the segment."""
_insert_segment(store)
_open_period(store)
for i in range(20):
d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, d, bw=1024*1024*100)
for i, pu in enumerate(pus):
_insert_sample(store, "2026-09-%02dT10:00:00+00:00" % (20 + i), pu=pu)
_insert_complete_local_days(store, "2026-09-29", 1)
@pytest.mark.parametrize("pus", [[5], [5, 5, 5], [5, 6]])
def test_implied_unavailable_before_two_increments(self, store, pus):
self._setup_implied(store, pus)
result = compute_projection(store, _clock())
assert result.confidence_state == ConfidenceState.UNSUPPORTED
assert result.baseline_tier == BaselineTier.NONE
assert result.headline_remaining_seconds is None
assert "vendor wear estimate too coarse to imply endurance" in result.contributing_facts
def test_implied_unavailable_ignores_increments_in_other_segments(self, store):
_insert_segment(store, opened_at="2026-09-01T00:00:00+00:00")
_insert_segment(store, opened_at="2026-09-15T00:00:00+00:00", identity_key="nqn.new")
_open_period(store)
for i in range(20):
d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, d, bw=1024*1024*100)
# Two increments in the old segment, none in the current one
_insert_sample(store, "2026-09-02T10:00:00+00:00", pu=3, segment_id=1)
_insert_sample(store, "2026-09-03T10:00:00+00:00", pu=4, segment_id=1)
_insert_sample(store, "2026-09-20T10:00:00+00:00", pu=5, segment_id=2)
_insert_complete_local_days(store, "2026-09-29", 1)
result = compute_projection(store, _clock())
assert result.baseline_tier == BaselineTier.NONE
assert "vendor wear estimate too coarse to imply endurance" in result.contributing_facts
def test_implied_unavailable_when_pu_out_of_range(self, store):
self._setup_implied(store, [0, 1, 2])
store.execute("UPDATE samples SET percentage_used = 255 WHERE id = 3")
store.commit()
result = compute_projection(store, _clock())
assert result.baseline_tier == BaselineTier.NONE
assert "vendor wear estimate too coarse to imply endurance" in result.contributing_facts
def test_implied_eligible_after_two_increments(self, store):
self._setup_implied(store, [5, 6, 7])
W_t = 512000000000
result = compute_projection(store, _clock())
assert result.baseline_tier == BaselineTier.IMPLIED
# Implied baseline is never Supported-eligible
assert result.confidence_state == ConfidenceState.LIMITED
assert "vendor wear estimate too coarse to imply endurance" not in result.contributing_facts
E_implied = 100 * W_t / 7
regime_bytes = 20 * 1024 * 1024 * 100
regime_start = datetime(2026, 9, 10, 0, 0, 0, tzinfo=timezone.utc)
rate = regime_bytes / int((_clock() - regime_start).total_seconds())
assert abs(result.headline_remaining_seconds - max(E_implied - W_t, 0) / rate) < 1.0
def test_override_wins_over_implied(self, store):
_insert_baseline(store, tbw_tb=1.0, verified=True)
self._setup_implied(store, [5, 6, 7])
result = compute_projection(store, _clock())
assert result.baseline_tier == BaselineTier.VERIFIED
class TestHorizonAgreement:
def test_single_horizon_passes_agreement(self, store):
"""ADR 0002 §8: agreement is judged across existing horizons; one passes."""
_insert_baseline(store, tbw_tb=1.0, verified=True)
_insert_segment(store)
_open_period(store)
# 14 days covers only the 7-day horizon
for i in range(14):
d = (datetime(2026, 9, 17) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, d, bw=1024*1024*100)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
_insert_complete_local_days(store, "2026-09-29", 1)
result = compute_projection(store, _clock())
assert list(result.scenario_range.rates) == [7]
assert not any("regime only" in f for f in result.contributing_facts)
assert result.confidence_state == ConfidenceState.SUPPORTED
class TestZeroRate:
def test_zero_rate_fixed_phrase(self, store):
@@ -315,16 +396,16 @@ class TestArithmetic:
_insert_day(store, d, bw=1024*1024*100)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
result = compute_projection(store, _clock())
if result.headline_remaining_seconds is not None:
E_rated = 1.0 * TBW_TO_BYTES
regime_bytes = 30 * 1024 * 1024 * 100
# Actual wall-clock: Sep 1 00:00 -> Sep 30 12:00 = 29.5 days
period_start = datetime(2026, 9, 1, 0, 0, 0, tzinfo=timezone.utc)
period_end = _clock()
actual_wc = int((period_end - period_start).total_seconds())
rate = regime_bytes / actual_wc
expected = max(E_rated - regime_bytes, 0) / rate
assert abs(result.headline_remaining_seconds - expected) < 1.0
E_rated = 1.0 * TBW_TO_BYTES
W_t = 512000000000 # lifetime bytes written of the newest published sample
regime_bytes = 30 * 1024 * 1024 * 100
# Actual wall-clock: Sep 1 00:00 -> Sep 30 12:00 = 29.5 days
period_start = datetime(2026, 9, 1, 0, 0, 0, tzinfo=timezone.utc)
actual_wc = int((_clock() - period_start).total_seconds())
rate = regime_bytes / actual_wc
expected = max(E_rated - W_t, 0) / rate
assert result.headline_remaining_seconds is not None
assert abs(result.headline_remaining_seconds - expected) < 1.0
def test_implied_baseline_formula(self, store):
W_t = 1024 * 1024 * 1000
@@ -339,17 +420,19 @@ class TestArithmetic:
for i in range(30):
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, d, bw=1024*1024*100)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
W_t = 512000000000 # lifetime written bytes of the newest sample
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5, bytes_written=W_t)
_insert_complete_local_days(store, "2026-09-29", 1)
result = compute_projection(store, _clock())
if result.headline_remaining_seconds is not None:
E_rated = 2.0 * TBW_TO_BYTES
regime_bytes = 30 * 1024 * 1024 * 100
period_start = datetime(2026, 9, 1, 0, 0, 0, tzinfo=timezone.utc)
period_end = _clock()
actual_wc = int((period_end - period_start).total_seconds())
rate = regime_bytes / actual_wc
expected = max(E_rated - regime_bytes, 0) / rate
assert abs(result.headline_remaining_seconds - expected) < 1.0
assert result.headline_remaining_seconds is not None
E_rated = 2.0 * TBW_TO_BYTES
regime_bytes = 30 * 1024 * 1024 * 100
period_start = datetime(2026, 9, 1, 0, 0, 0, tzinfo=timezone.utc)
actual_wc = int((_clock() - period_start).total_seconds())
rate = regime_bytes / actual_wc
# W_t is lifetime written bytes, not the regime delta
expected = max(E_rated - W_t, 0) / rate
assert abs(result.headline_remaining_seconds - expected) < 1.0
def test_wearing_rate_proportional(self, store):
_insert_baseline(store, tbw_tb=1.0, verified=True)
@@ -359,6 +442,7 @@ class TestArithmetic:
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, d, bw=1024*1024*100)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
_insert_complete_local_days(store, "2026-09-29", 1)
r_slow = compute_projection(store, _clock())
store.execute("DELETE FROM day_aggregates")
@@ -368,9 +452,9 @@ class TestArithmetic:
_insert_day(store, d, bw=2*1024*1024*100)
r_fast = compute_projection(store, _clock())
if r_slow.headline_remaining_seconds is not None and r_fast.headline_remaining_seconds is not None:
assert r_fast.headline_remaining_seconds < r_slow.headline_remaining_seconds
assert r_slow.headline_remaining_seconds is not None
assert r_fast.headline_remaining_seconds is not None
assert r_fast.headline_remaining_seconds < r_slow.headline_remaining_seconds
# ===========================================================================
@@ -394,17 +478,19 @@ class TestSustainedRegimeRate:
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)
W_t = 512000000000
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5, bytes_written=W_t)
_insert_complete_local_days(store, "2026-09-29", 1)
result = compute_projection(store, _clock())
# Regime = full 30 days; rate = 30*bw / wall-clock
regime_bytes = 30 * bw
period_start = datetime(2026, 9, 1, 0, 0, 0, tzinfo=timezone.utc)
wc = int((_clock() - period_start).total_seconds())
expected_rate = regime_bytes / wc
if result.headline_remaining_seconds is not None:
E = 10.0 * TBW_TO_BYTES
expected_seconds = max(E - regime_bytes, 0) / expected_rate
assert abs(result.headline_remaining_seconds - expected_seconds) < 1.0
assert result.headline_remaining_seconds is not None
E = 10.0 * TBW_TO_BYTES
expected_seconds = max(E - W_t, 0) / expected_rate
assert abs(result.headline_remaining_seconds - expected_seconds) < 1.0
def test_regime_capped_at_90_days(self, store):
"""Default regime is full history capped at 90 days."""
@@ -1039,8 +1125,6 @@ class TestEvidenceAnchoredHorizons:
# 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):
+19 -18
View File
@@ -5,7 +5,6 @@ day aggregates are retained indefinitely.
Issue #74: Boundary anchors required for successor evidence are retained.
"""
import sqlite3
import sys
from datetime import datetime, timezone, timedelta
from pathlib import Path
@@ -27,9 +26,14 @@ def store_conn(tmp_path: Path):
def _insert_sample(conn, ts_iso, device="/dev/nvme0"):
conn.execute(
"INSERT OR IGNORE INTO controller_segments "
"(id, opened_at, identity_key) VALUES (1, '2026-01-01T00:00:00+00:00', 'test')"
)
conn.execute(
"INSERT INTO samples (ts, device, data_units_written, data_units_read, "
" bytes_written, bytes_read, percentage_used) VALUES (?, ?, 0, 0, 0, 0, 0)",
" bytes_written, bytes_read, percentage_used, segment_id, local_tz) "
"VALUES (?, ?, 0, 0, 0, 0, 0, 1, 'UTC')",
(ts_iso, device),
)
conn.commit()
@@ -50,33 +54,30 @@ class TestPruneOldSamples:
cursor = store_conn.execute("SELECT COUNT(*) FROM samples")
assert cursor.fetchone()[0] == 1
def test_removes_old_samples(self, store_conn):
def test_keeps_old_samples_without_replacement_evidence(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
# Old samples stay until UTC and local-day evidence replace them.
for days_ago in [10, 14, 15]:
ts = (now - timedelta(days=days_ago)).isoformat()
_insert_sample(store_conn, ts)
pruned = prune_old_samples(store_conn, now, retention_days=14)
assert pruned == 1 # Only the 15-day-old sample removed
assert pruned == 0
cursor = store_conn.execute("SELECT COUNT(*) FROM samples")
assert cursor.fetchone()[0] == 2
assert cursor.fetchone()[0] == 3
def test_removes_many_old_samples(self, store_conn):
def test_keeps_many_old_samples_without_replacement_evidence(self, store_conn):
now = datetime(2026, 9, 15, 12, 0, 0, tzinfo=timezone.utc)
for days_ago in range(1, 30):
ts = (now - timedelta(days=days_ago)).isoformat()
_insert_sample(store_conn, ts)
pruned = prune_old_samples(store_conn, now, retention_days=14)
assert pruned == 15 # Days 15-29 removed
assert pruned == 0
cursor = store_conn.execute("SELECT COUNT(*) FROM samples")
assert cursor.fetchone()[0] == 14 # Days 1-14 kept
assert cursor.fetchone()[0] == 29
def test_empty_store_no_error(self, store_conn):
now = datetime(2026, 9, 15, 12, 0, 0, tzinfo=timezone.utc)
@@ -122,7 +123,7 @@ class TestPruneOldSamples:
_insert_sample(store_conn, "2026-09-16T12:30:00+00:00", 2000000)
# Run pruning
pruned = prune_old_samples(store_conn, now, retention_days=14)
prune_old_samples(store_conn, now, retention_days=14)
# The boundary anchor should be retained
cursor = store_conn.execute(
@@ -130,8 +131,8 @@ class TestPruneOldSamples:
)
assert cursor.fetchone()[0] == 1
def test_old_sample_with_derived_interval_removed(self, store_conn):
"""Old samples with fully derived intervals are removed."""
def test_hour_rows_alone_do_not_replace_local_day_evidence(self, store_conn):
"""An hour row alone cannot authorize source-sample deletion."""
now = datetime(2026, 9, 30, 12, 0, 0, tzinfo=timezone.utc)
# Old sample with derived interval
@@ -146,10 +147,10 @@ class TestPruneOldSamples:
store_conn.commit()
# Run pruning
pruned = prune_old_samples(store_conn, now, retention_days=14)
prune_old_samples(store_conn, now, retention_days=14)
# Old sample should be removed (interval is derived)
# Keep the source samples until local-day replacement evidence exists.
cursor = store_conn.execute(
"SELECT COUNT(*) FROM samples WHERE ts = '2026-09-10T10:00:00+00:00'"
)
assert cursor.fetchone()[0] == 0
assert cursor.fetchone()[0] == 1
+1 -3
View File
@@ -58,7 +58,7 @@ def _rpm_filename(version: str, release: int = 1) -> str:
def _registry_upload_deb_url(version: str) -> str:
"""Expected registry upload URL for a deb package."""
return f"debian/pool/bookworm/main/upload"
return "debian/pool/bookworm/main/upload"
def _registry_upload_rpm_url() -> str:
@@ -258,8 +258,6 @@ class TestRevisionBumping:
"""When revision is bumped, the deb filename also changes."""
version = _get_version()
# Deb filename includes release in nfpm naming
deb_r1 = f"fenris_{version}_amd64.deb"
deb_r2 = f"fenris_{version}_amd64.deb"
# For deb, the filename doesn't change with revision (deb uses epoch)
# But the RPM does — this verifies we test RPM revision correctly
rpm_r1 = _rpm_filename(version, 1)
+13 -16
View File
@@ -4,9 +4,8 @@ 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 datetime import datetime, timezone
from pathlib import Path
import pytest
@@ -102,7 +101,6 @@ class TestRepairUsesSameEvidenceRules:
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
@@ -138,7 +136,7 @@ class TestRepairUsesSameEvidenceRules:
store_conn.commit()
# Run repair
result = repair_derivation(store_conn)
repair_derivation(store_conn)
# Verify marker preserved
cursor = store_conn.execute(
@@ -235,8 +233,8 @@ class TestBoundaryAnchorRetention:
# 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."""
def test_hour_only_derivation_does_not_replace_local_day_evidence(self, store_conn):
"""UTC hour evidence alone does not make a sample safe to prune."""
now = datetime(2026, 9, 30, 12, 0, 0, tzinfo=timezone.utc)
# Insert sample and fully derive its interval
@@ -247,8 +245,7 @@ class TestBoundaryAnchorRetention:
_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)
assert 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."""
@@ -266,7 +263,7 @@ class TestBoundaryAnchorRetention:
_insert_sample(store_conn, "2026-09-29T12:00:00+00:00", 3000000)
# Run pruning
pruned = prune_old_samples(store_conn, now, retention_days=14)
prune_old_samples(store_conn, now, retention_days=14)
# The boundary anchor should be retained
cursor = store_conn.execute(
@@ -274,8 +271,8 @@ class TestBoundaryAnchorRetention:
)
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."""
def test_pruning_keeps_samples_without_local_day_replacement(self, store_conn):
"""Pruning keeps samples when only the UTC-hour row exists."""
now = datetime(2026, 9, 30, 12, 0, 0, tzinfo=timezone.utc)
# Old sample with derived interval
@@ -287,13 +284,13 @@ class TestBoundaryAnchorRetention:
bytes_written_delta=1000000)
# Run pruning
pruned = prune_old_samples(store_conn, now, retention_days=14)
prune_old_samples(store_conn, now, retention_days=14)
# Old sample should be removed (interval is derived)
# The source remains until local-day evidence is also durable.
cursor = store_conn.execute(
"SELECT COUNT(*) FROM samples WHERE ts = '2026-09-10T10:00:00+00:00'"
)
assert cursor.fetchone()[0] == 0
assert cursor.fetchone()[0] == 1
# ---------------------------------------------------------------------------
@@ -310,7 +307,7 @@ class TestLegacySummaryPrecision:
bytes_written_delta=5000000)
# Run repair
result = repair_derivation(store_conn)
repair_derivation(store_conn)
# Should not create hour observations for legacy day
cursor = store_conn.execute(
@@ -325,7 +322,7 @@ class TestLegacySummaryPrecision:
bytes_written_delta=5000000)
# Run repair
result = repair_derivation(store_conn)
repair_derivation(store_conn)
# Day aggregate should not be modified
cursor = store_conn.execute(
+2 -2
View File
@@ -259,8 +259,8 @@ class TestKeyCeremonyDoc:
def test_documents_private_key_storage(self):
content = self._doc_content()
assert "password manager" in content.lower(), \
"Must document that private key lives in password manager"
assert "gitea repository actions secret `gpg_private_key`" in content.lower(), \
"Must document that Gitea Actions stores the private signing key"
# ---------------------------------------------------------------------------
+221
View File
@@ -0,0 +1,221 @@
"""Staged projection with an evidence ladder (issue #106, ADR 0012).
Every gate is exercised through compute_projection() against SQLite
fixtures built by seed_evidence(): N hours or days of complete evidence
written as hour observations, then derived into day aggregates the same
way a collection run does.
"""
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.day_aggregate import derive_all_days, persist_day_aggregate
from fenris.monitoring_periods import ensure_period_open
from fenris.projection import (
BaselineTier, ConfidenceState, ProjectionStage, compute_projection,
)
from fenris.store import init_store
START = datetime(2026, 9, 1, 0, 0, 0, tzinfo=timezone.utc)
HOUR_BYTES = 10 * 1024 ** 3
MODEL = "Samsung SSD 970 EVO Plus 1TB"
@pytest.fixture
def store(tmp_path):
conn = init_store(tmp_path / "test.db")
yield conn
conn.close()
def seed_evidence(conn, hours, baseline="verified", start=START, degraded=False):
"""Seed *hours* hours of complete evidence and return the clock.
Monitoring opens at *start*; each hour is fully sampled and active.
The returned clock is the end of the last observed hour.
"""
conn.execute(
"INSERT INTO controller_segments "
"(opened_at, identity_key, identity_degraded, subnqn, sn, mn, fr, vid, ssvid, transport) "
"VALUES (?, ?, ?, 'nqn.test', 'SN1', ?, 'FW1', '0x144d', '0x144d', 'pcie')",
(start.isoformat(), "" if degraded else "nqn.test", degraded, MODEL),
)
if baseline:
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 (600, ?, 'v1', '2026-01-01', ?, 1024000000000, ?, ?, "
" '2026-01-01T00:00:00+00:00', '2026-01-01T00:00:00+00:00')",
("https://example.com/spec" if baseline == "verified" else None, MODEL,
"machine_match" if baseline == "verified" else None,
baseline == "verified"),
)
ensure_period_open(conn, start)
for h in range(hours):
hour = start + timedelta(hours=h)
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 (?, 3600, 0, 0, 0, ?, 0, 1, 1.0)",
(hour.strftime("%Y-%m-%dT%H:00:00Z"), HOUR_BYTES),
)
written = HOUR_BYTES * (h + 1)
conn.execute(
"INSERT INTO samples (ts, device, data_units_written, data_units_read, "
"percentage_used, bytes_written, bytes_read, power_on_hours, segment_id) "
"VALUES (?, '/dev/nvme0n1', ?, 0, 1, ?, 0, 100, 1)",
((hour + timedelta(hours=1)).isoformat(), written // 512000, written),
)
for aggregate in derive_all_days(conn):
persist_day_aggregate(conn, aggregate)
conn.commit()
return start + timedelta(hours=hours)
class TestHourStages:
def test_empty_store_has_no_observations(self, store):
result = compute_projection(store, START)
assert result.stage == ProjectionStage.NO_OBSERVATIONS
assert result.headline_remaining_seconds is None
def test_two_hours_is_too_few_for_a_number(self, store):
clock = seed_evidence(store, 2)
result = compute_projection(store, clock)
assert result.stage == ProjectionStage.NO_OBSERVATIONS
assert result.headline_remaining_seconds is None
assert result.hours_observed == 2
def test_three_hours_is_provisional_with_a_number(self, store):
clock = seed_evidence(store, 3)
result = compute_projection(store, clock)
assert result.stage == ProjectionStage.PROVISIONAL
assert result.headline_remaining_seconds is not None
assert result.headline_remaining_seconds > 0
assert result.hours_observed == 3
assert "3 hours observed" in result.contributing_facts
assert "daily cycle not yet seen" in result.contributing_facts
def test_three_hours_shows_short_horizon_spread(self, store):
clock = seed_evidence(store, 3)
result = compute_projection(store, clock)
assert result.scenario_range is not None
assert set(result.scenario_range.rates) <= {1, 3}
assert 1 in result.scenario_range.rates
def test_provisional_rate_is_the_observed_write_rate(self, store):
clock = seed_evidence(store, 3)
result = compute_projection(store, clock)
assert result.write_rate == pytest.approx(HOUR_BYTES / 3600, rel=0.01)
def test_complete_local_day_is_a_fact_not_a_gate(self, store):
clock = seed_evidence(store, 3)
result = compute_projection(store, clock)
assert result.headline_remaining_seconds is not None
assert "waiting for a full local observation day" in result.contributing_facts
def test_provisional_until_24_hours_then_warming(self, store):
clock = seed_evidence(store, 23)
assert compute_projection(store, clock).stage == ProjectionStage.PROVISIONAL
def test_twenty_four_hours_is_warming(self, store):
clock = seed_evidence(store, 24)
result = compute_projection(store, clock)
assert result.stage == ProjectionStage.WARMING
assert "daily cycle not yet seen" not in result.contributing_facts
class TestDayStages:
def test_warming_until_fourteen_qualifying_days(self, store):
clock = seed_evidence(store, 24 * 13)
assert compute_projection(store, clock).stage == ProjectionStage.WARMING
def test_fourteen_days_with_verified_baseline_is_supported(self, store):
clock = seed_evidence(store, 24 * 14)
result = compute_projection(store, clock)
assert result.stage == ProjectionStage.SUPPORTED
assert result.confidence_state == ConfidenceState.SUPPORTED
def test_unverified_baseline_is_limited_after_warm_up(self, store):
clock = seed_evidence(store, 24 * 14, baseline="unverified")
result = compute_projection(store, clock)
assert result.stage == ProjectionStage.LIMITED
assert result.baseline_tier == BaselineTier.UNVERIFIED
def test_degraded_identity_is_limited(self, store):
clock = seed_evidence(store, 24 * 14, degraded=True)
assert compute_projection(store, clock).stage == ProjectionStage.LIMITED
def test_stale_history_is_limited(self, store):
clock = seed_evidence(store, 24 * 14) + timedelta(days=4)
assert compute_projection(store, clock).stage == ProjectionStage.LIMITED
class TestNoBaseline:
def test_write_rate_replaces_lifespan(self, store):
clock = seed_evidence(store, 3, baseline=None)
result = compute_projection(store, clock)
assert result.stage == ProjectionStage.UNAVAILABLE
assert result.headline_remaining_seconds is None
assert result.write_rate == pytest.approx(HOUR_BYTES / 3600, rel=0.01)
def test_no_baseline_is_not_synthesised(self, store):
clock = seed_evidence(store, 24 * 14, baseline=None)
result = compute_projection(store, clock)
assert result.baseline_tier == BaselineTier.NONE
assert result.headline_remaining_seconds is None
assert result.write_rate is not None
class TestShortHorizons:
def test_short_horizons_appear_once_covered(self, store):
clock = seed_evidence(store, 24 * 2)
rates = compute_projection(store, clock).scenario_range.rates
assert 1 in rates
assert 3 not in rates
def test_three_day_horizon_covered_at_four_days(self, store):
clock = seed_evidence(store, 24 * 4)
rates = compute_projection(store, clock).scenario_range.rates
assert {1, 3} <= set(rates)
def test_short_horizons_drop_when_seven_day_covered(self, store):
clock = seed_evidence(store, 24 * 9)
rates = compute_projection(store, clock).scenario_range.rates
assert 7 in rates
assert 1 not in rates and 3 not in rates
class TestEvidenceLadder:
def test_ladder_lists_each_supported_condition_with_reason(self, store):
clock = seed_evidence(store, 3)
ladder = compute_projection(store, clock).evidence_ladder
assert len(ladder) == 8
assert all(c.reason for c in ladder)
assert all(isinstance(c.met, bool) for c in ladder)
def test_ladder_all_met_when_supported(self, store):
clock = seed_evidence(store, 24 * 14)
result = compute_projection(store, clock)
assert result.ladder_met == result.ladder_total == 8
def test_ladder_count_rises_as_evidence_accrues(self, store, tmp_path):
counts = []
for hours in (3, 24 * 2, 24 * 9, 24 * 14):
conn = init_store(tmp_path / ("ladder-%d.db" % hours))
clock = seed_evidence(conn, hours)
counts.append(compute_projection(conn, clock).ladder_met)
conn.close()
assert counts == sorted(counts)
assert counts[0] < counts[-1]
assert len(set(counts)) >= 3
def test_unmet_condition_names_its_reason(self, store):
clock = seed_evidence(store, 3)
unmet = [c for c in compute_projection(store, clock).evidence_ladder if not c.met]
assert any("qualifying days" in c.reason for c in unmet)
+112
View File
@@ -0,0 +1,112 @@
"""CLI and TUI render the projection stage, never pre-gate it (issue #106).
Seams: get_status() and the TUI headline band. Both must show what
compute_projection() reports: the stage, the ladder count, and the full
ladder in the outlook; no caller decides what the projection can show.
"""
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
from unittest.mock import patch
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
sys.path.insert(0, str(Path(__file__).parent))
from fenris.status import get_status
from fenris.store import init_store
from fenris.tui import FenrisTuiApp
from test_staged_projection import seed_evidence
SERVICES = {
"boot_enabled": False, "timer_active": False,
"last_collect_ok": None, "last_collect_age_s": None,
"last_collect_reason": None,
}
def _status(db, clock):
with patch("fenris.status.query_service_state", return_value=SERVICES):
return get_status(store_path=db, clock_now=clock,
query_services=False, query_journal=False)
@pytest.fixture
def db(tmp_path):
return tmp_path / "observations.db"
class TestCliStatus:
def test_provisional_stage_ladder_count_and_full_ladder(self, db):
conn = init_store(db)
clock = seed_evidence(conn, 3)
conn.close()
out = _status(db, clock)
assert "Provisional" in out
assert "3 hours observed" in out
assert "daily cycle not yet seen" in out
assert "remaining" in out
assert "conditions met" in out
assert "Evidence ladder" in out
assert "[ ] 14 qualifying days" in out
assert "[x] verified baseline" in out
def test_single_sample_is_not_pre_gated(self, db):
conn = init_store(db)
seed_evidence(conn, 1)
conn.close()
out = _status(db, datetime(2026, 9, 1, 1, 0, tzinfo=timezone.utc))
assert "requires at least two samples" not in out
assert "1 of 3 hours observed" in out
def test_no_baseline_shows_write_rate_not_lifespan(self, db):
conn = init_store(db)
clock = seed_evidence(conn, 3, baseline=None)
conn.close()
out = _status(db, clock)
assert "write rate" in out.lower()
assert "GB/day" in out
def test_supported_stage(self, db):
conn = init_store(db)
clock = seed_evidence(conn, 24 * 14)
conn.close()
out = _status(db, clock)
assert "Supported" in out
assert "8 of 8 conditions met" in out
class TestTuiHeadline:
@pytest.mark.asyncio
async def test_provisional_headline_and_ladder(self, db):
now = datetime.now(timezone.utc).replace(minute=0, second=0, microsecond=0)
conn = init_store(db)
seed_evidence(conn, 3, start=now - timedelta(hours=3))
conn.close()
with patch("fenris.status.query_service_state", return_value=SERVICES):
app = FenrisTuiApp(store_path=db)
async with app.run_test(size=(120, 50)):
text = str(app.query_one("#headline-band").render())
assert "Provisional" in text
assert "3 hours observed" in text
assert "conditions met" in text
assert "Evidence ladder" in text
@pytest.mark.asyncio
async def test_single_sample_is_not_pre_gated(self, db):
now = datetime.now(timezone.utc).replace(minute=0, second=0, microsecond=0)
conn = init_store(db)
seed_evidence(conn, 1, start=now - timedelta(hours=1))
conn.close()
with patch("fenris.status.query_service_state", return_value=SERVICES):
app = FenrisTuiApp(store_path=db)
async with app.run_test(size=(120, 50)):
text = str(app.query_one("#headline-band").render())
assert "at least two samples" not in text
def test_callers_carry_no_projection_pre_gate():
root = Path(__file__).parent.parent / "src" / "fenris"
for name in ("tui.py", "status.py"):
assert "at least two samples" not in (root / name).read_text()
+3 -5
View File
@@ -11,13 +11,11 @@ Covers:
- Retired command rejection with migration pointers
- Configuration error surfaced from direct reads
"""
import os
import sqlite3
import sys
import tempfile
from datetime import datetime, timedelta, timezone
from pathlib import Path
from unittest.mock import patch, MagicMock
from unittest.mock import patch
import pytest
@@ -25,7 +23,6 @@ sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from fenris.status import (
grade_freshness,
freshness_age_human,
format_disclosures,
check_retired_command,
check_retired_flag,
@@ -35,7 +32,8 @@ from fenris.status import (
ACCURACY_SEC,
)
from fenris.store import init_store, SCHEMA_VERSION
from fenris.projection import DISCLOSURES, ConfidenceState
from fenris.projection import DISCLOSURES
from fenris.timefmt import freshness_age_human
# ---------------------------------------------------------------------------
+5 -11
View File
@@ -10,10 +10,9 @@ Covers:
- AC78-5: Separate freshness, last outcome, boot enablement, activity facts
- AC78-6: 5s poll reevaluates freshness from cached clock
"""
import sqlite3
from datetime import datetime, timedelta, timezone
from pathlib import Path
from unittest.mock import patch, MagicMock
from unittest.mock import patch
import pytest
import sys
@@ -23,12 +22,9 @@ sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from fenris.store import init_store
from fenris.status_composition import (
StatusState,
StatusComposition,
compose_status,
render_status_cli,
render_status_tui,
FRESH_THRESHOLD_S,
STALENESS_THRESHOLD_S,
)
@@ -682,7 +678,6 @@ class TestStoreFaultSuppression:
conn.close()
def test_newer_schema_suppresses_projection(self, tmp_path):
import fenris.store as store_mod
conn = init_store(tmp_path / "test.db")
now = _clock()
@@ -957,8 +952,7 @@ class TestTUIIntegration:
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
_open_period(conn)
now = _clock()
ts = (now - timedelta(minutes=2)).isoformat()
ts = (datetime.now(timezone.utc) - 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")
@@ -971,7 +965,7 @@ class TestTUIIntegration:
"last_collect_ok": True, "last_collect_age_s": 120,
"last_collect_reason": None,
}):
async with app.run_test() as pilot:
async with app.run_test():
strip = str(app.query_one("#service-strip").render())
assert "●" in strip
assert "Monitoring" in strip or "monitoring" in strip.lower()
@@ -997,7 +991,7 @@ class TestTUIIntegration:
"last_collect_ok": None, "last_collect_age_s": None,
"last_collect_reason": None,
}):
async with app.run_test() as pilot:
async with app.run_test():
strip = str(app.query_one("#service-strip").render())
# Should show Paused state
assert "‖" in strip or "Paused" in strip or "paused" in strip.lower()
@@ -1024,7 +1018,7 @@ class TestTUIIntegration:
"last_collect_ok": False, "last_collect_age_s": 60,
"last_collect_reason": "exit code 3",
}):
async with app.run_test() as pilot:
async with app.run_test():
strip = str(app.query_one("#service-strip").render())
# Should show Error state
assert "✖" in strip or "Error" in strip or "error" in strip.lower()
+1 -1
View File
@@ -67,7 +67,7 @@ async def test_cli_tui_acquisition_parity(tmp_path, kind, service):
assert not app.query_one("#main-grid").has_class("paused")
await pilot.resize_terminal(60, 18)
await pilot.pause()
assert phrase in str(app.query_one("#constrained-summary").render()).lower()
assert phrase in str(app.query_one("#live-render").render()).lower()
for text in (cli, strip):
assert ("boot: enabled" if service == ACTIVE else "boot: disabled" if service == DISABLED else "boot: unknown") in text
assert ("timer: active" if service == ACTIVE else "timer: inactive" if service == DISABLED else "timer: unknown") in text
+1 -2
View File
@@ -6,7 +6,6 @@ instead of degrading to the Store fault view; (2) even group members could
not open the WAL-mode store because root-created sidecars lacked group write
and the store directory lacked group execute-then-write.
"""
import sqlite3
import sys
from pathlib import Path
@@ -14,7 +13,7 @@ import pytest
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from fenris.store import DEFAULT_STORE_PATH, init_store
from fenris.store import init_store
from fenris.status import StoreFault, open_store_readonly
+9 -10
View File
@@ -2,12 +2,11 @@
Covers:
- Three valid presets: Amber, Nord, High Contrast
- Amber is the default with amber graph role
- Chalktone is the default; Amber retains its 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
@@ -29,13 +28,13 @@ from fenris.themes import (
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_four_themes_registered(self):
"""All four presets are registered."""
assert len(THEMES) == 4
def test_theme_names(self):
"""Theme names are amber, nord, high_contrast."""
assert THEME_NAMES == {"amber", "nord", "high_contrast"}
assert THEME_NAMES == {"chalktone", "amber", "nord", "high_contrast"}
def test_get_theme_valid(self):
"""get_theme returns a Theme for each valid name."""
@@ -44,14 +43,14 @@ class TestThemeRegistry:
theme = get_theme(name)
assert isinstance(theme, Theme)
def test_get_theme_invalid_returns_amber(self):
def test_get_theme_invalid_returns_chalktone(self):
"""Unknown theme name returns the amber theme."""
theme = get_theme("nonexistent")
assert theme.name == "fenris-amber"
assert theme.name == "fenris-chalktone"
# ---------------------------------------------------------------------------
# Amber theme tests (default, amber graph role)
# Amber theme tests (amber graph role)
# ---------------------------------------------------------------------------
class TestAmberTheme:
@@ -179,7 +178,7 @@ class TestGraphColorRoles:
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."""
"""Zero-usage markers use a dim/subtle colour in all themes."""
for name in THEME_NAMES:
colors = get_graph_colors(name)
# Zero should be distinct from allocated
+112 -115
View File
@@ -13,46 +13,34 @@ import sqlite3
from xml.etree import ElementTree
from datetime import datetime, timedelta, timezone
from pathlib import Path
from unittest.mock import patch, MagicMock
from unittest.mock import patch
import pytest
import sys
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from textual.app import App
from textual.pilot import Pilot
from fenris.store import init_store, SCHEMA_VERSION
from fenris.monitoring_periods import ensure_period_open, close_period
from fenris.store import init_store
from fenris.monitoring_periods import ensure_period_open
from fenris.projection import (
ConfidenceState,
compute_projection,
DISCLOSURES,
WARMING_MIN_DAYS,
STALENESS_HOURS,
YOUNG_REGIME_DAYS,
)
from fenris.status import (
FRESH_THRESHOLD_S,
STALENESS_THRESHOLD_S,
grade_freshness,
)
from fenris.activity_readout import (
_query_daily_graph_data,
_query_drive_health,
_query_hourly_graph_data,
)
from fenris.tui import (
FenrisTuiApp,
DailyBarGraph,
HistoryGraph,
_format_remaining,
_query_drive_health,
_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,
)
@@ -66,6 +54,13 @@ def _clock(year=2026, month=9, day=30, hour=12):
return datetime(year, month, day, hour, 0, 0, tzinfo=timezone.utc)
class _FrozenTuiDateTime(datetime):
@classmethod
def now(cls, tz=None):
instant = datetime(2026, 9, 28, 12, 0, 0, tzinfo=timezone.utc)
return instant.replace(tzinfo=None) if tz is None else instant.astimezone(tz)
def _insert_baseline(conn, tbw_tb=1.0, verified=True,
model="Samsung SSD 970 EVO Plus 1TB"):
conn.execute(
@@ -106,8 +101,8 @@ def _insert_day(conn, day, bw=1024*1024*100, coverage=0.95, samples=24):
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 (?, ?, ?, ?, ?, ?, ?, ?)",
"percentage_used, bytes_written, bytes_read, power_on_hours, segment_id) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1)",
(ts, device, 1000000, 500000, pu, 512000000000, 256000000000, 8765),
)
conn.commit()
@@ -115,6 +110,7 @@ def _insert_sample(conn, ts, pu=5, device="/dev/nvme0n1"):
def _open_period(conn, start="2026-09-01T00:00:00+00:00"):
ensure_period_open(conn, datetime.fromisoformat(start))
conn.commit()
def _insert_local_day(conn, local_date, tz_name="UTC", tz_offset="+00:00",
@@ -129,8 +125,8 @@ def _insert_local_day(conn, local_date, tz_name="UTC", tz_offset="+00:00",
conn.execute(
"INSERT INTO local_days "
"(local_date, tz_name, tz_offset, utc_start, utc_end, "
" bytes_written, bytes_read, coverage, sample_count, complete) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
" bytes_written, bytes_read, coverage, sample_count, complete, activity_intervals) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)",
(local_date, tz_name, tz_offset, utc_start, utc_end,
bw, br, coverage, samples, complete),
)
@@ -281,7 +277,7 @@ class TestDenseScreen:
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test() as pilot:
async with app.run_test():
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
@@ -301,7 +297,7 @@ class TestDenseScreen:
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test() as pilot:
async with app.run_test():
headline = str(app.query_one("#headline-band").render())
assert "remaining" in headline.lower() or "projection" in headline.lower()
@@ -319,7 +315,7 @@ class TestDenseScreen:
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test() as pilot:
async with app.run_test():
headline = str(app.query_one("#headline-band").render())
assert "projection confidence" in headline.lower()
# Contributing facts shown, never a percentage as confidence
@@ -341,7 +337,7 @@ class TestDenseScreen:
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test() as pilot:
async with app.run_test():
strip = str(app.query_one("#service-strip").render())
assert "Boot:" in strip
assert "Timer:" in strip
@@ -365,15 +361,15 @@ class TestDenseScreen:
}):
async with app.run_test():
strip = str(app.query_one("#service-strip").render()).lower()
rail = str(app.query_one("#quit-rail").render())
usage = app.query_one("#usage-history")
rail = str(app.query_one("#action-rail").render())
usage = app.query_one("#activity-panel")
service = app.query_one("#service-strip")
quit_rail = app.query_one("#quit-rail")
quit_rail = app.query_one("#action-rail")
assert "continuity" in strip
assert "monitoring: active in background · persists across reboots" in strip
assert "r resume — enable monitoring and future boots" in strip
assert "r Resume" in str(app.query_one("#action-rail").render())
assert "q quit" not in strip
assert rail == "q Quit TUI"
assert "q Quit TUI" in rail
assert usage.region.y < service.region.y < quit_rail.region.y
assert usage.region.bottom <= service.region.y
assert service.region.bottom <= quit_rail.region.y
@@ -405,13 +401,12 @@ class TestDenseScreen:
main_grid = app.query_one("#main-grid")
assert str(main_grid.styles.layout) == "<grid>"
assert main_grid.has_class("paused")
assert len(main_grid.styles.grid_rows) == 6
banner = str(paused_banner.render()).lower()
assert "monitoring: paused — deliberate disable" in banner
assert "paused time is excluded from your usage habit · resume: fenris monitor resume" in banner
assert paused_banner.region.height >= 5
assert paused_banner.region.y < app.query_one("#usage-history").region.y
assert app.query_one("#usage-history").region.bottom <= app.query_one(
assert paused_banner.region.height >= 2
assert paused_banner.region.y < app.query_one("#activity-panel").region.y
assert app.query_one("#activity-panel").region.bottom <= app.query_one(
"#service-strip"
).region.y
screenshot = app.export_screenshot()
@@ -423,11 +418,6 @@ class TestDenseScreen:
assert "monitoring: does not start on next boot" in str(
app.query_one("#service-strip").render()
).lower()
dashboard_scroll = app.query_one("#dashboard-scroll")
assert dashboard_scroll.max_scroll_y > 0
dashboard_scroll.focus()
await pilot.press("end")
assert dashboard_scroll.scroll_y == dashboard_scroll.max_scroll_y
footer_text = " ".join(
"".join(
ElementTree.fromstring(app.export_screenshot()).itertext()
@@ -488,25 +478,24 @@ class TestDenseScreen:
auth_notice = "Open with fenris (no sudo). Actions authenticate via polkit. ? Help"
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 auth_notice in headline
assert "Fenris by Bongbetic" in str(app.query_one("#identity").render())
assert auth_notice in str(app.query_one("#auth-notice").render())
# Maker credit removed from service strip (issue #79)
assert "by Bongbetic" not 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())
assert not app.query_one("#auth-notice").display
await pilot.pause(0.25)
assert auth_notice not in str(app.query_one("#headline-band").render())
assert not app.query_one("#auth-notice").display
fresh_app = FenrisTuiApp(
store_path=tmp_path / "nonexistent.db",
refresh_interval_s=0.2,
)
async with fresh_app.run_test():
assert auth_notice in str(fresh_app.query_one("#headline-band").render())
assert auth_notice in str(fresh_app.query_one("#auth-notice").render())
# ---------------------------------------------------------------------------
@@ -530,7 +519,7 @@ class TestDisclosuresAndGreeting:
async def test_empty_store_greeting(self, tmp_path):
"""Empty store shows 'no observations yet' with enable hint."""
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app.run_test() as pilot:
async with app.run_test():
headline = str(app.query_one("#headline-band").render())
assert "no observations yet" in headline.lower()
assert "enable" in headline.lower() or "resume" in headline.lower()
@@ -595,12 +584,11 @@ class TestFirstRun:
async def test_first_run_prompt(self, tmp_path):
"""First-run prompt makes the keyboard action and boot effect explicit."""
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app.run_test() as pilot:
async with app.run_test():
headline = str(app.query_one("#headline-band").render())
strip = str(app.query_one("#service-strip").render())
assert "no observations yet" in headline.lower()
assert "r resume — enable monitoring and future boots" in headline.lower()
assert "r resume — enable monitoring and future boots" in strip.lower()
assert "r resume" in str(app.query_one("#action-rail").render()).lower()
# ---------------------------------------------------------------------------
@@ -864,15 +852,14 @@ class TestQueryHourlyGraphData:
# ---------------------------------------------------------------------------
# DailyBarGraph widget unit tests (issue #75)
# HistoryGraph widget unit tests (issue #75)
# ---------------------------------------------------------------------------
class TestDailyBarGraph:
class TestHistoryGraph:
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()
graph = HistoryGraph()
# Simulate setting empty data
from textual.app import App as TextualApp
class _TestApp(TextualApp):
@@ -884,14 +871,14 @@ class TestDailyBarGraph:
def test_range_default(self):
"""Default range is 14 days."""
graph = DailyBarGraph()
graph = HistoryGraph()
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()
graph = HistoryGraph()
data = [{"day": "2026-09-%02d" % d, "total_bytes": d * 100}
for d in range(1, 31)]
graph._all_day_data = data
@@ -902,7 +889,7 @@ class TestDailyBarGraph:
def test_range_all_fits(self):
"""When data fits within range, all days are shown."""
graph = DailyBarGraph()
graph = HistoryGraph()
data = [{"day": "2026-09-%02d" % d, "total_bytes": d * 100}
for d in range(1, 8)]
graph._all_day_data = data
@@ -911,7 +898,7 @@ class TestDailyBarGraph:
assert len(graph._day_data) == 7
def test_scale_includes_allocated_and_unallocated_writes(self):
graph = DailyBarGraph()
graph = HistoryGraph()
graph._all_day_data = [{
"day": "2026-09-16",
"total_bytes": 800,
@@ -927,7 +914,7 @@ class TestDailyBarGraph:
# ---------------------------------------------------------------------------
class TestBarGraphTUI:
"""Headless tests for the interactive bar graph."""
"""Headless tests for the interactive history plot."""
@pytest.mark.asyncio
async def test_graph_renders_with_data(self, tmp_path):
@@ -938,25 +925,25 @@ class TestBarGraphTUI:
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_local_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.press("v", "v")
graph = app.query_one("#usage-history")
assert isinstance(graph, DailyBarGraph)
assert isinstance(graph, HistoryGraph)
assert len(graph._day_data) > 0
# Legend should be visible
legend = str(app.query_one("#bar-legend").render())
assert "Alloc" in legend
assert "Unalloc" in legend
assert "Zero" in legend
range_label = str(app.query_one("#bar-range").render())
assert "14 days" in range_label
assert "UTC" in range_label
assert "14 local days" in range_label
plotted = str(app.query_one("#bar-render").render())
assert "Writes (" in plotted
assert "Day UTC" in plotted
assert "→" in plotted
assert "Writes (" in legend
assert "─" in plotted
@pytest.mark.asyncio
async def test_arrow_selection(self, tmp_path):
@@ -967,11 +954,13 @@ class TestBarGraphTUI:
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_local_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.press("v", "v")
graph = app.query_one("#usage-history")
app.set_focus(graph)
await pilot.pause()
@@ -1002,6 +991,7 @@ class TestBarGraphTUI:
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(80, 24)) as pilot:
await pilot.press("v", "v")
graph = app.query_one("#usage-history")
app.set_focus(graph)
await pilot.pause()
@@ -1030,7 +1020,7 @@ class TestBarGraphTUI:
assert len(graph._day_data) == 90
@pytest.mark.asyncio
async def test_readout_updates_on_selection(self, tmp_path):
async def test_readout_updates_on_selection(self, tmp_path, monkeypatch):
"""Readout shows selected day info."""
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
@@ -1038,11 +1028,14 @@ class TestBarGraphTUI:
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_local_day(conn, d, bw=1024*1024*100)
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
conn.close()
monkeypatch.setattr("fenris.tui.datetime", _FrozenTuiDateTime)
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(80, 24)) as pilot:
await pilot.press("v", "v")
graph = app.query_one("#usage-history")
app.set_focus(graph)
await pilot.pause()
@@ -1050,18 +1043,19 @@ class TestBarGraphTUI:
# Newest day is selected initially.
readout = str(app.query_one("#bar-readout").render())
assert "UTC" in readout
assert "GB" in readout
assert "totals so far" in readout
assert "W 0.105 GB known" in readout
# Moving left updates the selected-day readout.
await pilot.press("left")
await pilot.pause()
readout = str(app.query_one("#bar-readout").render())
assert "2026-09" in readout
assert "GB" in readout
assert "incomplete" in readout
assert "W 0.105 GB known" in readout
@pytest.mark.asyncio
async def test_hourly_drill_down_and_back(self, tmp_path):
async def test_hourly_drill_down_and_back(self, tmp_path, monkeypatch):
"""Enter drills into hourly view, Esc returns to daily."""
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
@@ -1069,6 +1063,7 @@ class TestBarGraphTUI:
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_local_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)
@@ -1077,45 +1072,45 @@ class TestBarGraphTUI:
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
conn.close()
monkeypatch.setattr("fenris.tui.datetime", _FrozenTuiDateTime)
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(80, 24)) as pilot:
await pilot.press("v", "v")
graph = app.query_one("#usage-history")
app.set_focus(graph)
await pilot.pause()
await pilot.pause()
# The newest day is selected automatically.
assert graph.selected_index == len(graph._day_data) - 1
assert graph.view_mode == "daily"
assert app.query_one("#activity-tabs").active == "view-history"
daily_readout = str(app.query_one("#bar-readout").render())
# 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
assert app.query_one("#activity-tabs").active == "view-day"
assert "UTC" in str(app.query_one("#bar-readout").render())
# Five-minute data refreshes retain the selected hour.
graph._hourly_selected = 12
selected_hour = graph._hour_data[12]["hour"]
# Inspection identity survives refresh through visible readout.
await pilot.press("left")
selected_hour = str(app.query_one("#bar-readout").render())
app._refresh()
await pilot.pause()
assert graph.view_mode == "hourly"
assert graph._hour_data[graph._hourly_selected]["hour"] == selected_hour
assert str(app.query_one("#bar-readout").render()) == selected_hour
# Esc returns to daily
await pilot.press("escape")
await pilot.pause()
assert graph.view_mode == "daily"
assert graph.drill_day is None
assert app.query_one("#activity-tabs").active == "view-history"
assert str(app.query_one("#bar-readout").render()) == daily_readout
@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:
await pilot.press("v", "v")
graph = app.query_one("#usage-history")
assert isinstance(graph, DailyBarGraph)
assert isinstance(graph, HistoryGraph)
render = str(app.query_one("#bar-render").render())
assert "awaiting" in render.lower()
@@ -1124,8 +1119,8 @@ class TestBarGraphTUI:
"""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"
await pilot.press("v", "v")
assert app.query_one("#activity-panel").border_title == "Drive activity"
@pytest.mark.asyncio
async def test_glyphs_in_legend(self, tmp_path):
@@ -1141,13 +1136,11 @@ class TestBarGraphTUI:
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(80, 24)) as pilot:
await pilot.press("v", "v")
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
for meaning in ("Writes", "Unalloc", "Gap", "Zero", "Partial"):
assert meaning in legend
# ---------------------------------------------------------------------------
@@ -1172,6 +1165,7 @@ class TestConstrainedLayout:
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(80, 24)) as pilot:
await pilot.press("v", "v")
# All four regions should be visible
assert app.query_one("#headline-band") is not None
assert app.query_one("#usage-history") is not None
@@ -1180,8 +1174,8 @@ class TestConstrainedLayout:
# 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"
summary = app.query_one("#bar-render")
assert "Graph needs" not in str(summary.render())
@pytest.mark.asyncio
async def test_constrained_below_80_width(self, tmp_path):
@@ -1198,13 +1192,14 @@ class TestConstrainedLayout:
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(79, 24)) as pilot:
await pilot.press("v", "v")
# 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 = app.query_one("#bar-render")
summary_text = str(summary.render())
assert "Graph needs ≥80×24" in summary_text
assert "days" in summary_text
@@ -1225,10 +1220,11 @@ class TestConstrainedLayout:
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(80, 23)) as pilot:
await pilot.press("v", "v")
# Should be in constrained mode
assert app._is_constrained_mode
# Constrained summary should be visible
summary = app.query_one("#constrained-summary")
summary = app.query_one("#bar-render")
summary_text = str(summary.render())
assert "Graph needs ≥80×24" in summary_text
@@ -1247,11 +1243,12 @@ class TestConstrainedLayout:
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(79, 24)) as pilot:
await pilot.press("v", "v")
# Start in constrained mode
assert app._is_constrained_mode
graph = app.query_one("#usage-history")
# Select a day while constrained
graph.selected_index = 5
# Select a day through the visible graph while constrained.
await pilot.press(*(["left"] * 8))
# Resize to normal — pilot.resize_terminal changes terminal size
await pilot.resize_terminal(80, 24)
@@ -1285,6 +1282,7 @@ class TestConstrainedLayout:
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(80, 24)) as pilot:
await pilot.press("v", "v")
# Start in normal mode
assert not app._is_constrained_mode
graph = app.query_one("#usage-history")
@@ -1303,7 +1301,7 @@ class TestConstrainedLayout:
# Should be in constrained mode
assert app._is_constrained_mode
# Constrained summary should show selected day context
summary = app.query_one("#constrained-summary")
summary = app.query_one("#bar-render")
summary_text = str(summary.render())
assert "Graph needs ≥80×24" in summary_text
# Selected day context should survive
@@ -1314,8 +1312,9 @@ class TestConstrainedLayout:
"""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:
await pilot.press("v", "v")
assert app._is_constrained_mode
summary = app.query_one("#constrained-summary")
summary = app.query_one("#bar-render")
summary_text = str(summary.render())
assert "Graph needs ≥80×24" in summary_text
assert "awaiting" in summary_text.lower()
@@ -1335,25 +1334,20 @@ class TestConstrainedLayout:
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(79, 24)) as pilot:
await pilot.press("v", "v")
# 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
assert app.query_one("#action-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
# Under 80x24 the footer collapses to help and quit (issue #110)
rail = str(app.query_one("#action-rail").render())
assert rail == "? help · q quit"
@pytest.mark.asyncio
async def test_constrained_long_reasons_visible(self, tmp_path):
@@ -1368,6 +1362,7 @@ class TestConstrainedLayout:
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(79, 24)) as pilot:
await pilot.press("v", "v")
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()
@@ -1387,6 +1382,7 @@ class TestConstrainedLayout:
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(79, 24)) as pilot:
await pilot.press("v", "v")
# Quit should still work
await pilot.press("q")
assert not app.is_running
@@ -1396,6 +1392,7 @@ class TestConstrainedLayout:
"""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:
await pilot.press("v", "v")
assert app._is_constrained_mode
# Toggle theme
await pilot.press("t")
@@ -1415,8 +1412,8 @@ class TestConstrainedLayout:
assert _MIN_HEIGHT == 24
def test_is_constrained_with_terminal_width(self, tmp_path):
"""DailyBarGraph._is_constrained checks terminal width."""
graph = DailyBarGraph()
"""HistoryGraph._is_constrained checks terminal width."""
graph = HistoryGraph()
# Widget-level check (no terminal width)
assert graph._is_constrained() is False
# Terminal width below minimum
+214
View File
@@ -0,0 +1,214 @@
"""Enter rated TBW from the TUI (issue #107, ADR 0012 §7).
Seam: the app's helper invocation (`_run_helper`), faked here the way the
other TUI tests fake it. The form builds an unverified override and hands it
to `fenris-monitor baseline set`; it never offers verified provenance.
"""
import json
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
sys.path.insert(0, str(Path(__file__).parent))
from fenris.monitor import cmd_baseline_set
from fenris.store import init_store
from fenris.tui import FenrisTuiApp
from test_staged_projection import seed_evidence
SERVICES = {
"boot_enabled": False, "timer_active": False,
"last_collect_ok": None, "last_collect_age_s": None,
"last_collect_reason": None,
}
@pytest.fixture
def db(tmp_path):
return tmp_path / "observations.db"
def _app(db):
return FenrisTuiApp(store_path=db)
def _seed_without_baseline(db):
now = datetime.now(timezone.utc).replace(minute=0, second=0, microsecond=0)
conn = init_store(db)
seed_evidence(conn, 3, baseline=None, start=now - timedelta(hours=3))
conn.close()
def _text(app, selector):
return str(app.query_one(selector).render())
async def _open_form(pilot):
await pilot.press("b")
await pilot.pause()
async def _fill(pilot, app, tbw, url=""):
app.screen.query_one("#tbw-input").value = tbw
app.screen.query_one("#source-input").value = url
await pilot.press("enter")
await pilot.pause()
class TestForm:
@pytest.mark.asyncio
async def test_b_opens_form_with_required_tbw_and_optional_source(self, db):
_seed_without_baseline(db)
with patch("fenris.status.query_service_state", return_value=SERVICES):
app = _app(db)
async with app.run_test(size=(120, 50)) as pilot:
await _open_form(pilot)
assert len(app.screen_stack) == 2
assert app.screen.query_one("#tbw-input")
assert app.screen.query_one("#source-input")
text = str(app.screen.query_one("#baseline-header").render())
assert "unverified" in text.lower()
@pytest.mark.asyncio
@pytest.mark.parametrize("value", ["", "abc", "0", "-5", "nan", "inf"])
async def test_invalid_tbw_is_rejected_without_calling_helper(self, db, value):
_seed_without_baseline(db)
with patch("fenris.status.query_service_state", return_value=SERVICES):
app = _app(db)
with patch.object(app, "_run_helper") as run_helper:
async with app.run_test(size=(120, 50)) as pilot:
await _open_form(pilot)
await _fill(pilot, app, value)
assert len(app.screen_stack) == 2
assert str(app.screen.query_one("#baseline-error").render()).strip()
run_helper.assert_not_called()
@pytest.mark.asyncio
async def test_invalid_source_url_is_rejected(self, db):
_seed_without_baseline(db)
with patch("fenris.status.query_service_state", return_value=SERVICES):
app = _app(db)
with patch.object(app, "_run_helper") as run_helper:
async with app.run_test(size=(120, 50)) as pilot:
await _open_form(pilot)
await _fill(pilot, app, "600", "not a url")
assert len(app.screen_stack) == 2
assert "url" in str(app.screen.query_one("#baseline-error").render()).lower()
run_helper.assert_not_called()
@pytest.mark.asyncio
async def test_escape_cancels_without_calling_helper(self, db):
_seed_without_baseline(db)
with patch("fenris.status.query_service_state", return_value=SERVICES):
app = _app(db)
with patch.object(app, "_run_helper") as run_helper:
async with app.run_test(size=(120, 50)) as pilot:
await _open_form(pilot)
await pilot.press("escape")
await pilot.pause()
assert len(app.screen_stack) == 1
run_helper.assert_not_called()
class TestSubmit:
@pytest.mark.asyncio
async def test_submit_routes_unverified_override_to_baseline_set(self, db):
_seed_without_baseline(db)
with patch("fenris.status.query_service_state", return_value=SERVICES):
app = _app(db)
with patch.object(app, "_run_helper", return_value=True) as run_helper:
async with app.run_test(size=(120, 50)) as pilot:
await _open_form(pilot)
await _fill(pilot, app, "600", "https://example.com/spec")
assert len(app.screen_stack) == 1
run_helper.assert_called_once()
operation, args = run_helper.call_args.args
assert operation == "baseline"
assert args[0] == "set"
payload = json.loads(args[1])
assert payload["tbw_terabytes"] == 600
assert payload["source_url"] == "https://example.com/spec"
assert payload["verified"] is False
assert payload["validated_by"] == "user"
# Incomplete provenance keeps the override unverified.
assert payload["document_revision"] is None
@pytest.mark.asyncio
async def test_blank_source_is_stored_as_absent(self, db):
_seed_without_baseline(db)
with patch("fenris.status.query_service_state", return_value=SERVICES):
app = _app(db)
with patch.object(app, "_run_helper", return_value=True) as run_helper:
async with app.run_test(size=(120, 50)) as pilot:
await _open_form(pilot)
await _fill(pilot, app, "1.5")
payload = json.loads(run_helper.call_args.args[1][1])
assert payload["tbw_terabytes"] == 1.5
assert payload["source_url"] is None
@pytest.mark.asyncio
async def test_headline_changes_from_write_rate_to_lifespan(self, db):
_seed_without_baseline(db)
def fake_helper(operation, extra_args=None):
cmd_baseline_set(SimpleNamespace(store_path=db, baseline_json=extra_args[1]))
return True
with patch("fenris.status.query_service_state", return_value=SERVICES):
app = _app(db)
with patch.object(app, "_run_helper", side_effect=fake_helper):
async with app.run_test(size=(120, 50)) as pilot:
before = _text(app, "#headline-band")
assert "Write rate" in before
assert "remaining" not in before
await _open_form(pilot)
await _fill(pilot, app, "600")
app.on_refresh_tick()
await pilot.pause()
after = _text(app, "#headline-band")
assert "remaining" in after
assert "Write rate" not in after
class TestBanner:
@pytest.mark.asyncio
async def test_banner_shown_when_no_baseline_applies(self, db):
_seed_without_baseline(db)
with patch("fenris.status.query_service_state", return_value=SERVICES):
app = _app(db)
async with app.run_test(size=(120, 50)):
banner = app.query_one("#baseline-banner")
assert banner.display
assert "b" in str(banner.render()).lower()
assert "rated tbw" in str(banner.render()).lower()
@pytest.mark.asyncio
async def test_banner_shown_on_first_run(self, tmp_path):
with patch("fenris.status.query_service_state", return_value=SERVICES):
app = _app(tmp_path / "missing.db")
async with app.run_test(size=(120, 50)):
assert app.query_one("#baseline-banner").display
@pytest.mark.asyncio
async def test_banner_hidden_once_baseline_exists(self, db):
now = datetime.now(timezone.utc).replace(minute=0, second=0, microsecond=0)
conn = init_store(db)
seed_evidence(conn, 3, start=now - timedelta(hours=3))
conn.close()
with patch("fenris.status.query_service_state", return_value=SERVICES):
app = _app(db)
async with app.run_test(size=(120, 50)):
assert not app.query_one("#baseline-banner").display
@pytest.mark.asyncio
async def test_banner_hidden_for_unreadable_store(self, db):
db.write_bytes(b"not a sqlite database" * 100)
with patch("fenris.status.query_service_state", return_value=SERVICES):
app = _app(db)
async with app.run_test(size=(120, 50)):
assert not app.query_one("#baseline-banner").display
+152
View File
@@ -0,0 +1,152 @@
"""Diagnostics panel on the dashboard (issue #109): toggle, render, badge."""
import sqlite3
import pytest
from test_dashboard_design import dashboard, visible # noqa: F401 (fixtures)
from fenris.tui import FenrisTuiApp, HelpScreen
def _store(app):
return sqlite3.connect(app.store_path)
def _set_latest_warning(app, value):
conn = _store(app)
conn.execute(
"UPDATE samples SET critical_warning = ?, power_on_hours = id, "
"unsafe_shutdowns = id / 20 WHERE 1", (value,))
conn.commit()
conn.close()
async def _settle(app, pilot):
app.on_refresh_tick()
await pilot.pause()
def _text(app, selector):
return str(app.query_one(selector).render())
def test_i_is_a_declared_binding_and_d_stays_disclosures():
keys = {b.key: b.action for b in FenrisTuiApp.BINDINGS}
assert keys["i"] == "toggle_diagnostics"
assert keys["d"] == "disclose"
@pytest.mark.asyncio
@pytest.mark.parametrize("size", [(140, 44), (100, 30)])
async def test_i_toggles_the_diagnostics_panel_and_hides_the_drive_pane(dashboard, size): # noqa: F811
app = dashboard
_set_latest_warning(app, 0)
async with app.run_test(size=size) as pilot:
await _settle(app, pilot)
assert not app.query_one("#diagnostics-panel").display
assert app.query_one("#drive-panel").display
await pilot.press("i")
await _settle(app, pilot)
assert app.query_one("#diagnostics-panel").display
assert not app.query_one("#drive-panel").display
body = _text(app, "#diagnostics")
assert "Diagnostics" in body and "Critical warning none" in body
assert "Power-on hours 61 Δ +60" in body
await pilot.press("i")
await _settle(app, pilot)
assert not app.query_one("#diagnostics-panel").display
assert app.query_one("#drive-panel").display
@pytest.mark.asyncio
async def test_footer_and_help_list_the_diagnostics_key(dashboard): # noqa: F811
app = dashboard
async with app.run_test(size=(140, 40)) as pilot:
await _settle(app, pilot)
assert "i Diagnostics" in _text(app, "#action-rail")
await pilot.press("?")
assert isinstance(app.screen, HelpScreen)
assert "Diagnostics" in str(app.screen.query_one("#help-text").render())
@pytest.mark.asyncio
async def test_diagnostics_follow_the_activity_view(dashboard): # noqa: F811
app = dashboard
async with app.run_test(size=(140, 44)) as pilot:
await _settle(app, pilot)
await pilot.press("i")
await _settle(app, pilot)
assert "last 3h" in _text(app, "#diagnostics")
await pilot.press("v") # Day
await _settle(app, pilot)
body = _text(app, "#diagnostics")
assert "· day 20" in body and "Hours ·" in body
await pilot.press("v") # History
await _settle(app, pilot)
assert "last 14 days" in _text(app, "#diagnostics")
@pytest.mark.asyncio
async def test_critical_warning_badge_shows_in_the_outlook_without_the_panel(dashboard): # noqa: F811
app = dashboard
_set_latest_warning(app, 0x04)
async with app.run_test(size=(140, 44)) as pilot:
await _settle(app, pilot)
assert not app.query_one("#diagnostics-panel").display
band = _text(app, "#headline-band")
assert "0x04" in band and "reliability degraded" in band
@pytest.mark.asyncio
async def test_no_badge_when_the_drive_reports_no_warning(dashboard): # noqa: F811
app = dashboard
_set_latest_warning(app, 0)
async with app.run_test(size=(140, 44)) as pilot:
await _settle(app, pilot)
assert "critical warning" not in _text(app, "#headline-band").lower()
@pytest.mark.asyncio
async def test_diagnostics_work_in_constrained_mode(dashboard): # noqa: F811
app = dashboard
_set_latest_warning(app, 0x01)
async with app.run_test(size=(60, 20)) as pilot:
await _settle(app, pilot)
assert app._is_constrained_mode
await pilot.press("i")
await _settle(app, pilot)
assert app.query_one("#diagnostics-panel").display
body = _text(app, "#diagnostics")
assert "available spare below threshold" in body
assert "Power-on hours" in body
await pilot.press("i")
await _settle(app, pilot)
assert app.query_one("#drive-panel").display
@pytest.mark.asyncio
async def test_toggling_while_zoomed_restores_the_grid_and_keeps_focus(dashboard): # noqa: F811
app = dashboard
async with app.run_test(size=(140, 44)) as pilot:
await _settle(app, pilot)
await pilot.press("z")
await pilot.press("i")
await _settle(app, pilot)
assert app._zoomed_panel is None
assert app.focused is not None
@pytest.mark.asyncio
async def test_history_bars_draw_reads_alongside_writes_only_with_diagnostics(dashboard): # noqa: F811
app = dashboard
async with app.run_test(size=(140, 44)) as pilot:
await _settle(app, pilot)
await pilot.press("v", "v") # History
await _settle(app, pilot)
assert "Reads" not in _text(app, "#bar-legend")
await pilot.press("i")
await _settle(app, pilot)
legend = _text(app, "#bar-legend")
assert "Writes" in legend and "Reads" in legend
await pilot.press("i")
await _settle(app, pilot)
assert "Reads" not in _text(app, "#bar-legend")
+456
View File
@@ -0,0 +1,456 @@
"""Unified keyboard and mouse navigation (issue #110).
Every action has one declaration: a Binding. The footer, the help screen and
the clickable chips are all generated from those bindings, and the keyboard,
mouse and wheel paths reach the same actions. Pilot drives each path.
"""
import re
from datetime import date, timedelta
from unittest.mock import patch
import pytest
from test_dashboard_design import dashboard # noqa: F401 (pytest fixture)
from textual import events
from textual.geometry import Region
from fenris.tui import (
ConfirmPause,
DatePickerScreen,
BaselineScreen,
DisclosuresScreen,
FenrisTuiApp,
HelpScreen,
HistoryGraph,
LiveActivityGraph,
)
BINDING_SOURCES = (FenrisTuiApp, LiveActivityGraph, HistoryGraph)
# -- helpers ---------------------------------------------------------------
async def _settle(app, pilot):
app.on_refresh_tick()
await pilot.pause()
def _chip(app, label):
"""Offset of a footer or tool chip label inside its Static."""
for selector in ("#action-rail", "#activity-tools"):
widget = app.query_one(selector)
region = Region(0, 0, widget.size.width, widget.size.height)
for y, strip in enumerate(widget.render_lines(region)):
x = strip.text.find(label)
if x >= 0:
return selector, (x + 1, y)
raise AssertionError("no chip %r in footer" % label)
async def _click_chip(app, pilot, label):
selector, offset = _chip(app, label)
await pilot.click(selector, offset=offset)
await pilot.pause()
def _scroll(widget, direction):
event = (events.MouseScrollUp if direction < 0 else events.MouseScrollDown)(
widget, 3, 1, 0, direction, 0, False, False, False,
)
widget.post_message(event)
def _help_rows(app):
text = str(app.screen.query_one("#help-text").render())
section = text.split("Keyboard controls\n", 1)[1].split("\n\n", 1)[0]
rows = {}
for line in section.splitlines():
keys, description = re.match(r"^(\S.*?)\s{2,}(\S.*)$", line).groups()
rows[description] = set(keys.split(" / "))
return rows
def _binding_rows(app):
rows = {}
for source in BINDING_SOURCES:
for binding in source.BINDINGS:
rows.setdefault(binding.description, set()).add(app.get_key_display(binding))
return rows
# -- one source of truth ---------------------------------------------------
def test_graphs_declare_bindings_instead_of_key_handlers():
for graph in (HistoryGraph, LiveActivityGraph):
assert "on_key" not in vars(graph)
assert graph.BINDINGS
@pytest.mark.asyncio
async def test_help_content_equals_binding_set(dashboard): # noqa: F811
async with dashboard.run_test(size=(100, 36)) as pilot:
await pilot.press("?")
assert isinstance(dashboard.screen, HelpScreen)
assert _help_rows(dashboard) == _binding_rows(dashboard)
assert "The dashboard does not need sudo" in str(
dashboard.screen.query_one("#help-text").render())
@pytest.mark.asyncio
async def test_footer_chips_are_generated_from_visible_bindings(dashboard): # noqa: F811
async with dashboard.run_test(size=(120, 36)) as pilot:
await pilot.pause()
footer = str(dashboard.query_one("#action-rail").render())
shown = [b for b in FenrisTuiApp.BINDINGS if b.show]
assert shown
for binding in shown:
assert "%s %s" % (dashboard.get_key_display(binding), binding.description) in footer
for binding in FenrisTuiApp.BINDINGS:
if not binding.show:
label = "%s %s" % (dashboard.get_key_display(binding), binding.description)
assert label not in footer
@pytest.mark.asyncio
@pytest.mark.parametrize("size", [(79, 24), (80, 23), (60, 18)])
async def test_footer_collapses_under_80x24(dashboard, size): # noqa: F811
async with dashboard.run_test(size=size) as pilot:
await pilot.pause()
assert str(dashboard.query_one("#action-rail").render()) == "? help · q quit"
await _click_chip(dashboard, pilot, "? help")
assert isinstance(dashboard.screen, HelpScreen)
await pilot.press("escape")
await _click_chip(dashboard, pilot, "q quit")
assert not dashboard.is_running
@pytest.mark.asyncio
async def test_footer_is_full_at_80x24(dashboard): # noqa: F811
async with dashboard.run_test(size=(80, 24)) as pilot:
await pilot.pause()
footer = str(dashboard.query_one("#action-rail").render())
assert "p Pause" in footer and "q Quit TUI" in footer
# -- keyboard: live graph --------------------------------------------------
@pytest.mark.asyncio
async def test_live_home_end_and_arrows(dashboard): # noqa: F811
app = dashboard
async with app.run_test(size=(100, 36)) as pilot:
await _settle(app, pilot)
graph = app.query_one("#live-activity")
last = len(graph._data) - 1
assert last > 3
await pilot.press("home")
assert graph._selected_index == 0
await pilot.press("right")
assert graph._selected_index == 1
await pilot.press("end")
assert graph._selected_index == last
await pilot.press("left")
assert graph._selected_index == last - 1
# -- keyboard: history graph -----------------------------------------------
async def _open_history(app, pilot):
await pilot.click("#view-history")
await _settle(app, pilot)
graph = app.query_one("#usage-history")
assert app.focused is graph
return graph
@pytest.mark.asyncio
async def test_history_home_end_and_week_jumps(dashboard): # noqa: F811
app = dashboard
async with app.run_test(size=(100, 36)) as pilot:
graph = await _open_history(app, pilot)
last = len(graph._day_data) - 1
assert last >= 13
await pilot.press("home")
assert graph.selected_index == 0
await pilot.press("end")
assert graph.selected_index == last
assert app._browse_date == graph._day_data[last]["day"]
await pilot.press("pageup")
assert graph.selected_index == last - 7
assert app._browse_date == graph._day_data[last - 7]["day"]
await pilot.press("pagedown")
assert graph.selected_index == last
await pilot.press("pagedown")
assert graph.selected_index == last
await pilot.press("home", "pageup")
assert graph.selected_index == 0
@pytest.mark.asyncio
async def test_history_range_keys_and_drill_bindings(dashboard): # noqa: F811
app = dashboard
async with app.run_test(size=(100, 36)) as pilot:
graph = await _open_history(app, pilot)
await pilot.press("1")
assert graph.range_days == 7
await pilot.press("3")
assert graph.range_days == 30
await pilot.press("left", "enter")
assert app._activity_view == "day" and graph.view_mode == "hourly"
await pilot.press("1")
assert graph.range_days == 30 # range keys only apply to the day graph
await pilot.press("escape")
assert graph.view_mode == "daily" and app._activity_view == "history"
await pilot.press("enter")
await pilot.press("backspace")
assert graph.view_mode == "daily"
@pytest.mark.asyncio
async def test_hour_graph_home_end_arrows_and_week_jump(dashboard): # noqa: F811
app = dashboard
async with app.run_test(size=(100, 36)) as pilot:
graph = await _open_history(app, pilot)
await pilot.press("enter")
day = app._browse_date
await pilot.press("home")
assert graph._hourly_selected == 0
await pilot.press("right")
assert graph._hourly_selected == 1
await pilot.press("end")
assert graph._hourly_selected == len(graph._hour_data) - 1
await pilot.press("left")
assert graph._hourly_selected == len(graph._hour_data) - 2
await pilot.press("pageup")
assert app._activity_view == "day"
assert app._browse_date == (date.fromisoformat(day) - timedelta(days=7)).isoformat()
@pytest.mark.asyncio
async def test_escape_in_zoomed_hour_view_restores_zoom_first(dashboard): # noqa: F811
app = dashboard
async with app.run_test(size=(100, 36)) as pilot:
graph = await _open_history(app, pilot)
await pilot.press("enter", "z")
assert app._zoomed_panel == "activity-panel"
await pilot.press("escape")
assert app._zoomed_panel is None and graph.view_mode == "hourly"
await pilot.press("escape")
assert graph.view_mode == "daily"
# -- keyboard: focus ring --------------------------------------------------
@pytest.mark.asyncio
async def test_tab_cycles_panels_with_visible_focus_ring(dashboard): # noqa: F811
app = dashboard
async with app.run_test(size=(120, 40)) as pilot:
await pilot.pause()
order = [app.focused.id]
for _ in range(3):
await pilot.press("tab")
order.append(app.focused.id)
assert order == ["live-activity", "drive-panel", "endurance-panel", "live-activity"]
await pilot.press("shift+tab")
assert app.focused.id == "endurance-panel"
await pilot.press("shift+tab")
assert app.focused.id == "drive-panel"
def ring(pane_id):
return app.query_one("#" + pane_id).styles.border_top[1]
assert ring("drive-panel") != ring("endurance-panel")
await pilot.press("tab")
assert app.focused.id == "endurance-panel"
assert ring("endurance-panel") != ring("drive-panel")
await pilot.press("tab")
assert ring("activity-panel") != ring("drive-panel")
# -- mouse -----------------------------------------------------------------
@pytest.mark.asyncio
async def test_click_focuses_each_panel(dashboard): # noqa: F811
app = dashboard
async with app.run_test(size=(120, 40)) as pilot:
await pilot.pause()
await pilot.click("#drive-health")
assert app.focused.id == "drive-panel"
await pilot.click("#headline-band")
assert app.focused.id == "endurance-panel"
await pilot.click("#local-day")
assert app.focused.id == "live-activity"
await pilot.click("#view-history")
await pilot.click("#drive-health")
await pilot.click("#local-day")
assert app.focused.id == "usage-history"
@pytest.mark.asyncio
async def test_view_tabs_are_clickable(dashboard): # noqa: F811
app = dashboard
async with app.run_test(size=(100, 36)) as pilot:
for view in ("history", "day", "live"):
await pilot.click("#view-" + view)
assert app._activity_view == view
assert app.query_one("#activity-tabs").active == "view-" + view
@pytest.mark.asyncio
async def test_action_chips_fire_their_bindings(dashboard): # noqa: F811
app = dashboard
calls = []
async with app.run_test(size=(120, 40)) as pilot:
await pilot.pause()
with patch.object(app, "_run_helper", side_effect=lambda *a: calls.append(a) or True):
await _click_chip(app, pilot, "p Pause")
assert isinstance(app.screen, ConfirmPause)
await pilot.press("n")
await _click_chip(app, pilot, "r Resume")
assert calls == [("enable", ["--now"])]
await _click_chip(app, pilot, "c Collect")
assert calls[-1] == ("collect",)
await _click_chip(app, pilot, "b Rated TBW")
assert isinstance(app.screen, BaselineScreen)
await pilot.press("escape")
await _click_chip(app, pilot, "s Theme")
assert app.theme == "fenris-amber"
await _click_chip(app, pilot, "d Disclosures")
assert isinstance(app.screen, DisclosuresScreen)
await pilot.press("escape")
await _click_chip(app, pilot, "z Zoom")
assert app._zoomed_panel is not None
await _click_chip(app, pilot, "z Zoom")
assert app._zoomed_panel is None
await _click_chip(app, pilot, "v View")
assert app._activity_view == "day"
await _click_chip(app, pilot, "m Motion")
assert app._reduced_motion is True
@pytest.mark.asyncio
async def test_activity_tool_chips_are_generated_from_bindings(dashboard): # noqa: F811
app = dashboard
async with app.run_test(size=(120, 40)) as pilot:
await pilot.pause()
tools = str(app.query_one("#activity-tools").render())
for action in ("prev_day", "next_day", "go_to_date", "today", "toggle_measure"):
binding = next(b for b in FenrisTuiApp.BINDINGS if b.action == action)
assert "%s %s" % (app.get_key_display(binding), binding.description) in tools
await _click_chip(app, pilot, "w Toggle read/write")
assert app.query_one("#live-activity").measure == "read"
await _click_chip(app, pilot, "g Go to date")
assert isinstance(app.screen, DatePickerScreen)
await pilot.press("escape")
await _click_chip(app, pilot, "Prev day")
assert app._activity_view == "day"
await _click_chip(app, pilot, "t Today")
assert app._activity_view == "live"
@pytest.mark.asyncio
async def test_wheel_moves_live_selection(dashboard): # noqa: F811
app = dashboard
async with app.run_test(size=(100, 36)) as pilot:
await _settle(app, pilot)
graph = app.query_one("#live-activity")
last = len(graph._data) - 1
_scroll(graph, -1)
await pilot.pause()
assert graph._selected_index == last - 1
_scroll(graph, -1)
await pilot.pause()
assert graph._selected_index == last - 2
_scroll(graph, 1)
await pilot.pause()
assert graph._selected_index == last - 1
@pytest.mark.asyncio
async def test_wheel_moves_history_day_and_hour_selection(dashboard): # noqa: F811
app = dashboard
async with app.run_test(size=(100, 36)) as pilot:
graph = await _open_history(app, pilot)
last = len(graph._day_data) - 1
_scroll(graph, -1)
await pilot.pause()
assert graph.selected_index == last - 1
_scroll(graph, 1)
await pilot.pause()
assert graph.selected_index == last
await pilot.press("enter", "end")
end = graph._hourly_selected
_scroll(graph, -1)
await pilot.pause()
assert graph._hourly_selected == end - 1
@pytest.mark.asyncio
async def test_click_to_inspect_stays(dashboard): # noqa: F811
app = dashboard
async with app.run_test(size=(100, 36)) as pilot:
await pilot.pause()
graph = app.query_one("#live-activity")
await pilot.click("#live-render", offset=(graph._point_columns[0], 1))
assert graph._selected_index == 0
# -- modal keys ------------------------------------------------------------
@pytest.mark.asyncio
async def test_confirm_pause_y_n_and_escape(dashboard): # noqa: F811
app = dashboard
async with app.run_test(size=(100, 36)) as pilot:
with patch.object(app, "_run_helper") as helper:
await pilot.press("p", "n")
assert not isinstance(app.screen, ConfirmPause)
await pilot.press("p", "escape")
assert not isinstance(app.screen, ConfirmPause)
helper.assert_not_called()
await pilot.press("p", "y")
assert not isinstance(app.screen, ConfirmPause)
helper.assert_called_once_with("disable", ["--now"])
@pytest.mark.asyncio
async def test_date_picker_keys(dashboard): # noqa: F811
app = dashboard
async with app.run_test(size=(100, 36)) as pilot:
await _settle(app, pilot)
await pilot.press("g", "escape")
assert not isinstance(app.screen, DatePickerScreen)
await pilot.press("g", "enter")
assert isinstance(app.screen, DatePickerScreen)
assert "Please enter a date" in str(app.screen.query_one("#date-error").render())
await pilot.press(*"2026-13-45", "enter")
assert "Invalid date format" in str(app.screen.query_one("#date-error").render())
app.screen.query_one("#date-input").value = ""
await pilot.press(*"2999-01-01", "enter")
assert "future" in str(app.screen.query_one("#date-error").render())
app.screen.query_one("#date-input").value = ""
await pilot.press(*"2026-09-17", "enter")
assert not isinstance(app.screen, DatePickerScreen)
assert app._browse_date == "2026-09-17"
assert app._activity_view == "day"
@pytest.mark.asyncio
async def test_disclosures_and_help_toggle_keys(dashboard): # noqa: F811
app = dashboard
async with app.run_test(size=(100, 36)) as pilot:
await pilot.press("d")
assert isinstance(app.screen, DisclosuresScreen)
await pilot.press("d")
assert not isinstance(app.screen, DisclosuresScreen)
await pilot.press("d", "escape")
assert not isinstance(app.screen, DisclosuresScreen)
await pilot.press("?")
assert isinstance(app.screen, HelpScreen)
await pilot.press("?")
assert not isinstance(app.screen, HelpScreen)
await pilot.press("?", "escape")
assert not isinstance(app.screen, HelpScreen)
@pytest.mark.asyncio
async def test_single_dashboard_has_no_page_navigation_bindings(dashboard): # noqa: F811
keys = {b.key for b in FenrisTuiApp.BINDINGS}
assert not keys & {"left", "right", "1", "2", "3", "4", "home", "end", "pageup", "pagedown"}
+26
View File
@@ -0,0 +1,26 @@
"""Timezone detection tests."""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from fenris import tz_util
def test_relative_localtime_symlink_returns_zoneinfo_key(tmp_path, monkeypatch):
zoneinfo = tmp_path / "usr" / "share" / "zoneinfo" / "Asia" / "Kolkata"
zoneinfo.parent.mkdir(parents=True)
zoneinfo.write_bytes(b"zoneinfo")
localtime = tmp_path / "etc" / "localtime"
localtime.parent.mkdir()
localtime.symlink_to("../usr/share/zoneinfo/Asia/Kolkata")
real_path = tz_util.Path
monkeypatch.setattr(
tz_util,
"Path",
lambda path: localtime if path == "/etc/localtime" else real_path(path),
)
monkeypatch.delenv("TZ", raising=False)
assert tz_util.detect_system_tz() == "Asia/Kolkata"