c04bd67922
* feat(digg): add Digg AI 1000 source module with cluster search and post enrichment - search_digg shells out to digg-pp-cli with --since 30d --agent - parse_digg_response normalizes clusters to last30days dict shape - enrich_with_top_posts attaches top-ranked X posts to top-K clusters - shutil.which gate plus subproc.run_with_timeout discipline matches bird_x.py / youtube_yt.py patterns 25 unit tests cover parse, age window, relevance, binary-missing fallback, timeout recovery, and partial enrichment failures. * feat(digg): wire Digg source into pipeline, normalize, signals, and render pipeline.py: - Import digg, add to MOCK_AVAILABLE_SOURCES, gate via shutil.which - Dispatch case calls search_digg + parse_digg_response, runs enrich_with_top_posts at default/deep depth - Mock fixture includes one enriched cluster + one bare cluster normalize.py: - _normalize_digg maps cluster dicts to SourceItem with container='Digg AI 1000' and metadata.posts pass-through signals.py: - SOURCE_QUALITY['digg'] = 0.85 (top tier alongside YouTube, reflecting Digg's curatorial layer) - ENGAGEMENT_WEIGHTS['digg'] balances postCount, uniqueAuthors, and the rank_score derived from Digg's curatorial position render.py: - SOURCE_LABELS['digg'] = 'Digg AI 1000' - _FOOTER_SOURCES adds '⛏️ Digg AI 1000' line after GitHub - ENGAGEMENT_DISPLAY mirrors footer keys - New _digg_posts_for + _format_digg_quote helpers emit inline '@handle via Digg AI 1000' quotes for clusters with attached X posts; both compact and full-dump renderers call them * feat(digg): polish per-item engagement display and progress label - ENGAGEMENT_DISPLAY for digg uses 'posts' / 'auth' to match the codebase abbreviation convention (HN: 'pts'/'cmt', X: 'rt'/'re') - Footer item word changes from 'story' to 'cluster' to dodge the pre-existing naive plural in _footer_line_for_source ('storys') and to match Digg's actual data model - ui.py SOURCE_COMPLETION_META adds digg with correct 'cluster'/ 'clusters' plural so 'Research complete' shows 'Digg: N clusters' * feat(digg): document Digg AI 1000 source in skill, README, and changelog - planner.py SOURCE_CAPABILITIES adds digg with discussion/social/link capabilities so the planner offers it through the standard fanout - SKILL.md ACTIVE_SOURCES_LIST gate includes 'which digg-pp-cli' check and the source list / available-sources line names digg as opt-in - README.md Sources table adds the Digg AI 1000 row with the activation gate so first-time readers see what they get - CHANGELOG.md Unreleased section calls out the source addition * fix(digg): enrich post-dedupe so brief survivors carry inline quotes Pipeline dispatch was attaching X posts to the top-3 items returned by search, but dedupe later picked different survivors when multiple clusters compared similar (common for trending topics). The brief ended up showing clusters with no posts attached even though enrichment ran successfully on positions 0-2. Move enrichment to _finalize_items_by_source. The new digg.enrich_source_items helper reads metadata['clusterUrlId'] and writes metadata['posts'] in place on the SourceItems that actually survive dedupe. Verified live on 'openclaw': 2 surviving clusters, both now carry real X-post quotes from @sama and @jeremyphoward attributed 'via Digg AI 1000'. Adds 3 unit tests covering survivor enrichment, non-digg skip, and clusterUrlId fallback to item_id. * test(digg): relax live off-topic test to check shape, not emptiness Digg's live search uses fuzzy/popularity fallback, so an impossible token can still return some loosely-related clusters. The contract the pipeline depends on is shape (results is always a list); token-overlap relevance handles the noise downstream. --------- Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
457 lines
16 KiB
Python
457 lines
16 KiB
Python
"""Tests for digg.py - Digg AI 1000 source via digg-pp-cli."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import shutil
|
|
import sys
|
|
from datetime import datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
sys.path.insert(0, str(Path(__file__).parent.parent / "skills" / "last30days" / "scripts"))
|
|
|
|
from lib import digg # noqa: E402
|
|
from lib import subproc # noqa: E402
|
|
|
|
|
|
# === Helpers ===
|
|
|
|
def _cluster(
|
|
cluster_url_id: str = "abc123xy",
|
|
title: str = "Sample cluster",
|
|
tldr: str = "A short summary of what is happening.",
|
|
rank: int = 1,
|
|
post_count: int = 5,
|
|
unique_authors: int = 3,
|
|
first_post_age: str = "5d",
|
|
):
|
|
return {
|
|
"clusterUrlId": cluster_url_id,
|
|
"clusterId": f"uuid-{cluster_url_id}",
|
|
"title": title,
|
|
"tldr": tldr,
|
|
"rank": rank,
|
|
"postCount": post_count,
|
|
"uniqueAuthors": unique_authors,
|
|
"firstPostAge": first_post_age,
|
|
}
|
|
|
|
|
|
def _post(
|
|
username: str = "someone",
|
|
body: str = "Some body text about the topic.",
|
|
rank: int = 100,
|
|
category: str = "Engineer",
|
|
post_type: str = "tweet",
|
|
x_url: str | None = None,
|
|
):
|
|
return {
|
|
"author": {
|
|
"username": username,
|
|
"display_name": username.title(),
|
|
"category": category,
|
|
"rank": rank,
|
|
},
|
|
"body": body,
|
|
"post_type": post_type,
|
|
"xUrl": x_url or f"https://x.com/{username}/status/1234567890",
|
|
"posted_at": "2026-05-01T12:00:00+00:00",
|
|
}
|
|
|
|
|
|
def _stdout_for(payload: dict) -> subproc.SubprocResult:
|
|
return subproc.SubprocResult(returncode=0, stdout=json.dumps(payload), stderr="")
|
|
|
|
|
|
# === _parse_first_post_age ===
|
|
|
|
def test_parse_first_post_age_days():
|
|
today = datetime(2026, 5, 9, tzinfo=timezone.utc)
|
|
assert digg._parse_first_post_age("5d", today=today) == "2026-05-04"
|
|
|
|
|
|
def test_parse_first_post_age_hours_returns_today():
|
|
today = datetime(2026, 5, 9, 10, 0, tzinfo=timezone.utc)
|
|
assert digg._parse_first_post_age("5h", today=today) == "2026-05-09"
|
|
|
|
|
|
def test_parse_first_post_age_weeks():
|
|
today = datetime(2026, 5, 9, tzinfo=timezone.utc)
|
|
assert digg._parse_first_post_age("2w", today=today) == "2026-04-25"
|
|
|
|
|
|
def test_parse_first_post_age_months_inside_window():
|
|
today = datetime(2026, 5, 9, tzinfo=timezone.utc)
|
|
# 1 month = 30 days exactly, still inside the 30-day window.
|
|
assert digg._parse_first_post_age("1m", today=today) == (today - timedelta(days=30)).date().isoformat()
|
|
|
|
|
|
def test_parse_first_post_age_outside_30d_returns_none():
|
|
today = datetime(2026, 5, 9, tzinfo=timezone.utc)
|
|
assert digg._parse_first_post_age("2m", today=today) is None
|
|
assert digg._parse_first_post_age("31d", today=today) is None
|
|
|
|
|
|
def test_parse_first_post_age_invalid():
|
|
assert digg._parse_first_post_age(None) is None
|
|
assert digg._parse_first_post_age("") is None
|
|
assert digg._parse_first_post_age("garbage") is None
|
|
assert digg._parse_first_post_age("5x") is None
|
|
assert digg._parse_first_post_age("d") is None
|
|
assert digg._parse_first_post_age("-3d") is None
|
|
|
|
|
|
# === parse_digg_response ===
|
|
|
|
def test_parse_response_happy_path():
|
|
response = {
|
|
"results": [
|
|
_cluster(cluster_url_id="aaa", title="First", rank=1),
|
|
_cluster(cluster_url_id="bbb", title="Second", rank=4),
|
|
_cluster(cluster_url_id="ccc", title="Third", rank=12),
|
|
]
|
|
}
|
|
items = digg.parse_digg_response(response)
|
|
assert len(items) == 3
|
|
ids = [i["id"] for i in items]
|
|
assert ids == ["aaa", "bbb", "ccc"]
|
|
for item in items:
|
|
assert item["url"].startswith("https://di.gg/ai/")
|
|
assert item["engagement"]["postCount"] == 5
|
|
assert item["engagement"]["uniqueAuthors"] == 3
|
|
assert item["engagement"]["rank"] in (1, 4, 12)
|
|
assert item["posts"] == []
|
|
assert item["date"] is not None
|
|
|
|
|
|
def test_parse_response_empty():
|
|
assert digg.parse_digg_response({"results": []}) == []
|
|
assert digg.parse_digg_response({}) == []
|
|
assert digg.parse_digg_response({"results": "not-a-list"}) == []
|
|
|
|
|
|
def test_parse_response_drops_missing_id():
|
|
response = {
|
|
"results": [
|
|
{"title": "no id", "tldr": "x", "postCount": 1, "uniqueAuthors": 1, "firstPostAge": "1d"},
|
|
_cluster(cluster_url_id="ok", title="ok"),
|
|
]
|
|
}
|
|
items = digg.parse_digg_response(response)
|
|
assert [i["id"] for i in items] == ["ok"]
|
|
|
|
|
|
def test_parse_response_drops_clusters_outside_30d():
|
|
response = {
|
|
"results": [
|
|
_cluster(cluster_url_id="recent", first_post_age="2d"),
|
|
_cluster(cluster_url_id="ancient", first_post_age="2m"),
|
|
]
|
|
}
|
|
items = digg.parse_digg_response(response)
|
|
assert [i["id"] for i in items] == ["recent"]
|
|
|
|
|
|
def test_parse_response_keeps_cluster_when_age_missing():
|
|
# When firstPostAge is absent or empty, we don't have evidence to drop;
|
|
# keep the cluster with date=None and let date-confidence downgrade it.
|
|
response = {
|
|
"results": [
|
|
{**_cluster(cluster_url_id="noage"), "firstPostAge": None},
|
|
]
|
|
}
|
|
items = digg.parse_digg_response(response)
|
|
assert len(items) == 1
|
|
assert items[0]["date"] is None
|
|
|
|
|
|
def test_parse_response_relevance_with_query():
|
|
response = {
|
|
"results": [
|
|
_cluster(cluster_url_id="match", title="OpenClaw launch", tldr="OpenClaw shipped today"),
|
|
_cluster(cluster_url_id="nomatch", title="Cricket scores", tldr="Mumbai vs Delhi"),
|
|
]
|
|
}
|
|
items = digg.parse_digg_response(response, query="OpenClaw")
|
|
by_id = {i["id"]: i for i in items}
|
|
assert by_id["match"]["relevance"] > by_id["nomatch"]["relevance"]
|
|
|
|
|
|
def test_parse_response_engagement_rank_score():
|
|
response = {
|
|
"results": [
|
|
_cluster(cluster_url_id="top", rank=1),
|
|
_cluster(cluster_url_id="off-leaderboard", rank=999),
|
|
]
|
|
}
|
|
items = digg.parse_digg_response(response)
|
|
by_id = {i["id"]: i for i in items}
|
|
assert by_id["top"]["engagement"]["rank_score"] == 50.0
|
|
assert by_id["off-leaderboard"]["engagement"]["rank_score"] == 0.0
|
|
|
|
|
|
# === _parse_post ===
|
|
|
|
def test_parse_post_happy():
|
|
out = digg._parse_post(_post(username="adam", body="Hello world"))
|
|
assert out is not None
|
|
assert out["username"] == "adam"
|
|
assert out["body"] == "Hello world"
|
|
assert out["x_url"].startswith("https://x.com/")
|
|
|
|
|
|
def test_parse_post_drops_missing_body_or_handle_or_url():
|
|
assert digg._parse_post({"author": {"username": "x"}, "body": "", "xUrl": "u"}) is None
|
|
assert digg._parse_post({"author": {}, "body": "txt", "xUrl": "u"}) is None
|
|
assert digg._parse_post({"author": {"username": "x"}, "body": "txt", "xUrl": ""}) is None
|
|
assert digg._parse_post(None) is None # type: ignore[arg-type]
|
|
|
|
|
|
# === _run_cli / search_digg with stubbed subprocess ===
|
|
|
|
def test_search_digg_binary_missing_returns_empty(monkeypatch):
|
|
monkeypatch.setattr(digg.shutil, "which", lambda _: None)
|
|
out = digg.search_digg("anything", "2026-04-09", "2026-05-09")
|
|
assert out["results"] == []
|
|
assert "error" in out
|
|
|
|
|
|
def test_search_digg_passes_since_30d(monkeypatch):
|
|
captured: dict = {}
|
|
|
|
def fake_run(cmd, *, timeout, env=None, on_pid=None):
|
|
captured["cmd"] = list(cmd)
|
|
return _stdout_for({"results": []})
|
|
|
|
monkeypatch.setattr(digg.shutil, "which", lambda _: "/fake/path")
|
|
monkeypatch.setattr(digg.subproc, "run_with_timeout", fake_run)
|
|
digg.search_digg("openclaw", "2026-04-09", "2026-05-09")
|
|
assert "--since" in captured["cmd"]
|
|
assert captured["cmd"][captured["cmd"].index("--since") + 1] == "30d"
|
|
assert "--agent" in captured["cmd"]
|
|
assert captured["cmd"][:3] == [digg.CLI_BIN, "search", "openclaw"]
|
|
|
|
|
|
def test_search_digg_subproc_timeout_returns_empty(monkeypatch):
|
|
monkeypatch.setattr(digg.shutil, "which", lambda _: "/fake/path")
|
|
|
|
def fake_run(*_a, **_kw):
|
|
raise subproc.SubprocTimeout("boom")
|
|
|
|
monkeypatch.setattr(digg.subproc, "run_with_timeout", fake_run)
|
|
out = digg.search_digg("openclaw", "2026-04-09", "2026-05-09")
|
|
assert out["results"] == []
|
|
assert "error" in out
|
|
|
|
|
|
def test_search_digg_nonzero_exit_returns_empty(monkeypatch):
|
|
monkeypatch.setattr(digg.shutil, "which", lambda _: "/fake/path")
|
|
monkeypatch.setattr(
|
|
digg.subproc,
|
|
"run_with_timeout",
|
|
lambda *a, **k: subproc.SubprocResult(returncode=2, stdout="", stderr="cluster not found"),
|
|
)
|
|
out = digg.search_digg("openclaw", "2026-04-09", "2026-05-09")
|
|
assert out["results"] == []
|
|
assert "error" in out
|
|
|
|
|
|
def test_search_digg_invalid_json_returns_empty(monkeypatch):
|
|
monkeypatch.setattr(digg.shutil, "which", lambda _: "/fake/path")
|
|
monkeypatch.setattr(
|
|
digg.subproc,
|
|
"run_with_timeout",
|
|
lambda *a, **k: subproc.SubprocResult(returncode=0, stdout="not json", stderr=""),
|
|
)
|
|
out = digg.search_digg("openclaw", "2026-04-09", "2026-05-09")
|
|
assert out["results"] == []
|
|
assert "error" in out
|
|
|
|
|
|
def test_search_digg_empty_query_short_circuits(monkeypatch):
|
|
called = MagicMock()
|
|
monkeypatch.setattr(digg.shutil, "which", lambda _: "/fake/path")
|
|
monkeypatch.setattr(digg.subproc, "run_with_timeout", called)
|
|
out = digg.search_digg("", "2026-04-09", "2026-05-09")
|
|
assert out["results"] == []
|
|
called.assert_not_called()
|
|
|
|
|
|
# === enrich_with_top_posts ===
|
|
|
|
def test_enrich_with_top_posts_attaches_posts(monkeypatch):
|
|
monkeypatch.setattr(digg.shutil, "which", lambda _: "/fake/path")
|
|
|
|
def fake_run(cmd, *, timeout, env=None, on_pid=None):
|
|
# cmd = ['digg-pp-cli', 'posts', '<urlId>', '--agent', '--by', 'rank', '--limit', '3']
|
|
cluster_url_id = cmd[2]
|
|
return _stdout_for(
|
|
{
|
|
"results": [
|
|
_post(username=f"u_{cluster_url_id}", body=f"body for {cluster_url_id}"),
|
|
_post(username=f"v_{cluster_url_id}", body=f"second for {cluster_url_id}"),
|
|
]
|
|
}
|
|
)
|
|
|
|
monkeypatch.setattr(digg.subproc, "run_with_timeout", fake_run)
|
|
|
|
items = [
|
|
{"id": "aaa", "engagement": {"postCount": 5}, "posts": []},
|
|
{"id": "bbb", "engagement": {"postCount": 3}, "posts": []},
|
|
{"id": "ccc", "engagement": {"postCount": 9}, "posts": []},
|
|
{"id": "ddd", "engagement": {"postCount": 1}, "posts": []},
|
|
]
|
|
digg.enrich_with_top_posts(items, top_k=2, posts_per=3)
|
|
assert len(items[0]["posts"]) == 2
|
|
assert items[0]["posts"][0]["username"] == "u_aaa"
|
|
assert len(items[1]["posts"]) == 2
|
|
assert items[2]["posts"] == [] # not enriched (top_k=2)
|
|
|
|
|
|
def test_enrich_skips_zero_postcount(monkeypatch):
|
|
monkeypatch.setattr(digg.shutil, "which", lambda _: "/fake/path")
|
|
fake = MagicMock(return_value=_stdout_for({"results": [_post()]}))
|
|
monkeypatch.setattr(digg.subproc, "run_with_timeout", fake)
|
|
|
|
items = [
|
|
{"id": "no-posts", "engagement": {"postCount": 0}, "posts": []},
|
|
{"id": "ok", "engagement": {"postCount": 7}, "posts": []},
|
|
]
|
|
digg.enrich_with_top_posts(items, top_k=2, posts_per=3)
|
|
assert items[0]["posts"] == []
|
|
assert len(items[1]["posts"]) == 1
|
|
|
|
|
|
def test_enrich_partial_timeout_does_not_break_others(monkeypatch):
|
|
monkeypatch.setattr(digg.shutil, "which", lambda _: "/fake/path")
|
|
call_count = {"n": 0}
|
|
|
|
def fake_run(cmd, *, timeout, env=None, on_pid=None):
|
|
call_count["n"] += 1
|
|
if call_count["n"] == 2:
|
|
raise subproc.SubprocTimeout("boom")
|
|
return _stdout_for({"results": [_post(username=f"u{call_count['n']}")]})
|
|
|
|
monkeypatch.setattr(digg.subproc, "run_with_timeout", fake_run)
|
|
|
|
items = [
|
|
{"id": "a", "engagement": {"postCount": 3}, "posts": []},
|
|
{"id": "b", "engagement": {"postCount": 3}, "posts": []},
|
|
{"id": "c", "engagement": {"postCount": 3}, "posts": []},
|
|
]
|
|
digg.enrich_with_top_posts(items, top_k=3, posts_per=3)
|
|
assert len(items[0]["posts"]) == 1
|
|
assert items[1]["posts"] == [] # timed out
|
|
assert len(items[2]["posts"]) == 1
|
|
|
|
|
|
def test_enrich_top_k_zero_skips_all(monkeypatch):
|
|
fake = MagicMock()
|
|
monkeypatch.setattr(digg.subproc, "run_with_timeout", fake)
|
|
items = [{"id": "a", "engagement": {"postCount": 5}, "posts": []}]
|
|
digg.enrich_with_top_posts(items, top_k=0)
|
|
fake.assert_not_called()
|
|
|
|
|
|
# === enrich_source_items (post-dedupe path) ===
|
|
|
|
class _FakeSourceItem:
|
|
def __init__(self, source, item_id, engagement, metadata):
|
|
self.source = source
|
|
self.item_id = item_id
|
|
self.engagement = engagement
|
|
self.metadata = metadata
|
|
|
|
|
|
def test_enrich_source_items_attaches_to_survivors(monkeypatch):
|
|
monkeypatch.setattr(digg.shutil, "which", lambda _: "/fake/path")
|
|
monkeypatch.setattr(
|
|
digg.subproc,
|
|
"run_with_timeout",
|
|
lambda cmd, *, timeout, env=None, on_pid=None: _stdout_for(
|
|
{"results": [_post(username=f"u_{cmd[2]}")]}
|
|
),
|
|
)
|
|
items = [
|
|
_FakeSourceItem("digg", "ID1", {"postCount": 4}, {"clusterUrlId": "ID1", "posts": []}),
|
|
_FakeSourceItem("digg", "ID2", {"postCount": 6}, {"clusterUrlId": "ID2", "posts": []}),
|
|
_FakeSourceItem("digg", "ID3", {"postCount": 8}, {"clusterUrlId": "ID3", "posts": []}),
|
|
]
|
|
digg.enrich_source_items(items, top_k=2)
|
|
assert items[0].metadata["posts"][0]["username"] == "u_ID1"
|
|
assert items[1].metadata["posts"][0]["username"] == "u_ID2"
|
|
assert items[2].metadata["posts"] == []
|
|
|
|
|
|
def test_enrich_source_items_skips_non_digg(monkeypatch):
|
|
fake = MagicMock()
|
|
monkeypatch.setattr(digg.shutil, "which", lambda _: "/fake/path")
|
|
monkeypatch.setattr(digg.subproc, "run_with_timeout", fake)
|
|
items = [_FakeSourceItem("hackernews", "HN1", {"points": 100}, {"posts": []})]
|
|
digg.enrich_source_items(items, top_k=3)
|
|
fake.assert_not_called()
|
|
|
|
|
|
def test_enrich_source_items_falls_back_to_item_id(monkeypatch):
|
|
monkeypatch.setattr(digg.shutil, "which", lambda _: "/fake/path")
|
|
captured = {}
|
|
|
|
def fake_run(cmd, *, timeout, env=None, on_pid=None):
|
|
captured["cluster_id"] = cmd[2]
|
|
return _stdout_for({"results": [_post()]})
|
|
|
|
monkeypatch.setattr(digg.subproc, "run_with_timeout", fake_run)
|
|
items = [_FakeSourceItem("digg", "fallbackid", {"postCount": 3}, {"posts": []})]
|
|
digg.enrich_source_items(items, top_k=1)
|
|
assert captured["cluster_id"] == "fallbackid"
|
|
|
|
|
|
# === Live tests (opt-in) ===
|
|
|
|
LIVE = os.environ.get("LAST30DAYS_DIGG_LIVE", "").lower() in ("1", "true", "yes")
|
|
HAVE_BIN = shutil.which(digg.CLI_BIN) is not None
|
|
|
|
|
|
@pytest.mark.skipif(not (LIVE and HAVE_BIN), reason="LAST30DAYS_DIGG_LIVE not set or digg-pp-cli missing")
|
|
class TestLiveDigg:
|
|
def test_search_returns_clusters(self):
|
|
out = digg.search_digg("claude code", "2026-04-09", "2026-05-09", depth="quick")
|
|
assert "results" in out
|
|
assert isinstance(out["results"], list)
|
|
# Topic should produce at least 1 cluster in the last 30d.
|
|
assert len(out["results"]) >= 1
|
|
sample = out["results"][0]
|
|
for key in ("clusterUrlId", "title", "firstPostAge", "postCount"):
|
|
assert key in sample
|
|
|
|
def test_parse_then_enrich_roundtrip(self):
|
|
raw = digg.search_digg("claude code", "2026-04-09", "2026-05-09", depth="quick")
|
|
items = digg.parse_digg_response(raw, query="claude code")
|
|
assert items, "expected at least one parsed cluster"
|
|
digg.enrich_with_top_posts(items, top_k=1, posts_per=2)
|
|
# Either the top cluster was successfully enriched, or it was a 0-post
|
|
# cluster and posts stayed empty. Both are valid; we just want no crash.
|
|
assert isinstance(items[0]["posts"], list)
|
|
|
|
def test_off_topic_returns_list(self):
|
|
# Digg's live search uses fuzzy/popularity fallback so an impossible
|
|
# token may return clusters Digg considers loosely related rather
|
|
# than an empty list. The contract we depend on is shape: results
|
|
# must always be a list. Token-overlap relevance later in the
|
|
# pipeline filters off-topic noise.
|
|
out = digg.search_digg("ksdjflksjdflkjsdf-impossible-token", "2026-04-09", "2026-05-09", depth="quick")
|
|
assert isinstance(out.get("results"), list)
|
|
|
|
def test_missing_cluster_id_graceful(self):
|
|
posts = digg.fetch_top_posts("notarealclusterid", posts_per=2)
|
|
assert posts == []
|
|
|
|
|
|
if __name__ == "__main__":
|
|
pytest.main([__file__, "-v"])
|