113 lines
3.8 KiB
Python
113 lines
3.8 KiB
Python
"""Palier 15 (0.19.0) — Performance + navigation Pilotage.
|
|
|
|
- A : les boucles par dépôt sont parallélisées (le comportement
|
|
visible ne change pas — couvert par les paliers précédents) et
|
|
le cache du client passe en stale-while-revalidate (testé dans
|
|
test_gitea_client.py).
|
|
- C : /forecast devient un tableau de bord des jalons (une ligne
|
|
par jalon, ancres depuis le dashboard) et /health un tableau
|
|
trié par sévérité, avec sélecteur de projet sur le détail.
|
|
"""
|
|
|
|
import asyncio
|
|
|
|
import httpx
|
|
|
|
from app.gitea_client import GiteaClient
|
|
|
|
|
|
def test_forecast_is_a_portfolio_table(client):
|
|
r = client.get("/forecast")
|
|
assert r.status_code == 200
|
|
assert "pilot-table" in r.text
|
|
assert "jalon(s) ouvert(s)" in r.text
|
|
# One row for the demo milestone, anchored for dashboard links.
|
|
assert 'id="ms-Projets-demo"' in r.text
|
|
assert "Sprint 1" in r.text
|
|
# The per-project pace table is still there.
|
|
assert "Débit par projet" in r.text
|
|
|
|
|
|
def test_dashboard_milestone_links_into_forecast(client):
|
|
r = client.get("/")
|
|
assert r.status_code == 200
|
|
assert 'href="/forecast#ms-Projets-demo"' in r.text
|
|
|
|
|
|
def test_health_index_is_a_severity_table(client):
|
|
r = client.get("/health")
|
|
assert r.status_code == 200
|
|
assert "pilot-table" in r.text
|
|
assert "data-severity" in r.text
|
|
assert "Projets/demo" in r.text
|
|
|
|
|
|
def test_health_detail_has_project_switcher(client):
|
|
r = client.get("/health/Projets/demo")
|
|
assert r.status_code == 200
|
|
assert 'id="health-switch"' in r.text
|
|
assert '<option value="Projets/demo" selected>' in r.text
|
|
assert "tous les projets" in r.text
|
|
|
|
|
|
async def test_swr_serves_stale_then_refreshes_in_background():
|
|
"""Stale entries are served instantly; the refresh lands
|
|
asynchronously and the next read sees the fresh value."""
|
|
calls = {"n": 0}
|
|
|
|
def handler(request):
|
|
if request.url.path.endswith("/user"):
|
|
return httpx.Response(200, json={"id": 7})
|
|
calls["n"] += 1
|
|
return httpx.Response(200, json={"data": [{"id": calls["n"]}]})
|
|
|
|
client = GiteaClient(
|
|
base_url="http://gitea.test",
|
|
token="",
|
|
cache_ttl=60,
|
|
transport=httpx.MockTransport(handler),
|
|
)
|
|
first = await client.search_repos()
|
|
assert first == [{"id": 1}]
|
|
# Fresh cache: no new fetch.
|
|
assert await client.search_repos() == [{"id": 1}]
|
|
assert calls["n"] == 1
|
|
# Age the entry beyond the TTL: the stale value comes back
|
|
# immediately (same content, no wait)…
|
|
ts, value = client._cache["search_repos"]
|
|
client._cache["search_repos"] = (ts - 120, value)
|
|
assert await client.search_repos() == [{"id": 1}]
|
|
# …and the background refresh replaces it.
|
|
await asyncio.sleep(0.3)
|
|
assert await client.search_repos() == [{"id": 2}]
|
|
await client.aclose()
|
|
|
|
|
|
async def test_swr_mutation_clear_blocks_stale_resurrection():
|
|
"""A refresh started before a cache clear (mutation) must not
|
|
put its now-stale result back."""
|
|
calls = {"n": 0}
|
|
|
|
def handler(request):
|
|
if request.url.path.endswith("/user"):
|
|
return httpx.Response(200, json={"id": 7})
|
|
calls["n"] += 1
|
|
return httpx.Response(200, json={"data": [{"id": calls["n"]}]})
|
|
|
|
client = GiteaClient(
|
|
base_url="http://gitea.test",
|
|
token="",
|
|
cache_ttl=60,
|
|
transport=httpx.MockTransport(handler),
|
|
)
|
|
await client.search_repos()
|
|
ts, value = client._cache["search_repos"]
|
|
client._cache["search_repos"] = (ts - 120, value)
|
|
await client.search_repos() # triggers background refresh
|
|
client._cache_clear() # a mutation lands first
|
|
await asyncio.sleep(0.3)
|
|
# The cache was cleared and the in-flight refresh was disarmed:
|
|
# the next read fetches fresh instead of serving the old value.
|
|
assert await client.search_repos() == [{"id": 3}]
|
|
await client.aclose()
|