The suite ran with -x, so a run stopped at the first failure and finding N failures cost N full runs. Move -rf into the pytest addopts so every run lists each failure, and add the triage targets test-fast (unit + api, no browser), test-failed (--last-failed), test-first-failure (the old -x), test-slowest and test-cache-clean. A stale .pytest_cache holding node ids from deleted files made --last-failed select everything; make clean and test-cache-clean drop it. Fix the fifteen failures this surfaced. DeepSearch crawling raised AttributeError in its finally block on every run: async_playwright().__aenter__() returns a Playwright, which has no __aexit__. Use start()/stop() at both call sites. Update the tests left behind by changed signatures: VectorStore is async now, fetch_page takes a browser, _summary_payload takes dom_evidence, and the messages/notifications page compounds take a user_uid. Close four real flakes that fail-fast had been hiding, all of them late in the run. Harvest assertions pinned crop.reward_coins while is_golden pays five times on about five percent of harvests, so they now assert through realizable_harvest_coins with the observed golden flag. Market saturation fixtures assumed a single active farm and landed two tiers milder once the api and e2e tiers had created farms, so they scale by active_farms(). The primary-administrator container test raced the one second cross-worker cache version window and now waits for the server to agree. The isslop tools test matched the collapsed nav dropdown link instead of the tools grid card. Stop burning ninety seconds waiting out server-side display caches: DEVPLACE_RANKING_TTL and DEVPLACE_MARKET_SATURATION_TTL follow the existing sitemap and home cache precedent and are zero for the suite, taking the leaderboard test from 60.6s to 4.4s and the saturation test from 30.1s to under a second. 2881 passed, 1 skipped in 15:13.
135 lines
3.7 KiB
Python
135 lines
3.7 KiB
Python
# retoor <retoor@molodetz.nl>
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from datetime import datetime, timedelta
|
|
|
|
from devplacepy.cache import TTLCache
|
|
from devplacepy.database import db, get_table
|
|
from devplacepy.utils import generate_uid
|
|
|
|
from .. import economy
|
|
from .common import _iso
|
|
|
|
|
|
SATURATION_TTL = int(os.environ.get("DEVPLACE_MARKET_SATURATION_TTL", "30"))
|
|
|
|
|
|
_saturation_cache = TTLCache(ttl=SATURATION_TTL, max_size=32)
|
|
|
|
|
|
def _ticks():
|
|
return get_table("game_market_ticks")
|
|
|
|
|
|
def _hour_bucket(now: datetime) -> str:
|
|
return now.strftime("%Y-%m-%dT%H")
|
|
|
|
|
|
def record_harvest_tick(crop_key: str, now: datetime) -> None:
|
|
with db:
|
|
db.query(
|
|
"INSERT INTO game_market_ticks (uid, crop_key, hour_bucket, harvests, updated_at) "
|
|
"VALUES (:uid, :crop_key, :hour_bucket, 1, :now) "
|
|
"ON CONFLICT(crop_key, hour_bucket) DO UPDATE SET "
|
|
"harvests = harvests + 1, updated_at = :now",
|
|
uid=generate_uid(),
|
|
crop_key=crop_key,
|
|
hour_bucket=_hour_bucket(now),
|
|
now=_iso(now),
|
|
)
|
|
_saturation_cache.pop(f"{crop_key}:{economy.MARKET_WINDOW_HOURS}")
|
|
|
|
|
|
def recent_harvests(crop_key: str, window_hours: int) -> int:
|
|
cache_key = f"{crop_key}:{window_hours}"
|
|
cached = _saturation_cache.get(cache_key)
|
|
if cached is not None:
|
|
return cached
|
|
from .common import _now
|
|
|
|
cutoff = _hour_bucket(_now() - timedelta(hours=window_hours))
|
|
row = db.query(
|
|
"SELECT COALESCE(SUM(harvests), 0) AS total FROM game_market_ticks "
|
|
"WHERE crop_key = :crop_key AND hour_bucket >= :cutoff",
|
|
crop_key=crop_key,
|
|
cutoff=cutoff,
|
|
)
|
|
total = 0
|
|
for result in row:
|
|
total = int(result["total"] or 0)
|
|
break
|
|
_saturation_cache.set(cache_key, total)
|
|
return total
|
|
|
|
|
|
def active_farms() -> int:
|
|
cached = _saturation_cache.get("active_farms")
|
|
if cached is not None:
|
|
return cached
|
|
rows = db.query(
|
|
"SELECT COUNT(*) AS active FROM game_farms WHERE COALESCE(harvests_week, 0) > 0"
|
|
)
|
|
total = 1
|
|
for row in rows:
|
|
total = max(1, int(row.get("active") or 0))
|
|
break
|
|
_saturation_cache.set("active_farms", total)
|
|
return total
|
|
|
|
|
|
def _saturation_for(crop_key: str) -> float:
|
|
crop = economy.crop_for(crop_key)
|
|
if crop is None:
|
|
return 1.0
|
|
recent = recent_harvests(crop_key, economy.MARKET_WINDOW_HOURS)
|
|
return economy.market_saturation_factor(
|
|
economy.supply_days(crop, recent, active_farms())
|
|
)
|
|
|
|
|
|
def market_pressure() -> float:
|
|
worst = 1.0
|
|
for key in economy.MARKET_PRESSURE_CROPS:
|
|
worst = min(worst, _saturation_for(key))
|
|
return 1.0 - worst
|
|
|
|
|
|
def market_factor_for(crop_key: str) -> float:
|
|
if economy.crop_for(crop_key) is None:
|
|
return 1.0
|
|
saturation = _saturation_for(crop_key)
|
|
if saturation < 1.0:
|
|
return saturation
|
|
return economy.market_buff_factor(crop_key, market_pressure())
|
|
|
|
|
|
def prune_ticks(older_than_hours: int = 96) -> int:
|
|
from sqlalchemy import text
|
|
|
|
from .common import _now
|
|
|
|
cutoff = _hour_bucket(_now() - timedelta(hours=older_than_hours))
|
|
with db:
|
|
result = db.executable.execute(
|
|
text("DELETE FROM game_market_ticks WHERE hour_bucket < :cutoff"),
|
|
{"cutoff": cutoff},
|
|
)
|
|
return result.rowcount
|
|
|
|
|
|
def prune_steals(older_than_days: int = 60) -> int:
|
|
from datetime import timedelta as _timedelta
|
|
|
|
from sqlalchemy import text
|
|
|
|
from .common import _iso, _now
|
|
|
|
cutoff = _iso(_now() - _timedelta(days=older_than_days))
|
|
with db:
|
|
result = db.executable.execute(
|
|
text("DELETE FROM game_steals WHERE stolen_at < :cutoff"), {"cutoff": cutoff}
|
|
)
|
|
return result.rowcount
|