207 lines
7.7 KiB
Python
207 lines
7.7 KiB
Python
"""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)
|