Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 67 additions & 12 deletions fenn/reproducibility.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,21 +33,76 @@ def generate_session_id() -> str:

# A curated list of "beautiful" words
adjectives = [
"autumn", "hidden", "bitter", "misty", "silent",
"empty", "dry", "dark", "summer", "icy", "delicate",
"quiet", "white", "cool", "spring", "winter", "patient",
"twilight", "dawn", "crimson", "wispy", "weathered",
"blue", "billowing", "broken", "cold", "damp", "falling",
"frosty", "green", "long", "late", "lingering"
"autumn",
"hidden",
"bitter",
"misty",
"silent",
"empty",
"dry",
"dark",
"summer",
"icy",
"delicate",
"quiet",
"white",
"cool",
"spring",
"winter",
"patient",
"twilight",
"dawn",
"crimson",
"wispy",
"weathered",
"blue",
"billowing",
"broken",
"cold",
"damp",
"falling",
"frosty",
"green",
"long",
"late",
"lingering",
]

nouns = [
"waterfall", "river", "breeze", "moon", "rain",
"wind", "sea", "morning", "snow", "lake", "sunset",
"pine", "shadow", "leaf", "dawn", "glitter", "forest",
"hill", "cloud", "meadow", "sun", "glade", "bird",
"brook", "butterfly", "bush", "dew", "dust", "field",
"fire", "flower", "firefly", "feather", "grass"
"waterfall",
"river",
"breeze",
"moon",
"rain",
"wind",
"sea",
"morning",
"snow",
"lake",
"sunset",
"pine",
"shadow",
"leaf",
"dawn",
"glitter",
"forest",
"hill",
"cloud",
"meadow",
"sun",
"glade",
"bird",
"brook",
"butterfly",
"bush",
"dew",
"dust",
"field",
"fire",
"flower",
"firefly",
"feather",
"grass",
]

# Select words
Expand Down
95 changes: 95 additions & 0 deletions tests/unit/dashboard/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -1471,3 +1471,98 @@ def test_requires_authentication(self, templates_client_no_auth):
resp = templates_client_no_auth.get("/api/templates/run/anything/status")
assert resp.status_code == 302
assert "/connect" in resp.headers["Location"]


class TestSessionMetricDetailPage:
"""GET /session/<project>/<id>/metric/<name> — response codes, auth
enforcement, and content for the metric detail route."""

def test_returns_200_for_known_metric(self, metrics_client):
"""A metric with recorded points must return 200 and render its data."""
resp = metrics_client.get("/session/metrics-proj/m1/metric/train_loss")
assert resp.status_code == 200
assert b"train_loss" in resp.data

def test_content_includes_correct_point_values(self, metrics_client):
"""The embedded metrics JSON blob must contain this metric's exact
step/value pairs, not another metric's or the full unfiltered list."""
resp = metrics_client.get("/session/metrics-proj/m1/metric/train_loss")
html = resp.get_data(as_text=True)
assert '"step": 0' in html or '"step":0' in html
assert '"value": 0.9' in html or '"value":0.9' in html
# val_loss/acc points should not leak into this page's data blob
assert '"name": "val_loss"' not in html and '"name":"val_loss"' not in html

def test_content_includes_breadcrumb_to_session(self, metrics_client):
"""Breadcrumb must link back to the parent session page."""
resp = metrics_client.get("/session/metrics-proj/m1/metric/train_loss")
assert b"/session/metrics-proj/m1" in resp.data

def test_returns_404_for_unknown_metric(self, metrics_client):
"""A metric name with no recorded points must 404, not render empty."""
resp = metrics_client.get("/session/metrics-proj/m1/metric/does_not_exist")
assert resp.status_code == 404

def test_returns_404_for_unknown_session(self, metrics_client):
resp = metrics_client.get("/session/metrics-proj/nope/metric/train_loss")
assert resp.status_code == 404

def test_returns_404_for_unknown_project(self, metrics_client):
resp = metrics_client.get("/session/nope-proj/m1/metric/train_loss")
assert resp.status_code == 404

def test_returns_404_for_metric_on_session_with_no_metrics(self, metrics_client):
"""m2 has zero <metric> nodes — any metric name 404s here too, since
there's no metric registry independent of recorded data."""
resp = metrics_client.get("/session/metrics-proj/m2/metric/train_loss")
assert resp.status_code == 404

def test_requires_authentication(self, client_no_auth):
"""Unauthenticated requests must redirect to /connect, same as the
parent session route."""
resp = client_no_auth.get("/session/alpha/a1/metric/train_loss")
assert resp.status_code == 302
assert "/connect" in resp.headers["Location"]

def test_session_page_links_to_metric_detail(self, metrics_client):
"""Each Graphs-tab chart card must link to its own detail page."""
resp = metrics_client.get("/session/metrics-proj/m1")
assert resp.status_code == 200
assert b"/session/metrics-proj/m1/metric/train_loss" in resp.data
assert b"/session/metrics-proj/m1/metric/val_loss" in resp.data
assert b"/session/metrics-proj/m1/metric/acc" in resp.data

def test_session_page_without_metrics_does_not_break(self, tmp_path, monkeypatch):
"""A completed session with no <metric> elements must still render
its Graphs tab (and links) without erroring — regression guard for
the empty-metrics case predating this feature."""
import fenn.dashboard.app as app_module

_write(
tmp_path / "nometrics.fn",
_FN_TMPL.format(
project="plain",
sid="nometrics",
started="2026-05-21 07:00:00",
ended="2026-05-21 07:00:10",
dur=10,
status="completed",
),
)
monkeypatch.setenv(
"FENN_DASHBOARD_OVERRIDES_PATH", str(tmp_path / "dashboard_overrides.json")
)
scanner = FennScanner(extra_dirs=[str(tmp_path)])

original = app_module.scanner
app_module.scanner = scanner
app.config["TESTING"] = True
try:
with app.test_client() as c:
with c.session_transaction() as sess:
sess["user"] = {"email": "test@example.com"}
resp = c.get("/session/plain/nometrics")
assert resp.status_code == 200
assert b"/session/plain/nometrics/metric/train_loss" in resp.data
finally:
app_module.scanner = original
4 changes: 2 additions & 2 deletions tests/unit/test_reproducibility.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,8 @@ def test_returns_string(self):

def test_format_matches_pattern(self):
session_id = generate_session_id()
# Expected: YYYYMMDD_HHMM_<4 hex chars>
assert re.match(r"^\d{8}_\d{4}_[0-9a-f]{4}$", session_id), (
# Expected: YYYYMMDD_HHMM_<adjective>_<noun>_<4 hex chars>
assert re.match(r"^\d{8}_\d{4}_[a-z]+_[a-z]+_[0-9a-f]{4}$", session_id), (
f"Session ID '{session_id}' does not match expected format"
)

Expand Down
6 changes: 4 additions & 2 deletions tests/unit/utils/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@ def test_session_id_returns_string():

def test_session_id_format():
result = generate_session_id()
# Should match: 20260412_1233_a3f9
assert re.match(r"\d{8}_\d{4}_[a-f0-9]{4}", result), f"Unexpected format: {result}"
# Should match: 20260412_1233_autumn_moon_a3f9
assert re.match(r"^\d{8}_\d{4}_[a-z]+_[a-z]+_[a-f0-9]{4}$", result), (
f"Unexpected format: {result}"
)


def test_session_id_is_unique():
Expand Down
Loading