mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-13 09:01:54 +00:00
* feat(billing): /usage → portal top-up browser handoff
Add the terminal side of the billing slice (phase 2a): start a top-up by
throwing the user to the portal billing page with the top-up modal open. The
terminal does not confirm, poll, or track payment — checkout completes in the
browser and the next /usage shows the new balance.
- nous_account.py: parse organisation.slug/name from /api/oauth/account into
NousPortalAccountInfo; add nous_portal_topup_url() building the org-pinned
{base}/orgs/{slug}/billing?topup=open with a null-slug fallback to the legacy
{base}/billing?topup=open (never /orgs/None/...).
- portal_cli.py: 'hermes portal topup' — fresh account fetch, identity line
(Topping up as <email> / org <name>), browser open with printed-URL fallback,
no-wait closing copy. No polling/confirmation (deferred to 2b).
- account_usage.py: the shared /usage credits block now links the org-pinned
top-up URL (auto-opens the modal) + points to the command.
Depends on NAS #409 (organisation.slug/name + ?topup=open). Do not merge until
that is live on the target env; until then /api/oauth/account returns
organisation: { id } only and the URL falls back to legacy.
* feat(billing): /credits command for balance + top-up handoff
Replace the standalone `hermes portal topup` subcommand with an in-session
/credits slash command — a focused money surface (balance in, top-up out) that
works in the CLI, TUI, and every messaging platform from one registry entry.
- commands.py: register /credits (Info category). Slack is at its 50-slash cap,
so /credits is routed via /hermes credits on Slack only (new
_SLACK_VIA_HERMES_ONLY set) to avoid clamping a canonical command off the
native list and breaking Telegram parity; native everywhere else.
- account_usage.py: build_credits_view() — one portal fetch → balance lines +
identity line + org-pinned top-up URL + depleted flag, consumed by all
surfaces. Reuses the same snapshot/URL builder as /usage so numbers match.
- cli.py: _show_credits() — balance block + identity line + 3-button panel
(Open top-up / Copy link / Cancel) via the existing prompt_toolkit modal.
ASK, never auto-launch; headless falls back to printing the URL.
- gateway/slash_commands.py: _handle_credits_command() — renders the block +
tappable top-up URL + no-wait copy; works on button and plain-text platforms.
- /usage credits line now points to /credits.
- Retire `hermes portal topup` (portal_cli.py back to baseline); the engine
(slug/name parse + nous_portal_topup_url) stays as the shared core.
No polling, no payment confirmation (billing phase 2a). Depends on NAS #409.
* fix(credits): /credits works in the TUI slash-worker (non-interactive)
In the TUI, /credits runs in the slash-worker subprocess where there is no
live prompt_toolkit app and stdin is the JSON-RPC pipe. _show_credits called
the 3-button modal unconditionally, which fell back to reading stdin →
exception → slash.exec rejected → the command produced no output (only the
pre-existing 'Credit access paused' banner showed).
- _show_credits: when self._app is None (TUI worker / piped / non-interactive),
render the text variant — balance block + tappable top-up URL + no-wait line,
same affordance as the messaging surfaces — and skip the modal entirely. The
3-button panel still renders in the interactive CLI.
- Depleted banner copy: 'run /usage for balance' → 'run /credits to top up'
now that /credits is the dedicated money surface (+ tests).
- Regression tests: _show_credits with self._app=None renders text and never
invokes the modal; logged-out path.
* feat(tui): credits.view RPC for the /credits tappable top-up button
Add a credits.view JSON-RPC method returning the structured CreditsView
(logged_in, balance_lines, identity_line, topup_url, depleted) so the TUI can
render a clickable <Link> top-up button instead of plain text. Account-
independent (portal fetch gated on a logged-in Nous account), fail-open to
{logged_in: false} on any hiccup. Mirrors session.usage's credits-block pattern.
Frontend (TUI-local /credits command + Ink component) lands separately.
* feat(tui): /credits command with keyboard-driven top-up confirm
TUI-local /credits: fetches the structured balance via the credits.view RPC,
prints the balance + identity + top-up URL, then arms the EXISTING confirm
overlay (Enter = open top-up in browser via openExternalUrl, Esc = cancel).
Reuses ConfirmReq — no new overlay component/state/input handler. Headless
(openExternalUrl returns false) falls back to printing the URL.
- gatewayTypes.ts: CreditsViewResponse.
- commands/credits.ts: the command (mirrors /status's rpc+guarded pattern).
- registry.ts: register creditsCommands.
- test: balance+overlay armed, headless fallback, no-url, logged-out (4 cases).
Matches the CLI /credits 'Enter to open' affordance. Phase 2a: no polling.
716 lines
31 KiB
Python
716 lines
31 KiB
Python
"""Tests for evaluate_credits_notices — pure threshold reconciliation policy (L4.1).
|
|
|
|
All tests use fresh latch = {"active": set(), "seen_below_90": False, "usage_band": None} per scenario.
|
|
CreditsState is constructed directly (not parsed from headers).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from agent.credits_tracker import (
|
|
CREDITS_NOTICE_KIND,
|
|
CREDITS_RESTORED_TTL_MS,
|
|
AgentNotice,
|
|
CreditsState,
|
|
evaluate_credits_notices,
|
|
)
|
|
|
|
|
|
# ── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def fresh_latch() -> dict:
|
|
return {"active": set(), "seen_below_90": False, "usage_band": None}
|
|
|
|
|
|
def state_with_fraction(
|
|
uf: float | None,
|
|
*,
|
|
paid_access: bool = True,
|
|
denominator_kind: str = "subscription_cap",
|
|
purchased_micros: int = 0,
|
|
purchased_usd: str = "0.00",
|
|
subscription_limit_usd: str | None = "20.00",
|
|
) -> CreditsState:
|
|
"""Build a minimal CreditsState that yields the desired used_fraction.
|
|
|
|
used_fraction = (limit - subscription_micros) / limit
|
|
|
|
When uf is None, we set limit to None so used_fraction returns None.
|
|
"""
|
|
if uf is None:
|
|
return CreditsState(
|
|
subscription_limit_micros=None,
|
|
subscription_limit_usd=None,
|
|
subscription_micros=0,
|
|
denominator_kind="none",
|
|
paid_access=paid_access,
|
|
purchased_micros=purchased_micros,
|
|
purchased_usd=purchased_usd,
|
|
)
|
|
# We want (limit - sub) / limit == uf → sub = limit * (1 - uf)
|
|
limit = 20_000_000 # $20 in micros
|
|
sub = int(limit * (1.0 - uf))
|
|
return CreditsState(
|
|
subscription_limit_micros=limit,
|
|
subscription_limit_usd=subscription_limit_usd,
|
|
subscription_micros=sub,
|
|
denominator_kind=denominator_kind,
|
|
paid_access=paid_access,
|
|
purchased_micros=purchased_micros,
|
|
purchased_usd=purchased_usd,
|
|
)
|
|
|
|
|
|
# ── Scenario 1: crossing 90% threshold ───────────────────────────────────────
|
|
|
|
|
|
class TestWarn90Crossing:
|
|
def test_below_lowest_band_no_notice_but_latch_set(self):
|
|
latch = fresh_latch()
|
|
s = state_with_fraction(0.10) # below the 50% band
|
|
to_show, to_clear = evaluate_credits_notices(s, latch)
|
|
assert all(n.key != "credits.usage" for n in to_show)
|
|
assert "credits.usage" not in to_clear
|
|
assert latch["seen_below_90"] is True
|
|
|
|
def test_crossing_to_90_fires_once(self):
|
|
latch = fresh_latch()
|
|
# First call: uf < 0.5 — sets seen_below_90 (below lowest band)
|
|
s1 = state_with_fraction(0.10)
|
|
evaluate_credits_notices(s1, latch)
|
|
# Second call: uf >= 0.9 — should fire the usage band at 90
|
|
s2 = state_with_fraction(0.95)
|
|
to_show, to_clear = evaluate_credits_notices(s2, latch)
|
|
keys = [n.key for n in to_show]
|
|
assert "credits.usage" in keys
|
|
assert "credits.usage" not in to_clear
|
|
|
|
def test_no_refire_on_repeated_over_90(self):
|
|
latch = fresh_latch()
|
|
s_below = state_with_fraction(0.10)
|
|
evaluate_credits_notices(s_below, latch)
|
|
s_over = state_with_fraction(0.95)
|
|
evaluate_credits_notices(s_over, latch)
|
|
# Third call: still ≥ 0.9 — must NOT re-fire
|
|
to_show, to_clear = evaluate_credits_notices(s_over, latch)
|
|
assert all(n.key != "credits.usage" for n in to_show)
|
|
assert "credits.usage" not in to_clear
|
|
|
|
|
|
# ── Scenario 2: recovery + re-cross ──────────────────────────────────────────
|
|
|
|
|
|
class TestWarn90RecoveryReCross:
|
|
def test_recovery_clears_warn90(self):
|
|
latch = fresh_latch()
|
|
# Cross below → above
|
|
evaluate_credits_notices(state_with_fraction(0.10), latch)
|
|
evaluate_credits_notices(state_with_fraction(0.95), latch)
|
|
# Recovery: uf drops back below ALL bands → usage notice clears entirely
|
|
to_show, to_clear = evaluate_credits_notices(state_with_fraction(0.10), latch)
|
|
assert "credits.usage" in to_clear
|
|
assert "credits.usage" not in latch["active"]
|
|
|
|
def test_recross_after_recovery_fires_again(self):
|
|
latch = fresh_latch()
|
|
evaluate_credits_notices(state_with_fraction(0.10), latch)
|
|
evaluate_credits_notices(state_with_fraction(0.95), latch)
|
|
evaluate_credits_notices(state_with_fraction(0.10), latch) # recovery
|
|
# Re-cross: uf >= 0.9 again — should fire again because the band is clearable
|
|
to_show, to_clear = evaluate_credits_notices(state_with_fraction(0.95), latch)
|
|
keys = [n.key for n in to_show]
|
|
assert "credits.usage" in keys
|
|
|
|
|
|
# ── Scenario 3: open-already-over (hybrid Q3 gate) ───────────────────────────
|
|
|
|
|
|
class TestOpenAlreadyOver:
|
|
def test_warn90_does_not_fire_without_seen_below_90(self):
|
|
"""First call uf≥0.9 with seen_below_90=False — warn90 must NOT fire."""
|
|
latch = fresh_latch()
|
|
assert latch["seen_below_90"] is False
|
|
s = state_with_fraction(0.95)
|
|
to_show, to_clear = evaluate_credits_notices(s, latch)
|
|
assert all(n.key != "credits.usage" for n in to_show)
|
|
assert "credits.usage" not in to_clear
|
|
|
|
|
|
# ── Scenario 3b: boundary — exact 0.9 and just-below-1.0 ────────────────────
|
|
|
|
|
|
class TestBoundaryFractions:
|
|
def test_exact_0_9_fires_warn90(self):
|
|
"""used_fraction == 0.9 exactly must fire warn90 (threshold is inclusive)."""
|
|
latch = fresh_latch()
|
|
# First: prime seen_below_90 with a sub-50% observation
|
|
evaluate_credits_notices(state_with_fraction(0.10), latch)
|
|
# Now construct a state where used_fraction is EXACTLY 0.9:
|
|
# subscription_limit_micros=20_000_000, subscription_micros=2_000_000
|
|
# → used = 18_000_000 / 20_000_000 = 0.9 exactly
|
|
s = CreditsState(
|
|
subscription_limit_micros=20_000_000,
|
|
subscription_limit_usd="20.00",
|
|
subscription_micros=2_000_000,
|
|
denominator_kind="subscription_cap",
|
|
paid_access=True,
|
|
)
|
|
assert s.used_fraction == 0.9
|
|
to_show, to_clear = evaluate_credits_notices(s, latch)
|
|
keys = [n.key for n in to_show]
|
|
assert "credits.usage" in keys
|
|
assert "credits.usage" not in to_clear
|
|
|
|
def test_just_below_1_0_does_not_fire_grant_spent(self):
|
|
"""subscription_micros = limit - 1 (used_fraction just under 1.0) must NOT fire grant_spent.
|
|
|
|
Locks the boundary so a future used_fraction clamp refactor cannot fire
|
|
grant_spent a micro early.
|
|
"""
|
|
latch = fresh_latch()
|
|
limit = 20_000_000
|
|
s = CreditsState(
|
|
subscription_limit_micros=limit,
|
|
subscription_limit_usd="20.00",
|
|
subscription_micros=1, # limit - 1 → used_fraction < 1.0
|
|
denominator_kind="subscription_cap",
|
|
purchased_micros=5_000_000,
|
|
purchased_usd="5.00",
|
|
paid_access=True,
|
|
)
|
|
assert s.used_fraction is not None and s.used_fraction < 1.0
|
|
to_show, to_clear = evaluate_credits_notices(s, latch)
|
|
assert all(n.key != "credits.grant_spent" for n in to_show)
|
|
assert "credits.grant_spent" not in to_clear
|
|
|
|
|
|
# ── Scenario 4: grant_spent ───────────────────────────────────────────────────
|
|
|
|
|
|
class TestGrantSpent:
|
|
def _grant_state(self, purchased_micros: int = 12_340_000) -> CreditsState:
|
|
return state_with_fraction(
|
|
1.0,
|
|
denominator_kind="subscription_cap",
|
|
purchased_micros=purchased_micros,
|
|
purchased_usd="12.34",
|
|
)
|
|
|
|
def test_grant_spent_fires_on_first_obs(self):
|
|
"""No crossing gate for grant_spent — fires immediately on first obs."""
|
|
latch = fresh_latch()
|
|
to_show, to_clear = evaluate_credits_notices(self._grant_state(), latch)
|
|
keys = [n.key for n in to_show]
|
|
assert "credits.grant_spent" in keys
|
|
|
|
def test_grant_spent_no_refire(self):
|
|
latch = fresh_latch()
|
|
evaluate_credits_notices(self._grant_state(), latch)
|
|
to_show, to_clear = evaluate_credits_notices(self._grant_state(), latch)
|
|
assert all(n.key != "credits.grant_spent" for n in to_show)
|
|
assert "credits.grant_spent" not in to_clear
|
|
|
|
def test_grant_spent_clears_when_purchased_zero(self):
|
|
latch = fresh_latch()
|
|
evaluate_credits_notices(self._grant_state(), latch)
|
|
# Now purchased → 0: grant_cond becomes False
|
|
s_no_purchase = state_with_fraction(
|
|
1.0,
|
|
denominator_kind="subscription_cap",
|
|
purchased_micros=0,
|
|
purchased_usd="0.00",
|
|
)
|
|
to_show, to_clear = evaluate_credits_notices(s_no_purchase, latch)
|
|
assert "credits.grant_spent" in to_clear
|
|
assert all(n.key != "credits.grant_spent" for n in to_show)
|
|
|
|
|
|
# ── Scenario 5: depleted + recovery ──────────────────────────────────────────
|
|
|
|
|
|
class TestDepleted:
|
|
def test_depleted_fires_level_error_kind_sticky(self):
|
|
latch = fresh_latch()
|
|
s = CreditsState(paid_access=False)
|
|
to_show, to_clear = evaluate_credits_notices(s, latch)
|
|
depleted_notices = [n for n in to_show if n.key == "credits.depleted"]
|
|
assert len(depleted_notices) == 1
|
|
n = depleted_notices[0]
|
|
assert n.level == "error"
|
|
assert n.kind == CREDITS_NOTICE_KIND
|
|
|
|
def test_recovery_emits_clear_and_restored(self):
|
|
latch = fresh_latch()
|
|
# Fire depleted
|
|
evaluate_credits_notices(CreditsState(paid_access=False), latch)
|
|
# Now recovered
|
|
to_show, to_clear = evaluate_credits_notices(CreditsState(paid_access=True), latch)
|
|
assert "credits.depleted" in to_clear
|
|
restored = [n for n in to_show if n.key == "credits.restored"]
|
|
assert len(restored) == 1
|
|
r = restored[0]
|
|
assert r.level == "success"
|
|
assert r.kind == "ttl"
|
|
assert r.ttl_ms == CREDITS_RESTORED_TTL_MS
|
|
|
|
def test_depleted_refires_after_recovery(self):
|
|
latch = fresh_latch()
|
|
evaluate_credits_notices(CreditsState(paid_access=False), latch)
|
|
evaluate_credits_notices(CreditsState(paid_access=True), latch)
|
|
# Goes depleted again
|
|
to_show, to_clear = evaluate_credits_notices(CreditsState(paid_access=False), latch)
|
|
keys = [n.key for n in to_show]
|
|
assert "credits.depleted" in keys
|
|
|
|
|
|
# ── Scenario 5b: free-model suppression of the depleted notice ───────────────
|
|
|
|
|
|
class TestDepletedFreeModelSuppression:
|
|
def test_depleted_suppressed_when_model_is_free(self):
|
|
latch = fresh_latch()
|
|
s = CreditsState(paid_access=False)
|
|
to_show, to_clear = evaluate_credits_notices(s, latch, model_is_free=True)
|
|
assert all(n.key != "credits.depleted" for n in to_show)
|
|
assert "credits.depleted" not in latch["active"]
|
|
assert to_clear == []
|
|
|
|
def test_switch_to_free_model_clears_without_restored(self):
|
|
latch = fresh_latch()
|
|
# Depleted on a paid model → notice fires
|
|
evaluate_credits_notices(CreditsState(paid_access=False), latch)
|
|
assert "credits.depleted" in latch["active"]
|
|
# Same depleted account, but now on a free model → clear, NO "restored"
|
|
to_show, to_clear = evaluate_credits_notices(
|
|
CreditsState(paid_access=False), latch, model_is_free=True
|
|
)
|
|
assert "credits.depleted" in to_clear
|
|
assert "credits.depleted" not in latch["active"]
|
|
assert all(n.key != "credits.restored" for n in to_show)
|
|
|
|
def test_switch_back_to_paid_model_while_depleted_reshows(self):
|
|
latch = fresh_latch()
|
|
evaluate_credits_notices(CreditsState(paid_access=False), latch)
|
|
evaluate_credits_notices(CreditsState(paid_access=False), latch, model_is_free=True)
|
|
# Back on a paid model, still depleted → notice re-fires
|
|
to_show, to_clear = evaluate_credits_notices(CreditsState(paid_access=False), latch)
|
|
keys = [n.key for n in to_show]
|
|
assert "credits.depleted" in keys
|
|
assert "credits.depleted" in latch["active"]
|
|
|
|
def test_genuine_recovery_on_free_model_no_spurious_restored(self):
|
|
"""Recovery observed while suppressed (notice never shown) → nothing to
|
|
clear, no 'restored' (there was no visible depleted state to restore)."""
|
|
latch = fresh_latch()
|
|
evaluate_credits_notices(CreditsState(paid_access=False), latch, model_is_free=True)
|
|
to_show, to_clear = evaluate_credits_notices(
|
|
CreditsState(paid_access=True), latch, model_is_free=True
|
|
)
|
|
assert to_clear == []
|
|
assert all(n.key != "credits.restored" for n in to_show)
|
|
|
|
def test_genuine_recovery_still_emits_restored_when_notice_active(self):
|
|
"""paid_access flip back to True with the notice showing → clear + restored
|
|
(unchanged behaviour, regardless of the model-free flag)."""
|
|
latch = fresh_latch()
|
|
evaluate_credits_notices(CreditsState(paid_access=False), latch)
|
|
to_show, to_clear = evaluate_credits_notices(
|
|
CreditsState(paid_access=True), latch, model_is_free=True
|
|
)
|
|
assert "credits.depleted" in to_clear
|
|
restored = [n for n in to_show if n.key == "credits.restored"]
|
|
assert len(restored) == 1
|
|
|
|
def test_free_flag_does_not_affect_other_notices(self):
|
|
"""Usage-band and grant notices are independent of the model-free gate."""
|
|
latch = fresh_latch()
|
|
evaluate_credits_notices(state_with_fraction(0.10), latch, model_is_free=True)
|
|
to_show, _ = evaluate_credits_notices(
|
|
state_with_fraction(0.95, paid_access=False), latch, model_is_free=True
|
|
)
|
|
keys = [n.key for n in to_show]
|
|
assert "credits.usage" in keys
|
|
assert "credits.depleted" not in keys
|
|
|
|
|
|
# ── Scenario 5c: is_free_tier_model (local-data-only check) ──────────────────
|
|
|
|
|
|
class TestIsFreeTierModel:
|
|
def test_free_suffix_is_free(self):
|
|
from agent.credits_tracker import is_free_tier_model
|
|
|
|
assert is_free_tier_model("nvidia/nemotron-3-ultra:free") is True
|
|
assert is_free_tier_model("Hermes-4-70B:free", "https://inference-api.nousresearch.com") is True
|
|
|
|
def test_empty_or_paid_model_is_not_free(self):
|
|
from agent.credits_tracker import is_free_tier_model
|
|
|
|
assert is_free_tier_model("") is False
|
|
assert is_free_tier_model("Hermes-4-405B") is False
|
|
|
|
def test_pricing_cache_peek_zero_priced_model(self, monkeypatch):
|
|
from agent.credits_tracker import is_free_tier_model
|
|
import hermes_cli.models as models_mod
|
|
|
|
# The picker keys the cache on the pre-/v1 root (get_pricing_for_provider
|
|
# strips a trailing /v1 before fetch_models_with_pricing).
|
|
monkeypatch.setattr(
|
|
models_mod,
|
|
"_pricing_cache",
|
|
{
|
|
"https://inference-api.nousresearch.com": {
|
|
"some/zero-priced": {"prompt": "0", "completion": "0"},
|
|
"some/paid": {"prompt": "0.000001", "completion": "0.000002"},
|
|
}
|
|
},
|
|
)
|
|
# The agent holds the /v1-suffixed URL (DEFAULT_NOUS_INFERENCE_URL) —
|
|
# the helper must normalize it down to the picker's cache key.
|
|
base = "https://inference-api.nousresearch.com/v1"
|
|
assert is_free_tier_model("some/zero-priced", base) is True
|
|
assert is_free_tier_model("some/paid", base) is False
|
|
# Pre-stripped and trailing-slash variants resolve to the same key.
|
|
assert is_free_tier_model("some/zero-priced", "https://inference-api.nousresearch.com/") is True
|
|
assert is_free_tier_model("some/zero-priced", "https://inference-api.nousresearch.com/v1/") is True
|
|
|
|
def test_cache_miss_is_not_free_and_no_fetch(self, monkeypatch):
|
|
from agent.credits_tracker import is_free_tier_model
|
|
import hermes_cli.models as models_mod
|
|
|
|
monkeypatch.setattr(models_mod, "_pricing_cache", {})
|
|
|
|
def _boom(*args, **kwargs): # any network attempt fails the test
|
|
raise AssertionError("is_free_tier_model must never hit the network")
|
|
|
|
import urllib.request
|
|
|
|
monkeypatch.setattr(urllib.request, "urlopen", _boom)
|
|
assert is_free_tier_model("some/model", "https://inference-api.nousresearch.com/v1") is False
|
|
|
|
def test_exception_fails_open_to_false(self, monkeypatch):
|
|
from agent.credits_tracker import is_free_tier_model
|
|
import hermes_cli.models as models_mod
|
|
|
|
class _Exploding:
|
|
def get(self, *_a, **_kw):
|
|
raise RuntimeError("boom")
|
|
|
|
monkeypatch.setattr(models_mod, "_pricing_cache", _Exploding())
|
|
assert is_free_tier_model("some/model", "https://inference-api.nousresearch.com") is False
|
|
|
|
|
|
# ── Scenario 6: denominator none (uf is None) ────────────────────────────────
|
|
|
|
|
|
class TestDenominatorNone:
|
|
def test_no_warn90_when_uf_none(self):
|
|
latch = fresh_latch()
|
|
s = state_with_fraction(None)
|
|
to_show, to_clear = evaluate_credits_notices(s, latch)
|
|
assert all(n.key != "credits.usage" for n in to_show)
|
|
assert "credits.usage" not in to_clear
|
|
|
|
def test_no_grant_spent_when_uf_none(self):
|
|
latch = fresh_latch()
|
|
s = CreditsState(
|
|
subscription_limit_micros=None,
|
|
denominator_kind="none",
|
|
purchased_micros=5_000_000,
|
|
purchased_usd="5.00",
|
|
)
|
|
to_show, to_clear = evaluate_credits_notices(s, latch)
|
|
assert all(n.key != "credits.grant_spent" for n in to_show)
|
|
|
|
def test_warn90_clears_when_uf_becomes_none(self):
|
|
"""If warn90 was active and uf becomes None, it should clear."""
|
|
latch = fresh_latch()
|
|
# Establish usage notice active: cross below → above
|
|
evaluate_credits_notices(state_with_fraction(0.10), latch)
|
|
evaluate_credits_notices(state_with_fraction(0.95), latch)
|
|
assert "credits.usage" in latch["active"]
|
|
# Now uf becomes None (denominator changed to "none")
|
|
s_none = state_with_fraction(None)
|
|
to_show, to_clear = evaluate_credits_notices(s_none, latch)
|
|
assert "credits.usage" in to_clear
|
|
assert "credits.usage" not in latch["active"]
|
|
|
|
|
|
# ── Scenario 7: copy / verbatim USD strings ──────────────────────────────────
|
|
|
|
|
|
class TestNoticeCopy:
|
|
def test_warn90_contains_verbatim_subscription_limit_usd(self):
|
|
latch = fresh_latch()
|
|
evaluate_credits_notices(state_with_fraction(0.10), latch)
|
|
s = state_with_fraction(0.95, subscription_limit_usd="20.00")
|
|
to_show, _ = evaluate_credits_notices(s, latch)
|
|
warn_notice = next(n for n in to_show if n.key == "credits.usage")
|
|
assert "$20.00" in warn_notice.text
|
|
assert "cap" in warn_notice.text
|
|
|
|
def test_grant_spent_contains_verbatim_purchased_usd(self):
|
|
latch = fresh_latch()
|
|
s = state_with_fraction(
|
|
1.0,
|
|
denominator_kind="subscription_cap",
|
|
purchased_micros=12_340_000,
|
|
purchased_usd="12.34",
|
|
)
|
|
to_show, _ = evaluate_credits_notices(s, latch)
|
|
grant_notice = next(n for n in to_show if n.key == "credits.grant_spent")
|
|
assert "$12.34" in grant_notice.text
|
|
assert "top-up left" in grant_notice.text
|
|
|
|
def test_depleted_mentions_credits_command(self):
|
|
latch = fresh_latch()
|
|
s = CreditsState(paid_access=False)
|
|
to_show, _ = evaluate_credits_notices(s, latch)
|
|
depleted_notice = next(n for n in to_show if n.key == "credits.depleted")
|
|
assert "/credits" in depleted_notice.text
|
|
|
|
|
|
# ── Scenario 8: severity order in a single call ──────────────────────────────
|
|
|
|
|
|
class TestSeverityOrder:
|
|
def test_multiple_new_notices_ordered_ascending_severity(self):
|
|
"""grant_spent < depleted in to_show when both fire in one call.
|
|
|
|
(usage is suppressed here: purchased>0 — see TestTopUpSuppression.
|
|
usage + grant_spent are now mutually exclusive by design.)
|
|
"""
|
|
latch = {"active": set(), "seen_below_90": True, "usage_band": None}
|
|
|
|
# Build state: subscription_cap, uf >= 1.0, purchased_micros > 0, NOT paid_access
|
|
# grant_cond: subscription_cap + uf >= 1.0 + purchased > 0 ✓
|
|
# depleted_cond: not paid_access ✓
|
|
# usage band: suppressed (purchased > 0)
|
|
s = CreditsState(
|
|
subscription_limit_micros=20_000_000,
|
|
subscription_limit_usd="20.00",
|
|
subscription_micros=0, # uf = 1.0
|
|
denominator_kind="subscription_cap",
|
|
purchased_micros=5_000_000,
|
|
purchased_usd="5.00",
|
|
paid_access=False,
|
|
)
|
|
to_show, _ = evaluate_credits_notices(s, latch)
|
|
keys = [n.key for n in to_show]
|
|
assert "credits.usage" not in keys
|
|
assert "credits.grant_spent" in keys
|
|
assert "credits.depleted" in keys
|
|
# Ascending severity: grant_spent before depleted
|
|
assert keys.index("credits.grant_spent") < keys.index("credits.depleted")
|
|
|
|
def test_usage_before_depleted_without_topup(self):
|
|
"""With no top-up funds, usage fires and precedes depleted."""
|
|
latch = {"active": set(), "seen_below_90": True, "usage_band": None}
|
|
s = CreditsState(
|
|
subscription_limit_micros=20_000_000,
|
|
subscription_limit_usd="20.00",
|
|
subscription_micros=0, # uf = 1.0
|
|
denominator_kind="subscription_cap",
|
|
purchased_micros=0,
|
|
purchased_usd="0.00",
|
|
paid_access=False,
|
|
)
|
|
to_show, _ = evaluate_credits_notices(s, latch)
|
|
keys = [n.key for n in to_show]
|
|
assert "credits.usage" in keys
|
|
assert "credits.depleted" in keys
|
|
assert keys.index("credits.usage") < keys.index("credits.depleted")
|
|
|
|
|
|
# ── Scenario 8b: top-up suppression of the usage gauge ───────────────────────
|
|
|
|
|
|
class TestTopUpSuppression:
|
|
"""purchased_micros > 0 suppresses the sub-cap usage gauge: the cap is the
|
|
wrong denominator for an account that can keep spending top-up funds."""
|
|
|
|
def test_no_usage_band_with_topup_at_90pct(self):
|
|
latch = fresh_latch()
|
|
evaluate_credits_notices(
|
|
state_with_fraction(0.10, purchased_micros=5_000_000, purchased_usd="5.00"),
|
|
latch,
|
|
)
|
|
to_show, to_clear = evaluate_credits_notices(
|
|
state_with_fraction(0.95, purchased_micros=5_000_000, purchased_usd="5.00"),
|
|
latch,
|
|
)
|
|
assert all(n.key != "credits.usage" for n in to_show)
|
|
assert latch["usage_band"] is None
|
|
|
|
def test_topup_landing_mid_session_clears_active_band(self):
|
|
"""A showing 90% warn must clear when a top-up lands (purchased 0 → >0)."""
|
|
latch = fresh_latch()
|
|
evaluate_credits_notices(state_with_fraction(0.10), latch)
|
|
evaluate_credits_notices(state_with_fraction(0.95), latch)
|
|
assert latch["usage_band"] == 90
|
|
to_show, to_clear = evaluate_credits_notices(
|
|
state_with_fraction(0.95, purchased_micros=10_000_000, purchased_usd="10.00"),
|
|
latch,
|
|
)
|
|
assert "credits.usage" in to_clear
|
|
assert latch["usage_band"] is None
|
|
assert all(n.key != "credits.usage" for n in to_show)
|
|
|
|
def test_band_resumes_after_topup_spent(self):
|
|
"""purchased back to 0 with usage still in-band → gauge resumes."""
|
|
latch = fresh_latch()
|
|
evaluate_credits_notices(state_with_fraction(0.10), latch)
|
|
evaluate_credits_notices(
|
|
state_with_fraction(0.95, purchased_micros=10_000_000, purchased_usd="10.00"),
|
|
latch,
|
|
)
|
|
assert latch["usage_band"] is None
|
|
to_show, _ = evaluate_credits_notices(state_with_fraction(0.95), latch)
|
|
n = next(n for n in to_show if n.key == "credits.usage")
|
|
assert "90%" in n.text
|
|
assert latch["usage_band"] == 90
|
|
|
|
def test_grant_spent_still_fires_with_topup(self):
|
|
"""Suppression only affects the gauge — grant_spent (which NEEDS purchased>0)
|
|
is untouched."""
|
|
latch = fresh_latch()
|
|
s = state_with_fraction(
|
|
1.0,
|
|
denominator_kind="subscription_cap",
|
|
purchased_micros=12_340_000,
|
|
purchased_usd="12.34",
|
|
)
|
|
to_show, _ = evaluate_credits_notices(s, latch)
|
|
keys = [n.key for n in to_show]
|
|
assert "credits.grant_spent" in keys
|
|
assert "credits.usage" not in keys
|
|
|
|
def test_depleted_unaffected_by_topup_suppression(self):
|
|
latch = fresh_latch()
|
|
s = CreditsState(paid_access=False, purchased_micros=5_000_000, purchased_usd="5.00")
|
|
to_show, _ = evaluate_credits_notices(s, latch)
|
|
assert any(n.key == "credits.depleted" for n in to_show)
|
|
|
|
|
|
# ── Invariant: never fire + clear same key in one call ────────────────────────
|
|
|
|
|
|
class TestNoFireAndClearSameKey:
|
|
def test_usage_never_both_fired_and_cleared(self):
|
|
latch = fresh_latch()
|
|
# Run many state transitions; across each, assert no key is in both lists
|
|
states = [
|
|
state_with_fraction(0.10),
|
|
state_with_fraction(0.95),
|
|
state_with_fraction(0.10),
|
|
state_with_fraction(0.95),
|
|
state_with_fraction(None),
|
|
]
|
|
for s in states:
|
|
to_show, to_clear = evaluate_credits_notices(s, latch)
|
|
fired_keys = {n.key for n in to_show}
|
|
cleared_keys = set(to_clear)
|
|
overlap = fired_keys & cleared_keys
|
|
assert not overlap, f"Key(s) both fired and cleared: {overlap}"
|
|
|
|
def test_depleted_never_both_fired_and_cleared(self):
|
|
latch = fresh_latch()
|
|
states = [
|
|
CreditsState(paid_access=False),
|
|
CreditsState(paid_access=True),
|
|
CreditsState(paid_access=False),
|
|
]
|
|
for s in states:
|
|
to_show, to_clear = evaluate_credits_notices(s, latch)
|
|
fired_keys = {n.key for n in to_show}
|
|
cleared_keys = set(to_clear)
|
|
overlap = fired_keys & cleared_keys
|
|
assert not overlap, f"Key(s) both fired and cleared: {overlap}"
|
|
|
|
|
|
# ── Scenario 9: escalating usage bands (50 → 75 → 90) ────────────────────────
|
|
|
|
|
|
class TestUsageBands:
|
|
"""The usage notice shows the HIGHEST crossed band as a single escalating line."""
|
|
|
|
def _band_text(self, to_show):
|
|
n = next((n for n in to_show if n.key == "credits.usage"), None)
|
|
return n.text if n else None
|
|
|
|
def test_50_band_fires_info(self):
|
|
latch = fresh_latch()
|
|
evaluate_credits_notices(state_with_fraction(0.10), latch) # prime
|
|
to_show, _ = evaluate_credits_notices(state_with_fraction(0.55), latch)
|
|
n = next(n for n in to_show if n.key == "credits.usage")
|
|
assert "50%" in n.text and n.level == "info"
|
|
assert latch["usage_band"] == 50
|
|
|
|
def test_75_band_fires_warn(self):
|
|
latch = fresh_latch()
|
|
evaluate_credits_notices(state_with_fraction(0.10), latch)
|
|
to_show, _ = evaluate_credits_notices(state_with_fraction(0.80), latch)
|
|
n = next(n for n in to_show if n.key == "credits.usage")
|
|
assert "75%" in n.text and n.level == "warn"
|
|
assert latch["usage_band"] == 75
|
|
|
|
def test_climb_replaces_band(self):
|
|
"""Climbing 50→75→90 replaces the single line (clear old + show new)."""
|
|
latch = fresh_latch()
|
|
evaluate_credits_notices(state_with_fraction(0.10), latch)
|
|
# 55% → 50 band
|
|
evaluate_credits_notices(state_with_fraction(0.55), latch)
|
|
assert latch["usage_band"] == 50
|
|
# 80% → climbs to 75, clearing the 50 line
|
|
to_show, to_clear = evaluate_credits_notices(state_with_fraction(0.80), latch)
|
|
assert "credits.usage" in to_clear
|
|
assert "75%" in self._band_text(to_show)
|
|
assert latch["usage_band"] == 75
|
|
# 95% → climbs to 90
|
|
to_show, to_clear = evaluate_credits_notices(state_with_fraction(0.95), latch)
|
|
assert "credits.usage" in to_clear
|
|
assert "90%" in self._band_text(to_show)
|
|
assert latch["usage_band"] == 90
|
|
|
|
def test_step_down_on_recovery(self):
|
|
"""Recovering steps the band back down, then clears below the lowest band."""
|
|
latch = fresh_latch()
|
|
evaluate_credits_notices(state_with_fraction(0.10), latch)
|
|
evaluate_credits_notices(state_with_fraction(0.95), latch)
|
|
assert latch["usage_band"] == 90
|
|
# drop to 80% → steps down to 75
|
|
to_show, to_clear = evaluate_credits_notices(state_with_fraction(0.80), latch)
|
|
assert "credits.usage" in to_clear
|
|
assert "75%" in self._band_text(to_show)
|
|
# drop to 55% → steps down to 50
|
|
to_show, _ = evaluate_credits_notices(state_with_fraction(0.55), latch)
|
|
assert "50%" in self._band_text(to_show)
|
|
# drop below 50% → clears entirely
|
|
to_show, to_clear = evaluate_credits_notices(state_with_fraction(0.10), latch)
|
|
assert "credits.usage" in to_clear
|
|
assert latch["usage_band"] is None
|
|
|
|
def test_no_refire_same_band(self):
|
|
latch = fresh_latch()
|
|
evaluate_credits_notices(state_with_fraction(0.10), latch)
|
|
evaluate_credits_notices(state_with_fraction(0.80), latch) # fires 75
|
|
# still 80% → same band, no re-emit, no clear
|
|
to_show, to_clear = evaluate_credits_notices(state_with_fraction(0.80), latch)
|
|
assert all(n.key != "credits.usage" for n in to_show)
|
|
assert "credits.usage" not in to_clear
|
|
|
|
def test_exact_band_boundaries_inclusive(self):
|
|
"""Thresholds are inclusive: exactly 0.50 / 0.75 / 0.90 land in their band."""
|
|
for uf, want in [(0.50, 50), (0.75, 75), (0.90, 90)]:
|
|
latch = fresh_latch()
|
|
latch["seen_below_90"] = True # allow firing
|
|
evaluate_credits_notices(state_with_fraction(uf), latch)
|
|
assert latch["usage_band"] == want, (uf, latch["usage_band"])
|
|
|
|
def test_open_below_lowest_band_no_notice(self):
|
|
latch = fresh_latch()
|
|
to_show, to_clear = evaluate_credits_notices(state_with_fraction(0.30), latch)
|
|
assert all(n.key != "credits.usage" for n in to_show)
|
|
assert latch["usage_band"] is None
|