forked from retoor/devplacepy
Implement a Farmville-style cooperative idle game mounted at `/game` with member-only play and public farm viewing. The data layer is pure and timestamp-driven with no background tick: plot states are derived from `ready_at` vs current time, never stored. Key features include plantable software projects (shell script through kernel) that build over real time, harvest for coins and XP, CI tier upgrades for faster builds, plot purchasing with doubling cost, watering mechanics for friends' builds, daily quests, and prestige system. All game endpoints negotiate HTML or JSON and return full farm state for single-request client refresh. Pub/sub notifications broadcast farm updates on `public.game.farm.{username}` topics. Database schema adds `game_farms`, `game_plots`, and `game_quests` tables with appropriate indexes.
587 lines
20 KiB
Python
587 lines
20 KiB
Python
# retoor <retoor@molodetz.nl>
|
|
|
|
import httpx
|
|
from devplacepy.services import news as news_mod
|
|
from devplacepy.services import base as base_mod
|
|
from devplacepy.services.news import (
|
|
NewsService,
|
|
ImageCandidate,
|
|
LANDING_MIN_SCORE,
|
|
LANDING_MAX,
|
|
UNIQUE_IMAGE_BONUS,
|
|
THIN_CONTENT_PENALTY,
|
|
_extract_grade,
|
|
_get_ai_key,
|
|
_get_article_images,
|
|
_flag_shared_placeholders,
|
|
_primary_image,
|
|
clean_news_text,
|
|
reliability_reason,
|
|
effective_score,
|
|
)
|
|
from datetime import datetime, timezone
|
|
from devplacepy.database import get_table
|
|
from devplacepy.utils import generate_uid
|
|
from tests.conftest import run_async
|
|
API_URL = "http://news.test/api"
|
|
AI_URL = "http://ai.test/v1/chat"
|
|
LINK_HIGH = "http://news.test/high"
|
|
LINK_LOW = "http://news.test/low"
|
|
class FakeResp_news_service:
|
|
def __init__(self, json_data=None, text="", status=200):
|
|
self._json = json_data
|
|
self.text = text
|
|
self.status_code = status
|
|
|
|
def raise_for_status(self):
|
|
if self.status_code >= 400:
|
|
raise httpx.HTTPError(f"status {self.status_code}")
|
|
|
|
def json(self):
|
|
return self._json
|
|
class FakeClient_news_service:
|
|
def __init__(self, articles):
|
|
self.articles = articles
|
|
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, *exc):
|
|
return False
|
|
|
|
async def get(self, url, timeout=None):
|
|
if url == API_URL:
|
|
return FakeResp_news_service(json_data={"articles": self.articles})
|
|
return FakeResp_news_service(text='<img src="http://img.test/a.png">')
|
|
|
|
async def post(self, url, json=None, headers=None, timeout=None):
|
|
prompt = json["messages"][0]["content"]
|
|
if "FailArticle" in prompt:
|
|
return FakeResp_news_service(status=500, text="err")
|
|
if "EmptyArticle" in prompt:
|
|
return FakeResp_news_service(json_data={"choices": [{"message": {"content": ""}}]})
|
|
if "BadArticle" in prompt:
|
|
return FakeResp_news_service(
|
|
json_data={"choices": [{"message": {"content": "no number"}}]}
|
|
)
|
|
grade = "9" if "HighArticle" in prompt else "3"
|
|
return FakeResp_news_service(json_data={"choices": [{"message": {"content": grade}}]})
|
|
class FailingApiClient(FakeClient_news_service):
|
|
async def get(self, url, timeout=None):
|
|
if url == API_URL:
|
|
raise httpx.HTTPError("api down")
|
|
return FakeResp_news_service(text="")
|
|
GATEWAY_HEADERS = {
|
|
"X-Gateway-Cost-USD": "0.00010000",
|
|
"X-Gateway-Model": "molodetz",
|
|
"X-Gateway-Prompt-Tokens": "500",
|
|
"X-Gateway-Completion-Tokens": "100",
|
|
"X-Gateway-Total-Tokens": "600",
|
|
"X-Gateway-Upstream-Latency-Ms": "400",
|
|
"X-Gateway-Total-Latency-Ms": "450",
|
|
}
|
|
class FakeRespHeaders(FakeResp_news_service):
|
|
def __init__(self, json_data=None, text="", status=200, headers=None):
|
|
super().__init__(json_data, text, status)
|
|
self.headers = headers or {}
|
|
class UsageClient(FakeClient_news_service):
|
|
async def post(self, url, json=None, headers=None, timeout=None):
|
|
prompt = json["messages"][0]["content"]
|
|
if "Reformat" in prompt:
|
|
body = "## Heading\n\n" + ("word " * 80)
|
|
return FakeRespHeaders(
|
|
json_data={"choices": [{"message": {"content": body}}]},
|
|
headers=GATEWAY_HEADERS,
|
|
)
|
|
grade = "9" if "HighArticle" in prompt else "3"
|
|
return FakeRespHeaders(
|
|
json_data={"choices": [{"message": {"content": grade}}]},
|
|
headers=GATEWAY_HEADERS,
|
|
)
|
|
def _settings_stub(threshold="7"):
|
|
def fake_get_setting(key, default=None):
|
|
return {
|
|
"news_api_url": API_URL,
|
|
"news_ai_url": AI_URL,
|
|
"news_ai_model": "test-model",
|
|
"news_grade_threshold": threshold,
|
|
"news_ai_key": "",
|
|
}.get(key, default)
|
|
|
|
return fake_get_setting
|
|
|
|
|
|
def test_extract_grade_parsing():
|
|
assert _extract_grade("8") == 8
|
|
assert _extract_grade("Grade: 9") == 9
|
|
assert _extract_grade("Score is 7/10") == 7
|
|
assert _extract_grade("0") is None
|
|
assert _extract_grade("11") is None
|
|
assert _extract_grade("not a number") is None
|
|
|
|
|
|
def test_get_ai_key_env_precedence(monkeypatch):
|
|
monkeypatch.setattr(news_mod, "get_setting", _settings_stub())
|
|
monkeypatch.setenv("NEWS_AI_KEY", "primary")
|
|
assert _get_ai_key() == "primary"
|
|
|
|
|
|
def test_get_ai_key_setting_fallback(monkeypatch):
|
|
monkeypatch.delenv("NEWS_AI_KEY", raising=False)
|
|
monkeypatch.setattr(
|
|
news_mod,
|
|
"get_setting",
|
|
lambda key, default=None: "from-setting" if key == "news_ai_key" else default,
|
|
)
|
|
assert _get_ai_key() == "from-setting"
|
|
|
|
|
|
def test_get_ai_key_internal_gateway_fallback(local_db, monkeypatch):
|
|
from devplacepy.database import internal_gateway_key
|
|
|
|
monkeypatch.delenv("NEWS_AI_KEY", raising=False)
|
|
monkeypatch.setattr(news_mod, "get_setting", _settings_stub())
|
|
assert _get_ai_key() == internal_gateway_key()
|
|
|
|
|
|
def test_grade_article_empty_content_returns_none(local_db, monkeypatch):
|
|
monkeypatch.setattr(news_mod, "get_setting", _settings_stub())
|
|
grade = run_async(
|
|
NewsService()._grade_article(
|
|
{"title": "EmptyArticle", "description": "d", "content": "c"},
|
|
AI_URL,
|
|
"m",
|
|
FakeClient_news_service([]),
|
|
)
|
|
)
|
|
assert grade is None
|
|
|
|
|
|
def test_grade_article_unparseable_returns_none(local_db, monkeypatch):
|
|
monkeypatch.setattr(news_mod, "get_setting", _settings_stub())
|
|
grade = run_async(
|
|
NewsService()._grade_article(
|
|
{"title": "BadArticle", "description": "d", "content": "c"},
|
|
AI_URL,
|
|
"m",
|
|
FakeClient_news_service([]),
|
|
)
|
|
)
|
|
assert grade is None
|
|
|
|
|
|
def test_run_once_handles_api_failure(local_db, monkeypatch):
|
|
monkeypatch.setattr(news_mod, "get_setting", _settings_stub())
|
|
monkeypatch.setattr(base_mod, "get_setting", _settings_stub())
|
|
monkeypatch.setattr(
|
|
news_mod.httpx, "AsyncClient", lambda *a, **k: FailingApiClient([])
|
|
)
|
|
run_async(NewsService().run_once())
|
|
|
|
|
|
def test_run_once_updates_existing_news_row(local_db, monkeypatch):
|
|
external_id = f"news-{generate_uid()}"
|
|
existing_uid = generate_uid()
|
|
get_table("news").insert(
|
|
{
|
|
"deleted_at": None,
|
|
"deleted_by": None,
|
|
"uid": existing_uid,
|
|
"external_id": external_id,
|
|
"slug": "",
|
|
"title": "Old Title",
|
|
"status": "draft",
|
|
"grade": 0,
|
|
"synced_at": "2020-01-01",
|
|
}
|
|
)
|
|
articles = [
|
|
{
|
|
"guid": external_id,
|
|
"title": "HighArticle deep technical analysis",
|
|
"description": "d" * 150,
|
|
"content": "c" * 150,
|
|
"link": LINK_HIGH,
|
|
"feed_name": "Feed",
|
|
"author": "A",
|
|
"published": "2026-01-01",
|
|
}
|
|
]
|
|
monkeypatch.setattr(news_mod, "get_setting", _settings_stub(threshold="7"))
|
|
monkeypatch.setattr(base_mod, "get_setting", _settings_stub(threshold="7"))
|
|
monkeypatch.setattr(
|
|
news_mod.httpx, "AsyncClient", lambda *a, **k: FakeClient_news_service(articles)
|
|
)
|
|
|
|
run_async(NewsService().run_once())
|
|
|
|
row = get_table("news").find_one(uid=existing_uid)
|
|
assert row["title"] == "HighArticle deep technical analysis"
|
|
assert row["status"] == "published"
|
|
assert get_table("news").count(external_id=external_id) == 1
|
|
|
|
|
|
def test_get_article_images_filters_and_dedupes(monkeypatch):
|
|
async def _allow(url):
|
|
return None
|
|
|
|
monkeypatch.setattr(news_mod.net_guard, "guard_public_url", _allow)
|
|
html = (
|
|
'<img src="http://img.test/a.png">'
|
|
"<img src='http://img.test/a.png'>"
|
|
'<img src="http://img.test/b.svg">'
|
|
'<img src="/relative.png">'
|
|
)
|
|
client = FakeClient_news_service([])
|
|
|
|
async def fake_get(url, timeout=None):
|
|
return FakeResp_news_service(text=html)
|
|
|
|
client.get = fake_get
|
|
images = run_async(_get_article_images("http://news.test/page", client))
|
|
assert [img["url"] for img in images] == ["http://img.test/a.png"]
|
|
|
|
|
|
def test_run_once_publishes_grades_and_is_idempotent(local_db, monkeypatch):
|
|
g_high, g_low, g_fail = (f"news-{generate_uid()}" for _ in range(3))
|
|
articles = [
|
|
{
|
|
"guid": g_high,
|
|
"title": "HighArticle definitive guide",
|
|
"description": "d" * 250,
|
|
"content": "c" * 250,
|
|
"link": LINK_HIGH,
|
|
"feed_name": "Feed",
|
|
"author": "A",
|
|
"published": "2026-01-01",
|
|
},
|
|
{
|
|
"guid": g_low,
|
|
"title": "LowArticle filler thoughts",
|
|
"description": "d" * 250,
|
|
"content": "c" * 250,
|
|
"link": LINK_LOW,
|
|
"feed_name": "Feed",
|
|
"author": "A",
|
|
"published": "2026-01-01",
|
|
},
|
|
{
|
|
"guid": g_fail,
|
|
"title": "FailArticle broken feed",
|
|
"description": "d" * 250,
|
|
"content": "c" * 250,
|
|
"link": LINK_HIGH,
|
|
"feed_name": "Feed",
|
|
"author": "A",
|
|
"published": "2026-01-01",
|
|
},
|
|
{
|
|
"guid": "",
|
|
"title": "NoGuid placeholder entry",
|
|
"description": "d" * 250,
|
|
"content": "c" * 250,
|
|
"link": "",
|
|
"feed_name": "Feed",
|
|
"author": "A",
|
|
"published": "2026-01-01",
|
|
},
|
|
]
|
|
monkeypatch.setattr(news_mod, "get_setting", _settings_stub(threshold="7"))
|
|
monkeypatch.setattr(base_mod, "get_setting", _settings_stub(threshold="7"))
|
|
monkeypatch.setattr(
|
|
news_mod.httpx, "AsyncClient", lambda *a, **k: FakeClient_news_service(articles)
|
|
)
|
|
|
|
run_async(NewsService().run_once())
|
|
|
|
news = get_table("news")
|
|
sync = get_table("news_sync")
|
|
assert news.find_one(external_id=g_high)["status"] == "published"
|
|
assert news.find_one(external_id=g_high)["grade"] == 9
|
|
assert news.find_one(external_id=g_low)["status"] == "draft"
|
|
assert news.find_one(external_id=g_low)["grade"] == 3
|
|
fail_row = news.find_one(external_id=g_fail)
|
|
assert fail_row["status"] == "draft"
|
|
assert fail_row["grade"] == 0
|
|
assert sync.find_one(external_id=g_fail)["status"] == "grading_failed"
|
|
assert sync.find_one(external_id=g_high)["status"] == "graded"
|
|
|
|
run_async(NewsService().run_once())
|
|
assert news.count(external_id=g_high) == 1
|
|
assert news.count(external_id=g_fail) == 1
|
|
|
|
|
|
def test_clean_news_text_strips_reddit_boilerplate():
|
|
raw = (
|
|
"Great article body that is long enough. submitted by /u/someone to "
|
|
"/r/programming [link] [12 comments]"
|
|
)
|
|
cleaned = clean_news_text(raw)
|
|
assert "submitted by" not in cleaned
|
|
assert "[link]" not in cleaned
|
|
assert "comments]" not in cleaned
|
|
assert "Great article body" in cleaned
|
|
assert " " not in cleaned
|
|
|
|
|
|
def test_clean_news_text_collapses_whitespace_and_html():
|
|
assert clean_news_text("<p>hello world</p>\n\n[link]") == "hello world"
|
|
assert clean_news_text("") == ""
|
|
|
|
|
|
def test_reliability_reason_gate():
|
|
body = "x" * 300
|
|
assert reliability_reason("A proper title here", body, "https://a.test") == ""
|
|
assert reliability_reason("short", body, "https://a.test") == "short_title"
|
|
assert (
|
|
reliability_reason("A proper title here", "tiny", "https://a.test")
|
|
== "thin_body"
|
|
)
|
|
assert (
|
|
reliability_reason("A proper title here", body, "") == "invalid_url"
|
|
)
|
|
assert (
|
|
reliability_reason("A proper title here", body, "ftp://a.test")
|
|
== "invalid_url"
|
|
)
|
|
assert (
|
|
reliability_reason("THIS IS ALL SHOUTING TITLE", body, "https://a.test")
|
|
== "shouting_title"
|
|
)
|
|
|
|
|
|
def test_effective_score_bonus_and_penalty():
|
|
assert effective_score(6, True, False) == 6 + UNIQUE_IMAGE_BONUS
|
|
assert effective_score(6, False, True) == 6 - THIN_CONTENT_PENALTY
|
|
assert effective_score(6, False, False) == 6
|
|
assert effective_score(10, True, False) == 10
|
|
assert effective_score(1, False, True) == 1
|
|
|
|
|
|
def _stripes_phash():
|
|
from PIL import Image, ImageDraw
|
|
import imagehash
|
|
|
|
image = Image.new("RGB", (200, 200), (255, 255, 255))
|
|
draw = ImageDraw.Draw(image)
|
|
for x in range(0, 200, 20):
|
|
draw.rectangle([x, 0, x + 10, 200], fill=(0, 0, 0))
|
|
return str(imagehash.phash(image))
|
|
|
|
|
|
def _circle_phash():
|
|
from PIL import Image, ImageDraw
|
|
import imagehash
|
|
|
|
image = Image.new("RGB", (200, 200), (255, 255, 255))
|
|
draw = ImageDraw.Draw(image)
|
|
draw.ellipse([40, 40, 160, 160], fill=(0, 0, 0))
|
|
return str(imagehash.phash(image))
|
|
|
|
|
|
def test_flag_shared_placeholders_marks_cross_article_duplicates():
|
|
shared = _stripes_phash()
|
|
distinct = _circle_phash()
|
|
by_article = {
|
|
"a": [ImageCandidate(url="u1", phash=shared, width=200, height=200, is_placeholder=False)],
|
|
"b": [ImageCandidate(url="u2", phash=shared, width=200, height=200, is_placeholder=False)],
|
|
"c": [ImageCandidate(url="u3", phash=distinct, width=200, height=200, is_placeholder=False)],
|
|
}
|
|
_flag_shared_placeholders(by_article)
|
|
assert by_article["a"][0].is_placeholder is True
|
|
assert by_article["b"][0].is_placeholder is True
|
|
assert by_article["c"][0].is_placeholder is False
|
|
|
|
|
|
def test_flag_shared_placeholders_keeps_same_article_duplicates():
|
|
shared = _stripes_phash()
|
|
by_article = {
|
|
"a": [
|
|
ImageCandidate(url="u1", phash=shared, width=200, height=200, is_placeholder=False),
|
|
ImageCandidate(url="u2", phash=shared, width=200, height=200, is_placeholder=False),
|
|
]
|
|
}
|
|
_flag_shared_placeholders(by_article)
|
|
assert by_article["a"][0].is_placeholder is False
|
|
assert by_article["a"][1].is_placeholder is False
|
|
|
|
|
|
def test_primary_image_picks_first_non_placeholder():
|
|
candidates = [
|
|
ImageCandidate(url="ph", is_placeholder=True),
|
|
ImageCandidate(url="good", is_placeholder=False),
|
|
]
|
|
assert _primary_image(candidates) == "good"
|
|
assert _primary_image([ImageCandidate(url="x", is_placeholder=True)]) == ""
|
|
|
|
|
|
def test_grade_article_full_unique_image_bonus():
|
|
service = NewsService()
|
|
article = {
|
|
"title": "A solid technical headline",
|
|
"description": "d" * 250,
|
|
"content": "c" * 250,
|
|
"link": "https://example.test/post",
|
|
}
|
|
unique = [ImageCandidate(url="img", phash="abc", width=400, height=400, is_placeholder=False)]
|
|
result = service._grade_article_full(article, 7, unique)
|
|
assert result.valid is True
|
|
assert result.has_unique_image is True
|
|
assert result.image_url == "img"
|
|
assert result.effective_score == 7 + UNIQUE_IMAGE_BONUS
|
|
|
|
placeholder = [ImageCandidate(url="img", is_placeholder=True)]
|
|
result2 = service._grade_article_full(article, 7, placeholder)
|
|
assert result2.has_unique_image is False
|
|
assert result2.effective_score == 7
|
|
|
|
|
|
def test_grade_article_full_gate_invalidates():
|
|
service = NewsService()
|
|
article = {
|
|
"title": "tiny",
|
|
"description": "d",
|
|
"content": "c",
|
|
"link": "https://example.test/post",
|
|
}
|
|
result = service._grade_article_full(article, 9, [])
|
|
assert result.valid is False
|
|
assert result.reject_reason == "short_title"
|
|
|
|
|
|
def _insert_landing_row(news_table, *, grade, featured, unique, locked=0, landing=0):
|
|
uid = generate_uid()
|
|
news_table.insert(
|
|
{
|
|
"deleted_at": None,
|
|
"deleted_by": None,
|
|
"uid": uid,
|
|
"external_id": f"land-{uid}",
|
|
"slug": uid,
|
|
"title": "Landing candidate",
|
|
"status": "published",
|
|
"grade": grade,
|
|
"featured": featured,
|
|
"has_unique_image": unique,
|
|
"landing_locked": locked,
|
|
"show_on_landing": landing,
|
|
"synced_at": datetime.now(timezone.utc).isoformat(),
|
|
}
|
|
)
|
|
return uid
|
|
|
|
|
|
def test_apply_landing_selection_promotes_top_and_respects_lock(local_db):
|
|
news_table = get_table("news")
|
|
news_table.delete()
|
|
top = _insert_landing_row(news_table, grade=10, featured=1, unique=1)
|
|
mid = _insert_landing_row(news_table, grade=LANDING_MIN_SCORE, featured=1, unique=1)
|
|
too_low = _insert_landing_row(news_table, grade=LANDING_MIN_SCORE - 1, featured=1, unique=1)
|
|
locked = _insert_landing_row(
|
|
news_table, grade=10, featured=1, unique=1, locked=1, landing=0
|
|
)
|
|
|
|
NewsService()._apply_landing_selection(news_table, 7)
|
|
|
|
assert news_table.find_one(uid=top)["show_on_landing"] == 1
|
|
assert news_table.find_one(uid=mid)["show_on_landing"] == 1
|
|
assert news_table.find_one(uid=too_low)["show_on_landing"] == 0
|
|
assert news_table.find_one(uid=locked)["show_on_landing"] == 0
|
|
|
|
|
|
def test_apply_landing_selection_caps_at_landing_max(local_db):
|
|
news_table = get_table("news")
|
|
news_table.delete()
|
|
uids = [
|
|
_insert_landing_row(news_table, grade=10, featured=1, unique=1)
|
|
for _ in range(LANDING_MAX + 3)
|
|
]
|
|
NewsService()._apply_landing_selection(news_table, 7)
|
|
promoted = sum(
|
|
1 for u in uids if news_table.find_one(uid=u)["show_on_landing"] == 1
|
|
)
|
|
assert promoted == LANDING_MAX
|
|
|
|
|
|
def test_strip_md_fence_unwraps_only_full_fence():
|
|
from devplacepy.services.news import _strip_md_fence
|
|
|
|
assert _strip_md_fence("```markdown\n# Hi\n\nBody\n```") == "# Hi\n\nBody"
|
|
assert _strip_md_fence("# Hi\n\nBody") == "# Hi\n\nBody"
|
|
embedded = "Intro\n\n```\ncode\n```"
|
|
assert _strip_md_fence(embedded) == embedded
|
|
|
|
|
|
def test_grade_article_accumulates_usage(local_db, monkeypatch):
|
|
from devplacepy.services.openai_gateway.usage import new_usage_totals
|
|
|
|
monkeypatch.setattr(news_mod, "get_setting", _settings_stub())
|
|
totals = new_usage_totals()
|
|
grade = run_async(
|
|
NewsService()._grade_article(
|
|
{"title": "HighArticle deep dive", "description": "d", "content": "c"},
|
|
AI_URL,
|
|
"m",
|
|
UsageClient([]),
|
|
totals,
|
|
)
|
|
)
|
|
assert grade == 9
|
|
assert totals["calls"] == 1
|
|
assert totals["total_tokens"] == 600
|
|
|
|
|
|
def test_format_article_returns_markdown_and_accumulates(local_db, monkeypatch):
|
|
from devplacepy.services.openai_gateway.usage import new_usage_totals
|
|
|
|
monkeypatch.setattr(news_mod, "get_setting", _settings_stub())
|
|
totals = new_usage_totals()
|
|
article = {
|
|
"title": "A solid technical headline",
|
|
"description": "d" * 120,
|
|
"content": "c" * 120,
|
|
"link": "https://example.test/post",
|
|
}
|
|
out = run_async(
|
|
NewsService()._format_article(article, AI_URL, "m", UsageClient([]), totals)
|
|
)
|
|
assert out.startswith("## Heading")
|
|
assert totals["calls"] == 1
|
|
assert totals["cost_usd"] > 0
|
|
|
|
|
|
def test_format_article_empty_body_returns_empty(local_db, monkeypatch):
|
|
monkeypatch.setattr(news_mod, "get_setting", _settings_stub())
|
|
out = run_async(
|
|
NewsService()._format_article(
|
|
{"title": "t", "description": "", "content": ""},
|
|
AI_URL,
|
|
"m",
|
|
UsageClient([]),
|
|
)
|
|
)
|
|
assert out == ""
|
|
|
|
|
|
def test_collect_metrics_reports_ai_usage(local_db):
|
|
from devplacepy.database import add_news_usage
|
|
|
|
get_table("news_usage").delete()
|
|
add_news_usage(
|
|
{
|
|
"calls": 2,
|
|
"prompt_tokens": 100,
|
|
"completion_tokens": 20,
|
|
"total_tokens": 120,
|
|
"cost_usd": 0.0002,
|
|
"upstream_latency_ms": 300.0,
|
|
"total_latency_ms": 350.0,
|
|
}
|
|
)
|
|
metrics = NewsService().collect_metrics()
|
|
labels = {stat["label"]: stat["value"] for stat in metrics["stats"]}
|
|
assert labels["AI calls"] == 2
|
|
assert labels["Total tokens"] == 120
|
|
assert labels["Total cost"].startswith("$")
|
|
assert labels["Avg cost/call"].startswith("$")
|