Mercury Genesys Token Calculator
This commit is contained in:
70
calculators/Genesys_Token_Calculator/tests/conftest.py
Normal file
70
calculators/Genesys_Token_Calculator/tests/conftest.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""Test plumbing: import path + notebook content served from tagged cells.
|
||||
|
||||
Content and client data live in the notebook, never in ``.py`` — the cells
|
||||
tagged ``topic-bank`` (the feature catalogue) and ``engagement-data`` (the
|
||||
client's volumes and commercial terms) in
|
||||
``notebooks/genesys_token_calculator.ipynb``. The fixtures below read those
|
||||
cells with nbformat and exec them, so pytest pins the exact content the
|
||||
deliverable ships (no kernel needed — tagged cells are self-contained by
|
||||
contract).
|
||||
|
||||
The published Genesys rate card is deliberately NOT here: it is a vendor
|
||||
record nobody in this repo authors, so it lives in ``genesyscalc/ratecard.py``
|
||||
as an immutable anchor and is pinned by ``test_rate_card.py`` (see
|
||||
docs/Calculator_Pattern_V1-00.md).
|
||||
|
||||
The sys.path insert makes genesyscalc importable even without the master
|
||||
venv active (the normal setup is ``pip install -e ".[dev]"`` into the
|
||||
master-local ``.venv/``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pathlib
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
MASTER_ROOT = pathlib.Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(MASTER_ROOT))
|
||||
|
||||
NOTEBOOK = MASTER_ROOT / "notebooks" / "genesys_token_calculator.ipynb"
|
||||
|
||||
|
||||
def tagged_cell_ns(tag: str) -> dict[str, Any]:
|
||||
"""Exec the single cell carrying ``tag`` and return its namespace."""
|
||||
import nbformat
|
||||
|
||||
nb = nbformat.read(NOTEBOOK, as_version=4)
|
||||
cells = [c for c in nb.cells if tag in c.metadata.get("tags", [])]
|
||||
assert len(cells) == 1, (
|
||||
f"expected exactly one cell tagged {tag!r} in {NOTEBOOK.name}, "
|
||||
f"found {len(cells)}"
|
||||
)
|
||||
ns: dict[str, Any] = {}
|
||||
exec(compile(cells[0].source, f"{NOTEBOOK.name} [{tag}]", "exec"), ns)
|
||||
return ns
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def catalogue_ns() -> dict[str, Any]:
|
||||
"""The executed namespace of the notebook's topic-bank cell."""
|
||||
return tagged_cell_ns("topic-bank")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def features(catalogue_ns: dict[str, Any]) -> tuple[Any, ...]:
|
||||
"""The FEATURES tuple as the deliverable defines it."""
|
||||
return catalogue_ns["FEATURES"] # type: ignore[no-any-return]
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def feature_by_key(features: tuple[Any, ...]) -> dict[str, Any]:
|
||||
return {f.key: f for f in features}
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def engagement() -> dict[str, Any]:
|
||||
"""The ENGAGEMENT dict as the deliverable's engagement-data cell ships it."""
|
||||
return tagged_cell_ns("engagement-data")["ENGAGEMENT"] # type: ignore[no-any-return]
|
||||
154
calculators/Genesys_Token_Calculator/tests/test_billing.py
Normal file
154
calculators/Genesys_Token_Calculator/tests/test_billing.py
Normal file
@@ -0,0 +1,154 @@
|
||||
"""Tokens → dollars: the allowance, the rounding, and the commercial levers.
|
||||
|
||||
Hand-checked first, then pinned.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from genesyscalc.billing import (
|
||||
MONTHS_PER_YEAR,
|
||||
allowance_for,
|
||||
billable_tokens_monthly,
|
||||
cost_lines,
|
||||
total_cost,
|
||||
)
|
||||
from genesyscalc.meters import LicenceModel, TokenPrice
|
||||
|
||||
NAMED = LicenceModel.NAMED
|
||||
CONCURRENT = LicenceModel.CONCURRENT
|
||||
|
||||
|
||||
# ── the free monthly allowance ───────────────────────────────────────────
|
||||
|
||||
|
||||
def test_free_allowance_deducted_named() -> None:
|
||||
"""1,000 consumption tokens, named org: 1,000 − 250 = 750 billable."""
|
||||
assert billable_tokens_monthly(1_000, 0, NAMED) == 750
|
||||
|
||||
|
||||
def test_free_allowance_deducted_concurrent() -> None:
|
||||
"""Same consumption, concurrent org: 1,000 − 350 = 650."""
|
||||
assert billable_tokens_monthly(1_000, 0, CONCURRENT) == 650
|
||||
|
||||
|
||||
def test_allowance_floors_at_zero() -> None:
|
||||
assert billable_tokens_monthly(120, 0, NAMED) == 0
|
||||
|
||||
|
||||
def test_allowance_does_not_carry_over() -> None:
|
||||
"""Two quiet months do not bank credit for a busy third.
|
||||
|
||||
120 → 0 billable. 120 → 0 billable. 400 → 150 billable, NOT 0: the
|
||||
unused 130 + 130 is discarded, because the allowance "renew[s] each
|
||||
month and do[es] not carry over to future months".
|
||||
"""
|
||||
assert billable_tokens_monthly(120, 0, NAMED) == 0
|
||||
assert billable_tokens_monthly(120, 0, NAMED) == 0
|
||||
assert billable_tokens_monthly(400, 0, NAMED) == 150
|
||||
|
||||
|
||||
def test_allowance_can_be_switched_off() -> None:
|
||||
"""So the gap is measurable on stage rather than merely asserted."""
|
||||
assert billable_tokens_monthly(1_000, 0, NAMED, apply_allowance=False) == 1_000
|
||||
assert allowance_for(NAMED, apply_allowance=False) == 0
|
||||
assert allowance_for(NAMED) == 250
|
||||
|
||||
|
||||
def test_allowance_applies_across_consumption_and_subscription() -> None:
|
||||
"""It is an org-level deduction, not a per-line one.
|
||||
|
||||
44.12 consumption → ceil 45; + 20,000 subscription = 20,045; − 250 =
|
||||
19,795.
|
||||
"""
|
||||
assert billable_tokens_monthly(44.117647, 20_000, NAMED) == 19_795
|
||||
|
||||
|
||||
# ── rounding ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_consumption_tokens_rounded_up_monthly() -> None:
|
||||
"""44.117647 tokens of bot time bills as 45."""
|
||||
assert billable_tokens_monthly(44.117647, 0, NAMED, apply_allowance=False) == 45
|
||||
|
||||
|
||||
def test_per_user_tokens_are_exact_not_rounded() -> None:
|
||||
"""500 named users × 40 = 20,000 exactly — no ceil on subscription."""
|
||||
assert billable_tokens_monthly(0, 20_000, NAMED, apply_allowance=False) == 20_000
|
||||
|
||||
|
||||
def test_negative_tokens_rejected() -> None:
|
||||
with pytest.raises(ValueError, match="must not be negative"):
|
||||
billable_tokens_monthly(-1, 0, NAMED)
|
||||
|
||||
|
||||
# ── price, concession, currency ──────────────────────────────────────────
|
||||
|
||||
|
||||
def test_concession_applies_to_msrp() -> None:
|
||||
"""$1.00 list, 30% concession → $0.70/token; 1,000 tokens → $700/mo."""
|
||||
price = TokenPrice(list_rate=1.00, concession_pct=0.30)
|
||||
assert price.effective_rate() == pytest.approx(0.70)
|
||||
totals = total_cost(1_000, 0, NAMED, price, apply_allowance=False)
|
||||
assert totals.token_cost_monthly == pytest.approx(700.0)
|
||||
assert totals.token_cost_annual == pytest.approx(8_400.0)
|
||||
|
||||
|
||||
def test_msrp_walk_is_carried_alongside() -> None:
|
||||
"""The deck-frame column: list beside effective, and the saving."""
|
||||
price = TokenPrice(list_rate=1.00, concession_pct=0.30)
|
||||
totals = total_cost(1_000, 0, NAMED, price, apply_allowance=False)
|
||||
assert totals.list_cost_annual == pytest.approx(12_000.0)
|
||||
assert totals.concession_saving_annual == pytest.approx(3_600.0)
|
||||
assert totals.token_cost_annual <= totals.list_cost_annual
|
||||
|
||||
|
||||
def test_contracted_rate_overrides_concession() -> None:
|
||||
price = TokenPrice(list_rate=1.00, contracted_rate=0.55, concession_pct=0.30)
|
||||
assert price.effective_rate() == pytest.approx(0.55)
|
||||
|
||||
|
||||
def test_currency_carried_through() -> None:
|
||||
price = TokenPrice(currency="JPY", list_rate=120.0)
|
||||
totals = total_cost(1_000, 0, NAMED, price, apply_allowance=False)
|
||||
assert totals.currency == "JPY"
|
||||
assert totals.token_cost_monthly == pytest.approx(120_000.0)
|
||||
|
||||
|
||||
def test_invalid_concession_rejected() -> None:
|
||||
with pytest.raises(ValueError, match="concession_pct"):
|
||||
TokenPrice(concession_pct=1.0)
|
||||
|
||||
|
||||
# ── cost lines ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_cost_lines_price_each_meter_at_the_effective_rate() -> None:
|
||||
price = TokenPrice(list_rate=1.00, concession_pct=0.25)
|
||||
lines = cost_lines(
|
||||
{"ai_scoring": 100.0, "virtual_agent": 7_500.0},
|
||||
{"ai_scoring": 2_000.0, "virtual_agent": 15_000.0},
|
||||
price,
|
||||
)
|
||||
by_key = {line.key: line for line in lines}
|
||||
assert by_key["ai_scoring"].cost_monthly == pytest.approx(75.0)
|
||||
assert by_key["virtual_agent"].cost_monthly == pytest.approx(5_625.0)
|
||||
assert by_key["virtual_agent"].cost_annual == pytest.approx(
|
||||
5_625.0 * MONTHS_PER_YEAR
|
||||
)
|
||||
assert by_key["ai_scoring"].units_monthly == pytest.approx(2_000.0)
|
||||
assert by_key["ai_scoring"].unit_label == "evaluations"
|
||||
|
||||
|
||||
def test_cost_lines_are_ordered_by_feature_name() -> None:
|
||||
lines = cost_lines(
|
||||
{"virtual_agent": 1.0, "ai_scoring": 1.0}, {}, TokenPrice()
|
||||
)
|
||||
assert [line.feature for line in lines] == ["AI Scoring", "Virtual Agent"]
|
||||
|
||||
|
||||
def test_totals_expose_the_allowance_that_was_applied() -> None:
|
||||
totals = total_cost(1_000, 20_000, NAMED, TokenPrice())
|
||||
assert totals.allowance_tokens_monthly == 250
|
||||
assert totals.gross_tokens_monthly == pytest.approx(21_000.0)
|
||||
assert totals.billable_tokens_monthly == 20_750
|
||||
94
calculators/Genesys_Token_Calculator/tests/test_catalogue.py
Normal file
94
calculators/Genesys_Token_Calculator/tests/test_catalogue.py
Normal file
@@ -0,0 +1,94 @@
|
||||
"""The feature catalogue — content pins, read from the notebook by tag.
|
||||
|
||||
The catalogue is the master's authored content: it lives in the notebook's
|
||||
``topic-bank`` cell, and these tests exec that cell rather than importing a
|
||||
module. The published RATES are not here — they are the vendor's record, in
|
||||
``genesyscalc/ratecard.py``, pinned by ``test_rate_card.py``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from genesyscalc.ratecard import METERS
|
||||
|
||||
EXPECTED_KEYS = (
|
||||
"agent_copilot_named",
|
||||
"speech_and_text_analytics_named",
|
||||
"bots_voice",
|
||||
"virtual_agent",
|
||||
"agentic_virtual_agent",
|
||||
"ai_summary_and_insights",
|
||||
"ai_scoring",
|
||||
"bots_digital",
|
||||
"ai_translate",
|
||||
"predictive_routing",
|
||||
"whatsapp_messaging",
|
||||
"genesys_cloud_copilot",
|
||||
"predictive_engagement",
|
||||
)
|
||||
|
||||
|
||||
def test_catalogue_size(features: tuple[Any, ...]) -> None:
|
||||
assert len(features) == 13
|
||||
|
||||
|
||||
def test_catalogue_keys_and_order(features: tuple[Any, ...]) -> None:
|
||||
"""Keys are stable identities — widgets, notes and the export key off them."""
|
||||
assert tuple(f.key for f in features) == EXPECTED_KEYS
|
||||
|
||||
|
||||
def test_keys_are_unique(features: tuple[Any, ...]) -> None:
|
||||
assert len({f.key for f in features}) == len(features)
|
||||
|
||||
|
||||
def test_every_catalogue_key_is_a_published_meter(features: tuple[Any, ...]) -> None:
|
||||
"""The cross-layer tie: a rename here silently unprices a feature."""
|
||||
for f in features:
|
||||
assert f.key in METERS, f"{f.key} is not a published Genesys meter"
|
||||
|
||||
|
||||
def test_every_feature_carries_its_content(features: tuple[Any, ...]) -> None:
|
||||
for f in features:
|
||||
assert f.title.strip(), f"{f.key} has no title"
|
||||
assert f.description.strip(), f"{f.key} has no description"
|
||||
assert f.ask.strip(), f"{f.key} has no sizing question"
|
||||
assert f.ask.strip().endswith(("?", ")")), (
|
||||
f"{f.key}: `ask` should read as a question to put to the client"
|
||||
)
|
||||
|
||||
|
||||
def test_titles_are_distinct(features: tuple[Any, ...]) -> None:
|
||||
"""Titles become widget labels; duplicates collide in the sidebar."""
|
||||
titles = [f.title for f in features]
|
||||
assert len(set(titles)) == len(titles)
|
||||
|
||||
|
||||
def test_default_scenario_is_a_coherent_starting_point(
|
||||
features: tuple[Any, ...],
|
||||
) -> None:
|
||||
"""Headless nbconvert takes every widget at its seed, so the defaults
|
||||
must form a scenario the gate can pass — and a plausible one to show."""
|
||||
default_on = {f.key for f in features if f.default_on}
|
||||
assert len(default_on) == 8
|
||||
assert "agent_copilot_named" in default_on
|
||||
assert "bots_voice" in default_on
|
||||
# Copilot on by default means the summary line must be visible but free —
|
||||
# that pairing is the published exclusion the calculator demonstrates.
|
||||
assert "ai_summary_and_insights" in default_on
|
||||
|
||||
|
||||
def test_catalogue_uses_a_schema_not_loose_dicts(features: tuple[Any, ...]) -> None:
|
||||
"""Content is structured, so the notebook and tests agree on its shape."""
|
||||
first = features[0]
|
||||
assert type(first).__name__ == "Feature"
|
||||
assert {"key", "title", "description", "ask", "default_on"} <= set(
|
||||
type(first).__dataclass_fields__
|
||||
)
|
||||
|
||||
|
||||
def test_catalogue_cell_is_self_contained(catalogue_ns: dict[str, Any]) -> None:
|
||||
"""It execs with no setup cell — the conftest fixture proves it, but pin
|
||||
the contract explicitly so a stray dependency is caught here."""
|
||||
assert "FEATURES" in catalogue_ns
|
||||
assert "Feature" in catalogue_ns
|
||||
115
calculators/Genesys_Token_Calculator/tests/test_engagement.py
Normal file
115
calculators/Genesys_Token_Calculator/tests/test_engagement.py
Normal file
@@ -0,0 +1,115 @@
|
||||
"""Engagement data — SHAPE pins only.
|
||||
|
||||
The master ships placeholders; an engagement copy fills them in. So these
|
||||
tests pin the *shape* of the cell and never its emptiness — asserting that
|
||||
``client`` is blank would fail the moment the copy is used for real, which
|
||||
is precisely backwards.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import nbformat
|
||||
|
||||
from tests.conftest import NOTEBOOK
|
||||
|
||||
IDENTITY_KEYS = {"client", "prepared_date", "prepared_by"}
|
||||
|
||||
VOLUME_KEYS = {
|
||||
"voice_inbound_monthly",
|
||||
"voice_outbound_monthly",
|
||||
"digital_sessions_monthly",
|
||||
"email_monthly",
|
||||
"messaging_monthly",
|
||||
"social_posts_monthly",
|
||||
"social_responses_monthly",
|
||||
"translations_monthly",
|
||||
"evaluations_monthly",
|
||||
"ai_actions_monthly",
|
||||
"routed_interactions_monthly",
|
||||
}
|
||||
|
||||
COMMERCIAL_KEYS = {
|
||||
"agent_users",
|
||||
"licence_model",
|
||||
"currency",
|
||||
"contracted_rate_per_token",
|
||||
"concession_pct",
|
||||
}
|
||||
|
||||
TTS_KEYS = {"tts_chars_per_call", "tts_voice_tier"}
|
||||
|
||||
|
||||
def test_engagement_shape(engagement: dict[str, Any]) -> None:
|
||||
assert set(engagement) == (
|
||||
IDENTITY_KEYS | VOLUME_KEYS | COMMERCIAL_KEYS | TTS_KEYS
|
||||
)
|
||||
|
||||
|
||||
def test_identity_fields_are_strings(engagement: dict[str, Any]) -> None:
|
||||
"""Blank in the master, filled in the copy — either way, strings."""
|
||||
for key in IDENTITY_KEYS:
|
||||
assert isinstance(engagement[key], str)
|
||||
|
||||
|
||||
def test_volumes_are_non_negative_numbers(engagement: dict[str, Any]) -> None:
|
||||
for key in VOLUME_KEYS:
|
||||
value = engagement[key]
|
||||
assert isinstance(value, (int, float)) and not isinstance(value, bool)
|
||||
assert value >= 0, f"{key} must not be negative"
|
||||
|
||||
|
||||
def test_commercial_terms_are_well_formed(engagement: dict[str, Any]) -> None:
|
||||
assert isinstance(engagement["agent_users"], int)
|
||||
assert engagement["agent_users"] >= 0
|
||||
assert engagement["licence_model"] in ("named", "concurrent")
|
||||
assert isinstance(engagement["currency"], str) and engagement["currency"]
|
||||
assert 0.0 <= float(engagement["concession_pct"]) < 1.0
|
||||
contracted = engagement["contracted_rate_per_token"]
|
||||
assert contracted is None or float(contracted) >= 0
|
||||
|
||||
|
||||
def test_currency_is_one_the_rate_card_publishes(engagement: dict[str, Any]) -> None:
|
||||
from genesyscalc.ratecard import LIST_PRICE_BY_CURRENCY
|
||||
|
||||
assert engagement["currency"] in LIST_PRICE_BY_CURRENCY
|
||||
|
||||
|
||||
def test_tts_settings(engagement: dict[str, Any]) -> None:
|
||||
from genesyscalc.tts import TTS_PRICE_PER_MILLION_CHARS
|
||||
|
||||
assert isinstance(engagement["tts_chars_per_call"], int)
|
||||
assert engagement["tts_chars_per_call"] >= 0
|
||||
assert engagement["tts_voice_tier"] in TTS_PRICE_PER_MILLION_CHARS
|
||||
|
||||
|
||||
def test_master_carries_no_client_identity(engagement: dict[str, Any]) -> None:
|
||||
"""The one emptiness check that IS correct: masters stay client-clean.
|
||||
|
||||
This guards the master in THIS repo. An engagement copy lives outside
|
||||
Palladium, so it never runs this suite — filling the cell there does not
|
||||
break anything (CLAUDE.md § Confidentiality).
|
||||
"""
|
||||
assert engagement["client"] == "", (
|
||||
"a client name in the master means the copy-out step was skipped — "
|
||||
"copy the directory out of Palladium before entering client data"
|
||||
)
|
||||
|
||||
|
||||
def test_engagement_cell_sits_above_the_widget_cell() -> None:
|
||||
"""Client data must not re-run on a sidebar change (Mercury re-runs only
|
||||
cells below a changed widget)."""
|
||||
nb = nbformat.read(NOTEBOOK, as_version=4)
|
||||
eng = [
|
||||
i
|
||||
for i, c in enumerate(nb.cells)
|
||||
if "engagement-data" in c.metadata.get("tags", [])
|
||||
]
|
||||
widgets = [
|
||||
i
|
||||
for i, c in enumerate(nb.cells)
|
||||
if c.cell_type == "code" and "mr.Select(" in c.source
|
||||
]
|
||||
assert len(eng) == 1 and widgets
|
||||
assert eng[0] < min(widgets)
|
||||
263
calculators/Genesys_Token_Calculator/tests/test_model.py
Normal file
263
calculators/Genesys_Token_Calculator/tests/test_model.py
Normal file
@@ -0,0 +1,263 @@
|
||||
"""End-to-end pricing, hand-checked.
|
||||
|
||||
The reference scenario below is the one the notebook ships as its default.
|
||||
Every figure was computed by hand from the published rates before it was
|
||||
pinned here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from genesyscalc.appendix import result_json
|
||||
from genesyscalc.meters import LicenceModel, TokenPrice
|
||||
from genesyscalc.model import (
|
||||
CalculatorInputs,
|
||||
calculate,
|
||||
compare,
|
||||
cost_per_interaction,
|
||||
metered_units,
|
||||
)
|
||||
from genesyscalc.ratecard import RATE_CARD_SOURCE_DATE
|
||||
from genesyscalc.tts import TtsUsage
|
||||
from genesyscalc.usage import DeflectionMix, VoiceBotUsage, Volumes
|
||||
|
||||
REFERENCE_ENABLED = frozenset(
|
||||
{
|
||||
"agent_copilot_named",
|
||||
"speech_and_text_analytics_named",
|
||||
"bots_voice",
|
||||
"virtual_agent",
|
||||
"agentic_virtual_agent",
|
||||
"ai_summary_and_insights",
|
||||
"ai_scoring",
|
||||
"whatsapp_messaging",
|
||||
"predictive_engagement",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def volumes() -> Volumes:
|
||||
return Volumes(
|
||||
voice_inbound_monthly=100_000,
|
||||
voice_outbound_monthly=20_000,
|
||||
digital_sessions_monthly=8_000,
|
||||
email_monthly=12_000,
|
||||
messaging_monthly=6_000,
|
||||
evaluations_monthly=2_000,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def reference(volumes: Volumes) -> CalculatorInputs:
|
||||
return CalculatorInputs(
|
||||
volumes=volumes,
|
||||
users=500,
|
||||
licence=LicenceModel.NAMED,
|
||||
enabled=REFERENCE_ENABLED,
|
||||
mix=DeflectionMix(
|
||||
bot_only_share=0.30, virtual_agent_share=0.15, agentic_va_share=0.05
|
||||
),
|
||||
price=TokenPrice(),
|
||||
voice_bot=VoiceBotUsage(
|
||||
calls_per_month=30_000, avg_bot_seconds_per_call=40
|
||||
),
|
||||
tts=TtsUsage(calls_monthly=100_000, chars_per_call=2_000),
|
||||
)
|
||||
|
||||
|
||||
# ── the hand-checked reference scenario ──────────────────────────────────
|
||||
|
||||
|
||||
def test_subscription_lines(reference: CalculatorInputs) -> None:
|
||||
"""500 users: Copilot 500×40 = 20,000; STA 500×30 = 15,000 tokens/mo."""
|
||||
by_key = {line.key: line for line in calculate(reference).lines}
|
||||
assert by_key["agent_copilot_named"].tokens_monthly == pytest.approx(20_000.0)
|
||||
assert by_key["speech_and_text_analytics_named"].tokens_monthly == pytest.approx(
|
||||
15_000.0
|
||||
)
|
||||
assert by_key["agent_copilot_named"].cost_annual == pytest.approx(240_000.0)
|
||||
|
||||
|
||||
def test_voice_bot_line(reference: CalculatorInputs) -> None:
|
||||
"""30,000 calls × ceil(40/15)×15s = 45s = 0.75 min → 22,500 min.
|
||||
22,500 / 17 = 1,323.53 tokens/mo → $15,882.35/yr at $1.00."""
|
||||
by_key = {line.key: line for line in calculate(reference).lines}
|
||||
assert by_key["bots_voice"].units_monthly == pytest.approx(22_500.0)
|
||||
assert by_key["bots_voice"].tokens_monthly == pytest.approx(1_323.529412, rel=1e-6)
|
||||
assert by_key["bots_voice"].cost_annual == pytest.approx(15_882.35, abs=0.01)
|
||||
|
||||
|
||||
def test_virtual_agent_lines_use_the_partition(reference: CalculatorInputs) -> None:
|
||||
"""120,000 voice: VA 15% = 18,000 × 0.5 = 9,000 tokens;
|
||||
AVA 5% = 6,000 × 1.2 = 7,200 tokens."""
|
||||
by_key = {line.key: line for line in calculate(reference).lines}
|
||||
assert by_key["virtual_agent"].units_monthly == pytest.approx(18_000.0)
|
||||
assert by_key["virtual_agent"].tokens_monthly == pytest.approx(9_000.0)
|
||||
assert by_key["agentic_virtual_agent"].units_monthly == pytest.approx(6_000.0)
|
||||
assert by_key["agentic_virtual_agent"].tokens_monthly == pytest.approx(7_200.0)
|
||||
|
||||
|
||||
def test_summary_is_zeroed_because_copilot_is_on(reference: CalculatorInputs) -> None:
|
||||
result = calculate(reference)
|
||||
by_key = {line.key: line for line in result.lines}
|
||||
assert by_key["ai_summary_and_insights"].units_monthly == 0.0
|
||||
assert by_key["ai_summary_and_insights"].cost_annual == 0.0
|
||||
assert any("Agent Copilot is enabled" in w for w in result.warnings)
|
||||
|
||||
|
||||
def test_zero_rated_line_is_shown_not_hidden(reference: CalculatorInputs) -> None:
|
||||
"""A published $0 is a finding — it renders as a line, at zero."""
|
||||
by_key = {line.key: line for line in calculate(reference).lines}
|
||||
assert "predictive_engagement" in by_key
|
||||
assert by_key["predictive_engagement"].units_monthly > 0
|
||||
assert by_key["predictive_engagement"].cost_annual == 0.0
|
||||
|
||||
|
||||
def test_reference_totals(reference: CalculatorInputs) -> None:
|
||||
"""consumption 17,638.53 → ceil 17,639; + 35,000 subscription = 52,639;
|
||||
− 250 allowance = 52,389 billable → $52,389/mo → $628,668/yr.
|
||||
TTS 200M chars, 20% VA-free → 160M → $3,200/mo → $38,400/yr.
|
||||
Grand total $667,068/yr."""
|
||||
totals = calculate(reference).totals
|
||||
assert totals.consumption_tokens_monthly == pytest.approx(17_638.529412, rel=1e-6)
|
||||
assert totals.subscription_tokens_monthly == pytest.approx(35_000.0)
|
||||
assert totals.gross_tokens_monthly == pytest.approx(52_639.0)
|
||||
assert totals.allowance_tokens_monthly == 250
|
||||
assert totals.billable_tokens_monthly == 52_389
|
||||
assert totals.token_cost_annual == pytest.approx(628_668.0)
|
||||
|
||||
|
||||
def test_reference_grand_total_splits_tokens_from_tts(
|
||||
reference: CalculatorInputs,
|
||||
) -> None:
|
||||
result = calculate(reference)
|
||||
assert result.tts_cost_annual == pytest.approx(38_400.0)
|
||||
assert result.grand_total_annual == pytest.approx(667_068.0)
|
||||
assert result.grand_total_annual == pytest.approx(
|
||||
result.token_cost_annual + result.tts_cost_annual
|
||||
)
|
||||
|
||||
|
||||
def test_cost_per_interaction(reference: CalculatorInputs) -> None:
|
||||
"""$667,068 / (146,000 × 12) = $0.3807 per interaction."""
|
||||
result = calculate(reference)
|
||||
assert cost_per_interaction(result, reference.volumes) == pytest.approx(
|
||||
0.380747, rel=1e-5
|
||||
)
|
||||
|
||||
|
||||
def test_roundup_warning_is_reported(reference: CalculatorInputs) -> None:
|
||||
assert any("15-second per-call round-up" in w for w in calculate(reference).warnings)
|
||||
|
||||
|
||||
# ── structural ties that must hold at any setting ────────────────────────
|
||||
|
||||
|
||||
def test_lines_sum_to_the_gross_token_count(reference: CalculatorInputs) -> None:
|
||||
result = calculate(reference)
|
||||
assert sum(line.tokens_monthly for line in result.lines) == pytest.approx(
|
||||
result.totals.consumption_tokens_monthly
|
||||
+ result.totals.subscription_tokens_monthly
|
||||
)
|
||||
|
||||
|
||||
def test_effective_cost_never_exceeds_list(reference: CalculatorInputs) -> None:
|
||||
discounted = reference.with_(price=TokenPrice(concession_pct=0.4))
|
||||
result = calculate(discounted)
|
||||
assert result.totals.token_cost_annual <= result.totals.list_cost_annual
|
||||
assert result.totals.concession_saving_annual >= 0
|
||||
|
||||
|
||||
def test_allowance_only_ever_reduces_the_bill(reference: CalculatorInputs) -> None:
|
||||
with_allowance = calculate(reference).totals.billable_tokens_monthly
|
||||
without = calculate(
|
||||
reference.with_(apply_allowance=False)
|
||||
).totals.billable_tokens_monthly
|
||||
assert with_allowance == without - 250
|
||||
|
||||
|
||||
def test_allowance_absorbing_a_month_is_reported() -> None:
|
||||
quiet = CalculatorInputs(
|
||||
volumes=Volumes(voice_inbound_monthly=100),
|
||||
users=0,
|
||||
enabled=frozenset({"ai_scoring"}),
|
||||
)
|
||||
result = calculate(quiet)
|
||||
assert result.totals.billable_tokens_monthly == 0
|
||||
assert any("free monthly allowance" in w for w in result.warnings)
|
||||
|
||||
|
||||
def test_unknown_feature_key_rejected(volumes: Volumes) -> None:
|
||||
with pytest.raises(ValueError, match="not published meters"):
|
||||
CalculatorInputs(volumes=volumes, users=1, enabled=frozenset({"telepathy"}))
|
||||
|
||||
|
||||
def test_metered_units_covers_every_enabled_key(reference: CalculatorInputs) -> None:
|
||||
assert set(metered_units(reference)) == set(reference.enabled)
|
||||
|
||||
|
||||
def test_empty_scenario_costs_nothing() -> None:
|
||||
result = calculate(CalculatorInputs(volumes=Volumes(), users=0))
|
||||
assert result.grand_total_annual == 0.0
|
||||
assert result.lines == ()
|
||||
|
||||
|
||||
# ── licence model, scenarios ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_concurrent_licence_costs_more_per_user(reference: CalculatorInputs) -> None:
|
||||
"""Copilot 40→60 and STA 30→45, so 35,000 → 52,500 subscription tokens."""
|
||||
concurrent = reference.with_(
|
||||
licence=LicenceModel.CONCURRENT,
|
||||
enabled=frozenset(
|
||||
{"agent_copilot_named", "speech_and_text_analytics_named"}
|
||||
),
|
||||
)
|
||||
named = reference.with_(
|
||||
enabled=frozenset({"agent_copilot_named", "speech_and_text_analytics_named"})
|
||||
)
|
||||
assert calculate(named).totals.subscription_tokens_monthly == pytest.approx(35_000.0)
|
||||
assert calculate(concurrent).totals.subscription_tokens_monthly == pytest.approx(
|
||||
52_500.0
|
||||
)
|
||||
|
||||
|
||||
def test_compare_is_monotone_in_deflection(reference: CalculatorInputs) -> None:
|
||||
"""More virtual-agent deflection means more consumption tokens."""
|
||||
rows = compare(
|
||||
{
|
||||
"Conservative": reference.with_(
|
||||
mix=DeflectionMix(bot_only_share=0.20, virtual_agent_share=0.05)
|
||||
),
|
||||
"Base": reference,
|
||||
"Aggressive": reference.with_(
|
||||
mix=DeflectionMix(
|
||||
bot_only_share=0.35,
|
||||
virtual_agent_share=0.25,
|
||||
agentic_va_share=0.10,
|
||||
)
|
||||
),
|
||||
}
|
||||
)
|
||||
assert [r["Scenario"] for r in rows] == ["Conservative", "Base", "Aggressive"]
|
||||
totals = [r["Token cost / yr"] for r in rows]
|
||||
assert totals[0] < totals[1] < totals[2]
|
||||
|
||||
|
||||
# ── the export payload ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_result_json_round_trips_and_carries_provenance(
|
||||
reference: CalculatorInputs,
|
||||
) -> None:
|
||||
payload = result_json(calculate(reference), reference, meta={"client": "Example"})
|
||||
assert json.loads(json.dumps(payload)) == payload
|
||||
assert payload["rate_card"]["source_date"] == RATE_CARD_SOURCE_DATE
|
||||
assert payload["rate_card"]["voice_bot_roundup_seconds"] == 15
|
||||
assert payload["tts_source"]["source_date"] == "2026-05-22"
|
||||
assert payload["totals"]["grand_total_annual"] == pytest.approx(667_068.0)
|
||||
assert payload["meta"]["client"] == "Example"
|
||||
assert len(payload["lines"]) == len(REFERENCE_ENABLED)
|
||||
174
calculators/Genesys_Token_Calculator/tests/test_rate_card.py
Normal file
174
calculators/Genesys_Token_Calculator/tests/test_rate_card.py
Normal file
@@ -0,0 +1,174 @@
|
||||
"""The vendor-change tripwire.
|
||||
|
||||
Every published row is pinned by its exact feature wording AND its exact
|
||||
rate string, so a transcription slip or a vendor republication breaks the
|
||||
build rather than quietly moving a client-facing number. When Genesys
|
||||
republishes, these pins are updated in the SAME commit as the anchor — see
|
||||
the re-anchoring protocol in docs/Calculator_Pattern_V1-00.md.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from genesyscalc.meters import Confidence, LicenceModel, MeterBasis
|
||||
from genesyscalc.ratecard import (
|
||||
ALLOWANCE_CARRIES_OVER,
|
||||
COPILOT_COVERS_SUMMARY_RULE,
|
||||
FREE_TOKENS_PER_MONTH,
|
||||
HIGHEST_TIER_RULE,
|
||||
LIST_PRICE_BY_CURRENCY,
|
||||
METERS,
|
||||
METERS_VERBATIM,
|
||||
RATE_CARD_SOURCE,
|
||||
RATE_CARD_SOURCE_DATE,
|
||||
VOICE_BOT_ROUNDUP_SECONDS,
|
||||
ZERO_RATED_VERBATIM,
|
||||
meter,
|
||||
rate_card_rows,
|
||||
)
|
||||
|
||||
# The published table, as it reads on the page. Transcribed 2026-07-12.
|
||||
PUBLISHED = {
|
||||
"Bots (Voice)": "17 minutes per token",
|
||||
"Bots (Digital)": "51 sessions per token",
|
||||
"Virtual Agent": "0.5 tokens per Virtual Agent interaction",
|
||||
"Agentic Virtual Agent": "1.2 tokens per interaction",
|
||||
"Agent Copilot [named]": "40 tokens per user",
|
||||
"Agent Copilot [concurrent]": "60 tokens per user",
|
||||
"AI Scoring": "20 evaluations per token",
|
||||
"AI Translate": "2 translations per token",
|
||||
"AI Summary and Insights": "50 summaries/insights per token",
|
||||
"Apple Messages for Business": "400 inbound or outbound messages per token",
|
||||
"Facebook Messenger": "400 messages per token",
|
||||
"Instagram Direct Messaging": "400 messages per token",
|
||||
"WhatsApp Messaging": "400 messages per token",
|
||||
"X Direct Messaging": "400 messages per token",
|
||||
"Genesys Cloud Social": "400 social post ingestions per channel per token",
|
||||
"Social Post Responses": "400 outbound messages per channel per token",
|
||||
"Predictive Routing": "17 routes per token",
|
||||
"Speech and Text Analytics [named]": "30 tokens per user",
|
||||
"Speech and Text Analytics [concurrent]": "45 tokens per user",
|
||||
"Genesys Cloud Copilot": "20 AI actions per token",
|
||||
}
|
||||
|
||||
|
||||
def test_published_meter_table_verbatim() -> None:
|
||||
assert {m.feature: m.published_rate for m in METERS_VERBATIM} == PUBLISHED
|
||||
|
||||
|
||||
def test_meter_count() -> None:
|
||||
assert len(METERS_VERBATIM) == 20
|
||||
assert len(ZERO_RATED_VERBATIM) == 2
|
||||
|
||||
|
||||
def test_source_pinned() -> None:
|
||||
"""Bumping the date without updating the pins is the failure mode."""
|
||||
assert RATE_CARD_SOURCE_DATE == "2026-07-12"
|
||||
assert RATE_CARD_SOURCE == (
|
||||
"https://help.genesys.cloud/articles/genesys-cloud-tokens-model/"
|
||||
)
|
||||
|
||||
|
||||
def test_every_confirmed_meter_carries_source_url_and_date() -> None:
|
||||
for m in METERS.values():
|
||||
if m.confidence is Confidence.CONFIRMED:
|
||||
assert m.source_url, f"{m.key} is 🟢 without a source URL"
|
||||
assert m.source_date, f"{m.key} is 🟢 without a source date"
|
||||
|
||||
|
||||
def test_unsourced_confirmed_meter_cannot_be_constructed() -> None:
|
||||
from genesyscalc.meters import Meter
|
||||
|
||||
with pytest.raises(ValueError, match="source URL and date"):
|
||||
Meter(
|
||||
key="made_up",
|
||||
feature="Made Up",
|
||||
published_rate="1 per token",
|
||||
basis=MeterBasis.UNITS_PER_TOKEN,
|
||||
rate=1.0,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("key", "expected"),
|
||||
[
|
||||
("bots_voice", 1 / 17),
|
||||
("bots_digital", 1 / 51),
|
||||
("virtual_agent", 0.5),
|
||||
("agentic_virtual_agent", 1.2),
|
||||
("ai_scoring", 1 / 20),
|
||||
("ai_translate", 1 / 2),
|
||||
("ai_summary_and_insights", 1 / 50),
|
||||
("whatsapp_messaging", 1 / 400),
|
||||
("genesys_cloud_social", 1 / 400),
|
||||
("predictive_routing", 1 / 17),
|
||||
("genesys_cloud_copilot", 1 / 20),
|
||||
],
|
||||
)
|
||||
def test_numeric_rates_match_their_published_strings(key: str, expected: float) -> None:
|
||||
"""Catches a float drifting away from the wording beside it."""
|
||||
assert meter(key).rate == pytest.approx(expected)
|
||||
|
||||
|
||||
def test_per_user_rates() -> None:
|
||||
copilot = meter("agent_copilot_named")
|
||||
assert copilot.tokens_per_user_month(LicenceModel.NAMED) == 40.0
|
||||
assert copilot.tokens_per_user_month(LicenceModel.CONCURRENT) == 60.0
|
||||
|
||||
sta = meter("speech_and_text_analytics_named")
|
||||
assert sta.tokens_per_user_month(LicenceModel.NAMED) == 30.0
|
||||
assert sta.tokens_per_user_month(LicenceModel.CONCURRENT) == 45.0
|
||||
|
||||
|
||||
def test_free_allowance() -> None:
|
||||
assert FREE_TOKENS_PER_MONTH[LicenceModel.NAMED] == 250
|
||||
assert FREE_TOKENS_PER_MONTH[LicenceModel.CONCURRENT] == 350
|
||||
assert ALLOWANCE_CARRIES_OVER is False
|
||||
|
||||
|
||||
def test_voice_roundup_granularity() -> None:
|
||||
assert VOICE_BOT_ROUNDUP_SECONDS == 15
|
||||
|
||||
|
||||
def test_zero_rated_features() -> None:
|
||||
assert {m.key for m in ZERO_RATED_VERBATIM} == {
|
||||
"predictive_engagement",
|
||||
"knowledge_queries",
|
||||
}
|
||||
assert all(m.rate == 0.0 for m in ZERO_RATED_VERBATIM)
|
||||
assert all(m.tokens_for(1_000_000) == 0.0 for m in ZERO_RATED_VERBATIM)
|
||||
|
||||
|
||||
def test_highest_tier_rule_text_verbatim() -> None:
|
||||
"""It renders on stage, so the wording is part of the deliverable."""
|
||||
assert HIGHEST_TIER_RULE == (
|
||||
"In cases where an interaction uses multiple AI resources, such as bot "
|
||||
"flows, virtual agents, and agentic virtual agents, Genesys bases "
|
||||
"charges on the highest tier (price) resource that Genesys Cloud uses "
|
||||
"during the interaction."
|
||||
)
|
||||
|
||||
|
||||
def test_copilot_covers_summary_rule_text_verbatim() -> None:
|
||||
assert COPILOT_COVERS_SUMMARY_RULE == (
|
||||
"if you enable Agent Copilot simultaneously, then Supervisor Copilot "
|
||||
"summaries and insights do not consume tokens"
|
||||
)
|
||||
|
||||
|
||||
def test_list_price() -> None:
|
||||
assert LIST_PRICE_BY_CURRENCY["USD"] == 1.00
|
||||
assert LIST_PRICE_BY_CURRENCY["JPY"] == 120.0
|
||||
|
||||
|
||||
def test_meter_lookup_error_is_useful() -> None:
|
||||
with pytest.raises(KeyError, match="not a published Genesys meter"):
|
||||
meter("nope")
|
||||
|
||||
|
||||
def test_rate_card_rows_cover_every_published_line() -> None:
|
||||
rows = rate_card_rows()
|
||||
assert len(rows) == len(METERS_VERBATIM) + len(ZERO_RATED_VERBATIM)
|
||||
assert rows[0]["Feature"] == "Bots (Voice)"
|
||||
assert all(r["Confidence"] == "🟢" for r in rows)
|
||||
assert all(r["Source"] == RATE_CARD_SOURCE_DATE for r in rows)
|
||||
110
calculators/Genesys_Token_Calculator/tests/test_sensitivity.py
Normal file
110
calculators/Genesys_Token_Calculator/tests/test_sensitivity.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""Sweeps and tornado data."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from genesyscalc.meters import TokenPrice
|
||||
from genesyscalc.model import CalculatorInputs, calculate
|
||||
from genesyscalc.sensitivity import (
|
||||
DRIVERS,
|
||||
driver_value,
|
||||
set_driver,
|
||||
sweep,
|
||||
tornado,
|
||||
)
|
||||
from genesyscalc.tts import TtsUsage
|
||||
from genesyscalc.usage import DeflectionMix, VoiceBotUsage, Volumes
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def base() -> CalculatorInputs:
|
||||
return CalculatorInputs(
|
||||
volumes=Volumes(voice_inbound_monthly=100_000, voice_outbound_monthly=20_000),
|
||||
users=500,
|
||||
enabled=frozenset(
|
||||
{"agent_copilot_named", "bots_voice", "virtual_agent"}
|
||||
),
|
||||
mix=DeflectionMix(bot_only_share=0.30, virtual_agent_share=0.15),
|
||||
price=TokenPrice(),
|
||||
voice_bot=VoiceBotUsage(calls_per_month=30_000, avg_bot_seconds_per_call=40),
|
||||
tts=TtsUsage(calls_monthly=100_000),
|
||||
)
|
||||
|
||||
|
||||
def test_driver_round_trip(base: CalculatorInputs) -> None:
|
||||
assert driver_value(base, "users") == 500
|
||||
assert driver_value(set_driver(base, "users", 750), "users") == 750
|
||||
|
||||
|
||||
def test_nested_driver_round_trip(base: CalculatorInputs) -> None:
|
||||
assert driver_value(base, "concession_pct") == 0.0
|
||||
moved = set_driver(base, "concession_pct", 0.25)
|
||||
assert moved.price.concession_pct == pytest.approx(0.25)
|
||||
assert base.price.concession_pct == 0.0 # inputs are frozen; no mutation
|
||||
|
||||
|
||||
def test_integer_drivers_stay_integers(base: CalculatorInputs) -> None:
|
||||
assert isinstance(set_driver(base, "users", 512.6).users, int)
|
||||
assert set_driver(base, "users", 512.6).users == 513
|
||||
|
||||
|
||||
def test_unknown_driver_is_named(base: CalculatorInputs) -> None:
|
||||
with pytest.raises(KeyError, match="not a known driver"):
|
||||
driver_value(base, "vibes")
|
||||
|
||||
|
||||
def test_sweep_is_monotone_in_token_price(base: CalculatorInputs) -> None:
|
||||
rows = sweep(base, "token_price", [0.5, 1.0, 1.5, 2.0])
|
||||
costs = [r["token_cost_annual"] for r in rows]
|
||||
assert costs == sorted(costs)
|
||||
assert len(rows) == 4
|
||||
|
||||
|
||||
def test_sweep_carries_the_driver_value(base: CalculatorInputs) -> None:
|
||||
rows = sweep(base, "concession_pct", [0.0, 0.25])
|
||||
assert rows[0]["concession_pct"] == 0.0
|
||||
assert rows[1]["grand_total_annual"] < rows[0]["grand_total_annual"]
|
||||
|
||||
|
||||
def test_tornado_is_sorted_by_swing(base: CalculatorInputs) -> None:
|
||||
rows = tornado(base, ["token_price", "users", "bot_seconds", "concession_pct"])
|
||||
swings = [r["swing"] for r in rows]
|
||||
assert swings == sorted(swings, reverse=True)
|
||||
assert all(r["swing"] >= 0 for r in rows)
|
||||
|
||||
|
||||
def test_tornado_brackets_the_baseline(base: CalculatorInputs) -> None:
|
||||
baseline = calculate(base).grand_total_annual
|
||||
for row in tornado(base, ["token_price", "users"]):
|
||||
assert row["low"] <= baseline <= row["high"]
|
||||
assert row["baseline"] == pytest.approx(baseline)
|
||||
|
||||
|
||||
def test_tornado_skips_inactive_levers() -> None:
|
||||
"""No TTS and no voice bot in the scenario — those levers are noise."""
|
||||
plain = CalculatorInputs(
|
||||
volumes=Volumes(voice_inbound_monthly=1_000),
|
||||
users=10,
|
||||
enabled=frozenset({"agent_copilot_named"}),
|
||||
)
|
||||
drivers = [r["driver"] for r in tornado(plain, list(DRIVERS))]
|
||||
assert "bot_seconds" not in drivers
|
||||
assert "tts_chars_per_call" not in drivers
|
||||
assert "users" in drivers
|
||||
|
||||
|
||||
def test_tornado_skips_a_lever_that_would_break_the_partition() -> None:
|
||||
"""Raising a share past the partition is illegal, not a data point."""
|
||||
saturated = CalculatorInputs(
|
||||
volumes=Volumes(voice_inbound_monthly=1_000),
|
||||
users=10,
|
||||
enabled=frozenset({"virtual_agent"}),
|
||||
mix=DeflectionMix(bot_only_share=0.5, virtual_agent_share=0.5),
|
||||
)
|
||||
drivers = [r["driver"] for r in tornado(saturated, ["virtual_agent_share"])]
|
||||
assert drivers == []
|
||||
|
||||
|
||||
def test_invalid_delta_rejected(base: CalculatorInputs) -> None:
|
||||
with pytest.raises(ValueError, match="delta"):
|
||||
tornado(base, ["users"], delta=1.5)
|
||||
61
calculators/Genesys_Token_Calculator/tests/test_staging.py
Normal file
61
calculators/Genesys_Token_Calculator/tests/test_staging.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""Stage/backstage detection.
|
||||
|
||||
Two signals mark the Mercury app view (see staging.py): the app's shadow
|
||||
session name (``__mercury__`` in ``JPY_SESSION_NAME`` — per-view, primary)
|
||||
and the ``MERCURY_CONFIG_DIR`` env var (server-level fallback, only set by
|
||||
``mercury --working-dir``). Either one means "a client may be looking".
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from genesyscalc import staging
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clean_stage_env(monkeypatch):
|
||||
monkeypatch.delenv("MERCURY_CONFIG_DIR", raising=False)
|
||||
monkeypatch.delenv("JPY_SESSION_NAME", raising=False)
|
||||
|
||||
|
||||
def test_backstage_by_default(capsys):
|
||||
assert not staging.on_stage()
|
||||
staging.backstage("visible")
|
||||
assert capsys.readouterr().out == "visible\n"
|
||||
|
||||
|
||||
def test_plain_session_name_is_backstage(monkeypatch, capsys):
|
||||
# A JupyterLab or nbconvert session: the real notebook path, no marker.
|
||||
monkeypatch.setenv("JPY_SESSION_NAME", "notebooks/genesys_token_calculator.ipynb")
|
||||
assert not staging.on_stage()
|
||||
staging.backstage("visible")
|
||||
assert capsys.readouterr().out == "visible\n"
|
||||
|
||||
|
||||
def test_mercury_shadow_session_is_stage(monkeypatch, capsys):
|
||||
# The Mercury app runs against a shadow copy: <stem>__mercury__<id>.ipynb
|
||||
monkeypatch.setenv(
|
||||
"JPY_SESSION_NAME", "notebooks/genesys_token_calculator__mercury__545e5520.ipynb"
|
||||
)
|
||||
assert staging.on_stage()
|
||||
staging.backstage("hidden")
|
||||
assert capsys.readouterr().out == ""
|
||||
|
||||
|
||||
def test_config_dir_fallback_is_stage(monkeypatch, capsys):
|
||||
monkeypatch.setenv("MERCURY_CONFIG_DIR", "/tmp/app")
|
||||
assert staging.on_stage()
|
||||
staging.backstage("hidden")
|
||||
assert capsys.readouterr().out == ""
|
||||
|
||||
|
||||
def test_backstage_md_renders_only_off_stage(monkeypatch, capsys):
|
||||
# Off stage it must emit SOMETHING (rich markdown under a kernel;
|
||||
# IPython's display degrades to print under plain pytest) …
|
||||
staging.backstage_md("**visible**")
|
||||
assert capsys.readouterr().out != ""
|
||||
|
||||
# … and on stage — either signal — nothing at all.
|
||||
monkeypatch.setenv(
|
||||
"JPY_SESSION_NAME", "notebooks/genesys_token_calculator__mercury__ab12cd34.ipynb"
|
||||
)
|
||||
staging.backstage_md("**hidden**")
|
||||
assert capsys.readouterr().out == ""
|
||||
114
calculators/Genesys_Token_Calculator/tests/test_tts.py
Normal file
114
calculators/Genesys_Token_Calculator/tests/test_tts.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""Enhanced TTS — the non-token line, its round-up, and its free share."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from genesyscalc.meters import Confidence
|
||||
from genesyscalc.tts import (
|
||||
TTS_PRICE_PER_MILLION_CHARS,
|
||||
TTS_SOURCE,
|
||||
TTS_SOURCE_DATE,
|
||||
TTS_STANDARD_END_OF_SALE,
|
||||
TTS_STANDARD_END_OF_SUPPORT,
|
||||
TtsUsage,
|
||||
tts_cost,
|
||||
tts_free_share,
|
||||
)
|
||||
from genesyscalc.usage import DeflectionMix
|
||||
|
||||
|
||||
def test_published_rates() -> None:
|
||||
assert TTS_PRICE_PER_MILLION_CHARS == {"advanced": 20.0, "standard": 5.0}
|
||||
|
||||
|
||||
def test_source_pinned_and_distinct_from_the_tokens_article() -> None:
|
||||
"""TTS has its own page and its own date — it is not a token meter."""
|
||||
from genesyscalc.ratecard import RATE_CARD_SOURCE, RATE_CARD_SOURCE_DATE
|
||||
|
||||
assert TTS_SOURCE_DATE == "2026-05-22"
|
||||
assert TTS_SOURCE.endswith("genesys-enhanced-tts-pricing/")
|
||||
assert TTS_SOURCE != RATE_CARD_SOURCE
|
||||
assert TTS_SOURCE_DATE != RATE_CARD_SOURCE_DATE
|
||||
|
||||
|
||||
def test_tts_is_published_so_confirmed() -> None:
|
||||
line = tts_cost(TtsUsage(calls_monthly=100_000))
|
||||
assert line.confidence is Confidence.CONFIRMED
|
||||
|
||||
|
||||
def test_hand_checked_advanced_cost() -> None:
|
||||
"""100,000 calls × 2,000 chars = 200M chars → 200 units × $20 = $4,000/mo."""
|
||||
line = tts_cost(TtsUsage(calls_monthly=100_000, chars_per_call=2_000))
|
||||
assert line.total_chars_monthly == pytest.approx(200_000_000.0)
|
||||
assert line.billed_millions_monthly == 200
|
||||
assert line.cost_monthly == pytest.approx(4_000.0)
|
||||
assert line.cost_annual == pytest.approx(48_000.0)
|
||||
|
||||
|
||||
def test_hand_checked_standard_cost() -> None:
|
||||
"""Same volume on standard voices: 200 × $5 = $1,000/mo."""
|
||||
line = tts_cost(
|
||||
TtsUsage(calls_monthly=100_000, chars_per_call=2_000, tier="standard")
|
||||
)
|
||||
assert line.cost_monthly == pytest.approx(1_000.0)
|
||||
|
||||
|
||||
def test_per_million_roundup_published_examples() -> None:
|
||||
"""The article's own examples: 200,000 chars = $5; 1,000,005 chars = $10.
|
||||
|
||||
Both on standard voices, where the unit rate is $5.
|
||||
"""
|
||||
small = tts_cost(TtsUsage(calls_monthly=1, chars_per_call=200_000, tier="standard"))
|
||||
assert small.billed_millions_monthly == 1
|
||||
assert small.cost_monthly == pytest.approx(5.0)
|
||||
|
||||
just_over = tts_cost(
|
||||
TtsUsage(calls_monthly=1, chars_per_call=1_000_005, tier="standard")
|
||||
)
|
||||
assert just_over.billed_millions_monthly == 2
|
||||
assert just_over.cost_monthly == pytest.approx(10.0)
|
||||
|
||||
|
||||
def test_free_share_tracks_the_virtual_agent_tiers() -> None:
|
||||
"""Free during VA and agentic VA — bot-only deflection does not qualify."""
|
||||
mix = DeflectionMix(
|
||||
bot_only_share=0.30, virtual_agent_share=0.15, agentic_va_share=0.05
|
||||
)
|
||||
assert tts_free_share(mix) == pytest.approx(0.20)
|
||||
|
||||
|
||||
def test_free_share_reduces_the_bill() -> None:
|
||||
"""200M chars, 20% free → 160M billable → 160 × $20 = $3,200/mo."""
|
||||
mix = DeflectionMix(virtual_agent_share=0.15, agentic_va_share=0.05)
|
||||
line = tts_cost(TtsUsage(calls_monthly=100_000, chars_per_call=2_000), mix)
|
||||
assert line.free_share == pytest.approx(0.20)
|
||||
assert line.free_chars_monthly == pytest.approx(40_000_000.0)
|
||||
assert line.billable_chars_monthly == pytest.approx(160_000_000.0)
|
||||
assert line.cost_monthly == pytest.approx(3_200.0)
|
||||
|
||||
|
||||
def test_standard_tier_warns_about_end_of_life() -> None:
|
||||
line = tts_cost(TtsUsage(calls_monthly=1_000, tier="standard"))
|
||||
assert len(line.warnings) == 1
|
||||
assert TTS_STANDARD_END_OF_SALE in line.warnings[0]
|
||||
assert TTS_STANDARD_END_OF_SUPPORT in line.warnings[0]
|
||||
|
||||
|
||||
def test_advanced_tier_does_not_warn() -> None:
|
||||
assert tts_cost(TtsUsage(calls_monthly=1_000)).warnings == ()
|
||||
|
||||
|
||||
def test_concession_applies_to_tts_too() -> None:
|
||||
line = tts_cost(TtsUsage(calls_monthly=100_000), concession_pct=0.25)
|
||||
assert line.cost_monthly == pytest.approx(3_000.0)
|
||||
|
||||
|
||||
def test_zero_volume_costs_nothing() -> None:
|
||||
line = tts_cost(TtsUsage(calls_monthly=0))
|
||||
assert line.billed_millions_monthly == 0
|
||||
assert line.cost_monthly == 0.0
|
||||
|
||||
|
||||
def test_unknown_tier_rejected() -> None:
|
||||
with pytest.raises(ValueError, match="unknown TTS tier"):
|
||||
TtsUsage(calls_monthly=1, tier="neural-ultra")
|
||||
206
calculators/Genesys_Token_Calculator/tests/test_usage.py
Normal file
206
calculators/Genesys_Token_Calculator/tests/test_usage.py
Normal file
@@ -0,0 +1,206 @@
|
||||
"""Volumes → tokens, with the three published rules that trip the naive math.
|
||||
|
||||
Every number below was computed by hand first, then pinned.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from genesyscalc.meters import LicenceModel, Tier
|
||||
from genesyscalc.usage import (
|
||||
DeflectionMix,
|
||||
VoiceBotUsage,
|
||||
Volumes,
|
||||
apply_copilot_covers_summary,
|
||||
subscription_tokens,
|
||||
tier_interactions,
|
||||
virtual_agent_tokens,
|
||||
voice_bot_billable_minutes,
|
||||
voice_bot_roundup_uplift,
|
||||
voice_bot_tokens,
|
||||
)
|
||||
|
||||
# ── 1 · the 15-second per-call round-up ──────────────────────────────────
|
||||
|
||||
|
||||
def test_voice_bot_15_second_roundup() -> None:
|
||||
"""1,000 calls at a 40s average.
|
||||
|
||||
ceil(40/15) = 3 increments = 45s = 0.75 min/call → 750.0 min/month.
|
||||
The naive 40/60 × 1,000 = 666.67 min understates by 12.5%.
|
||||
"""
|
||||
usage = VoiceBotUsage(calls_per_month=1_000, avg_bot_seconds_per_call=40)
|
||||
assert voice_bot_billable_minutes(usage) == pytest.approx(750.0)
|
||||
assert voice_bot_tokens(usage) == pytest.approx(750.0 / 17.0)
|
||||
assert voice_bot_tokens(usage) == pytest.approx(44.117647, rel=1e-6)
|
||||
|
||||
|
||||
def test_roundup_uplift_is_reportable() -> None:
|
||||
"""The stage-facing number: 12.5% more than the raw duration implies."""
|
||||
usage = VoiceBotUsage(calls_per_month=1_000, avg_bot_seconds_per_call=40)
|
||||
assert voice_bot_roundup_uplift(usage) == pytest.approx(0.125)
|
||||
|
||||
|
||||
def test_roundup_is_exact_on_increment_boundaries() -> None:
|
||||
"""45s is exactly three increments — no inflation, uplift 0."""
|
||||
usage = VoiceBotUsage(calls_per_month=1_000, avg_bot_seconds_per_call=45)
|
||||
assert voice_bot_billable_minutes(usage) == pytest.approx(750.0)
|
||||
assert voice_bot_roundup_uplift(usage) == pytest.approx(0.0)
|
||||
|
||||
|
||||
def test_roundup_penalises_short_calls_hardest() -> None:
|
||||
"""A 5-second bot greeting bills as 15 seconds — 3× its duration.
|
||||
|
||||
1,000 calls × 5s = 83.33 raw minutes, billed as 250.0.
|
||||
"""
|
||||
usage = VoiceBotUsage(calls_per_month=1_000, avg_bot_seconds_per_call=5)
|
||||
assert voice_bot_billable_minutes(usage) == pytest.approx(250.0)
|
||||
assert voice_bot_roundup_uplift(usage) == pytest.approx(2.0)
|
||||
|
||||
|
||||
def test_zero_bot_volume_is_free_and_does_not_divide_by_zero() -> None:
|
||||
usage = VoiceBotUsage(calls_per_month=0, avg_bot_seconds_per_call=0)
|
||||
assert voice_bot_billable_minutes(usage) == 0.0
|
||||
assert voice_bot_roundup_uplift(usage) == 0.0
|
||||
assert voice_bot_tokens(usage) == 0.0
|
||||
|
||||
|
||||
def test_negative_bot_usage_rejected() -> None:
|
||||
with pytest.raises(ValueError, match="calls_per_month"):
|
||||
VoiceBotUsage(calls_per_month=-1, avg_bot_seconds_per_call=30)
|
||||
|
||||
|
||||
# ── 2 · the highest-tier rule as a partition ─────────────────────────────
|
||||
|
||||
|
||||
def test_tier_shares_must_partition() -> None:
|
||||
"""Shares summing past 1.0 would bill some interaction twice."""
|
||||
with pytest.raises(ValueError, match="sum to at most 1.0"):
|
||||
DeflectionMix(
|
||||
bot_only_share=0.5, virtual_agent_share=0.4, agentic_va_share=0.3
|
||||
)
|
||||
|
||||
|
||||
def test_tier_share_bounds() -> None:
|
||||
with pytest.raises(ValueError, match="must be in"):
|
||||
DeflectionMix(virtual_agent_share=1.4)
|
||||
|
||||
|
||||
def test_agent_handled_share_is_the_remainder() -> None:
|
||||
mix = DeflectionMix(
|
||||
bot_only_share=0.30, virtual_agent_share=0.15, agentic_va_share=0.05
|
||||
)
|
||||
assert mix.total_deflected_share == pytest.approx(0.50)
|
||||
assert mix.agent_handled_share == pytest.approx(0.50)
|
||||
|
||||
|
||||
def test_highest_tier_charges_each_interaction_once() -> None:
|
||||
"""100,000 interactions: 30% bot, 15% VA, 5% agentic VA.
|
||||
|
||||
VA = 100,000 × 0.15 × 0.5 tokens = 7,500
|
||||
AVA = 100,000 × 0.05 × 1.2 tokens = 6,000
|
||||
------
|
||||
13,500
|
||||
|
||||
Applying every tier to the whole volume — the additive reading — would
|
||||
give 50,000 + 120,000 = far more, billing the same interaction at
|
||||
several tiers at once.
|
||||
"""
|
||||
mix = DeflectionMix(
|
||||
bot_only_share=0.30, virtual_agent_share=0.15, agentic_va_share=0.05
|
||||
)
|
||||
tokens = virtual_agent_tokens(100_000, mix)
|
||||
assert tokens["virtual_agent"] == pytest.approx(7_500.0)
|
||||
assert tokens["agentic_virtual_agent"] == pytest.approx(6_000.0)
|
||||
assert sum(tokens.values()) == pytest.approx(13_500.0)
|
||||
|
||||
|
||||
def test_tier_interactions_never_exceed_the_volume() -> None:
|
||||
mix = DeflectionMix(
|
||||
bot_only_share=0.40, virtual_agent_share=0.35, agentic_va_share=0.25
|
||||
)
|
||||
split = tier_interactions(100_000, mix)
|
||||
assert sum(split.values()) == pytest.approx(100_000.0)
|
||||
assert split[Tier.BOT] == pytest.approx(40_000.0)
|
||||
|
||||
|
||||
def test_bot_tier_is_not_priced_per_interaction() -> None:
|
||||
"""Voice bots bill in minutes with a round-up, so they are absent here."""
|
||||
mix = DeflectionMix(bot_only_share=1.0)
|
||||
assert virtual_agent_tokens(100_000, mix) == {
|
||||
"virtual_agent": 0.0,
|
||||
"agentic_virtual_agent": 0.0,
|
||||
}
|
||||
|
||||
|
||||
# ── 3 · Copilot covers Supervisor summaries ──────────────────────────────
|
||||
|
||||
|
||||
def test_summary_billed_when_copilot_off() -> None:
|
||||
usage = apply_copilot_covers_summary(
|
||||
{"ai_summary_and_insights": 120_000.0}, copilot_enabled=False
|
||||
)
|
||||
assert usage.get("ai_summary_and_insights") == pytest.approx(120_000.0)
|
||||
assert usage.notes == ()
|
||||
|
||||
|
||||
def test_copilot_covers_summary_zeroes_the_line() -> None:
|
||||
"""The published exclusion, enforced — and reported, not silent."""
|
||||
usage = apply_copilot_covers_summary(
|
||||
{"ai_summary_and_insights": 120_000.0}, copilot_enabled=True
|
||||
)
|
||||
assert usage.get("ai_summary_and_insights") == 0.0
|
||||
assert len(usage.notes) == 1
|
||||
assert "Agent Copilot is enabled" in usage.notes[0]
|
||||
|
||||
|
||||
def test_copilot_exclusion_leaves_other_meters_alone() -> None:
|
||||
usage = apply_copilot_covers_summary(
|
||||
{"ai_summary_and_insights": 100.0, "ai_scoring": 500.0}, copilot_enabled=True
|
||||
)
|
||||
assert usage.get("ai_scoring") == pytest.approx(500.0)
|
||||
|
||||
|
||||
def test_copilot_exclusion_is_quiet_when_there_is_nothing_to_zero() -> None:
|
||||
usage = apply_copilot_covers_summary(
|
||||
{"ai_summary_and_insights": 0.0}, copilot_enabled=True
|
||||
)
|
||||
assert usage.notes == ()
|
||||
|
||||
|
||||
# ── subscription tokens ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_per_user_tokens_are_exact_and_licence_sensitive() -> None:
|
||||
"""500 users × 40 = 20,000 named; × 60 = 30,000 concurrent."""
|
||||
keys = frozenset({"agent_copilot_named"})
|
||||
assert subscription_tokens(500, keys, LicenceModel.NAMED) == {
|
||||
"agent_copilot_named": 20_000.0
|
||||
}
|
||||
assert subscription_tokens(500, keys, LicenceModel.CONCURRENT) == {
|
||||
"agent_copilot_named": 30_000.0
|
||||
}
|
||||
|
||||
|
||||
def test_subscription_rejects_a_consumption_meter() -> None:
|
||||
with pytest.raises(ValueError, match="not a per-user meter"):
|
||||
subscription_tokens(10, frozenset({"ai_scoring"}), LicenceModel.NAMED)
|
||||
|
||||
|
||||
# ── volumes ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_volume_totals() -> None:
|
||||
v = Volumes(
|
||||
voice_inbound_monthly=100_000,
|
||||
voice_outbound_monthly=20_000,
|
||||
digital_sessions_monthly=8_000,
|
||||
email_monthly=12_000,
|
||||
)
|
||||
assert v.voice_total_monthly == pytest.approx(120_000.0)
|
||||
assert v.all_interactions_monthly == pytest.approx(140_000.0)
|
||||
|
||||
|
||||
def test_negative_volume_rejected() -> None:
|
||||
with pytest.raises(ValueError, match="voice_inbound_monthly"):
|
||||
Volumes(voice_inbound_monthly=-1)
|
||||
Reference in New Issue
Block a user