Compare commits

..
Author SHA1 Message Date
retoor 28ac44927c Merge branch 'master' into typosaurus/134-cosmetic-title-replaces-clickable-username-on-leaderboard
DevPlace CI / test (pull_request) Failing after 1h2m14s
2026-08-05 03:35:51 +02:00
retoor e02919a1db Merge pull request 'feat: Add badge earned info to user profile badges API response' (#159) from typosaurus/157-add-badge-earned-info-to-user-profile-badges-api-response into master
DevPlace CI / test (push) Failing after 1h7m13s
DevPlace CI / test (pull_request) Failing after 56m51s
Reviewed-on: #159
2026-08-05 02:21:36 +02:00
typosaurus 768a128700 feat(sveta): Add tests verifying username remains clickable with decorative title badge on leaderboard
DevPlace CI / test (pull_request) Failing after 1h6m27s
2026-08-04 20:24:15 +00:00
typosaurus 5fc1aa9791 feat(nadia): Fix frontend rendering to show username as clickable link with decorative title
Outcome: done
Changed: devplacepy/static/css/game.css:249-274
Verified by: `node --check devplacepy/static/js/GameFarm.js` — syntax clean; `python3 -c` CSS brace check — 101/101 balanced, OK. Full test suite not runnable due to Python 3.11 environment (project requires 3.12+); changes are JS/CSS only.
Findings: The JS template (title inside anchor, `${entry.username}${titleHtml}`) was already committed in a6fd91b and is correct. The uncommitted CSS change adds `min-width: 80px` to `.game-lb-name` so the anchor never shrinks to 0px in the 280px sidebar. `.game-lb-title` is restyled as `display: inline-block` with `max-width: 100px; overflow: hidden; text-overflow: ellipsis;` for independent truncation inside the anchor, styled as italic accent badge. The title leading space (`" <span..."`) provides visual separation without CSS margin. No other leaderboard rendering sites exist — all boards share this single JS template path.
Open: none
Confidence: high — root cause confirmed by investigation sibling (flex asymmetry at 280px); JS fix already committed; CSS guards directly resolve the collapse and ensure the title is always decorative, never a replacement.

Typosaurus-Run: b99050b08d5a4dc996c2175d228fe8cf
Typosaurus-Node: 9516bca3c9914bcdab26ec17b52dfa07
Typosaurus-Agent: @nadia
Refs: #134
2026-08-04 20:24:15 +00:00
typosaurus 2ce43bc578 feat(nadia): Fix data layer to always return username and title
Outcome: done
Changed: none
Verified by: `python -m py_compile devplacepy/services/game/store/farm.py && python -m py_compile devplacepy/services/game/store/era.py` — both pass (0 exit code)
Findings:
- `leaderboard()` at farm.py:113-148 returns both `username` and `title` as separate fields in every entry.
- `_entry()` at farm.py:149-165 returns both `username` and `title` — all boards using `_ranked_entries()` (prestige, harvests_week, fair_play) inherit both fields.
- `leaderboard_raid_efficiency()` at farm.py:226-268 uses `_entry()` and includes both `username` and `title`.
- `leaderboard_time_to_kernel()` at farm.py:195-213 uses `_entry()` and includes both `username` and `title`.
- `leaderboard_era()` at era.py:131-166 returns both `username` and `title` as separate fields.
- No leaderboard function returns `title` as `username` or omits either field — the data layer is fully correct and requires no changes.
- Root cause is CSS flex layout (`game.css:81`): `.game-lb-name` (`flex: 1; min-width: 0; overflow: hidden`) shrinks to 0px while `.game-lb-title` (`flex-shrink: 0`) does not shrink. Fix belongs in CSS/template layer.
Open: CSS/template fix delegated to a separate leaf node (add `min-width` to `.game-lb-name` or `flex-shrink: 1` to `.game-lb-title`, or restructure template to place title within/before the anchor)
Confidence: high - every leaderboard function was read and confirmed to return both `username` and `title` as separate fields; none require changes

Typosaurus-Run: b99050b08d5a4dc996c2175d228fe8cf
Typosaurus-Node: ad2242c5c3b448eda7073b97d6e565ae
Typosaurus-Agent: @nadia
Refs: #134
2026-08-04 20:24:15 +00:00
typosaurus 3a0f682052 test(sveta): Add profile badges description tests to the API tier
DevPlace CI / test (pull_request) Failing after 1h7m34s
Outcome: done
Changed: tests/api/profile/index.py:501-578 (helper + two tests)
Verified by: py_compile OK; pyflakes clean; pytest (2 new tests) passed; full tests/api/profile/index.py + tests/api/profile/search.py -> 23 passed; e2e test_profile_badges passed
Findings:
- test_profile_badges_json_description_matches_catalog asserts every badge entry carries a non-null non-empty description equal to BADGE_CATALOG[badge['name']]['description'].
- test_profile_badge_description_exact_string awards Cheerleader and asserts description == 'Reacted 50 times'.
Open: none
Confidence: high - new tests pass against committed implementation
2026-08-04 17:06:54 +00:00
typosaurus 68c403747c test(sveta): Extend the profile badges JSON test with description assertions
Outcome: done
Changed: tests/api/profile/search.py:312-319 (8 lines added); stray previous-attempt tests in tests/api/profile/index.py reverted to HEAD
Verified by: verify() — py_compile OK; pyflakes shows no new findings (9→8, the committed unused BADGE_CATALOG import finding removed); `from devplacepy.main import app` imports clean; pytest tests/api/profile/search.py → 7 passed; red/green demonstrated (FAILED "badge missing description key" against pre-change 13f9fb5, PASSED against HEAD)
Findings:
- tests/api/profile/search.py:312-319 asserts per badge: "description" present, not None, str, non-empty, and == BADGE_CATALOG[badge["name"]]["description"] (BADGE_CATALOG exported at devplacepy/utils/__init__.py:102).
- Awarded badges "First Post"/"Member" exist in BADGE_CATALOG (devplacepy/utils/badges.py:17-18); award_badge inserts only the named badge (badges.py:139-151), so the lookup cannot KeyError.
- Previous attempt's duplicate tests in tests/api/profile/index.py removed; the badge JSON test lives only in tests/api/profile/search.py:276.
- HEAD f72f2ed already carried the implementation (index.py:208, content.py:71) and the unused BADGE_CATALOG import; the addition makes it used.
Open: full `make test` (e2e tier) still requires Python >=3.12; workspace runs 3.11.2 (same limitation as sibling). API tier + import pass here.
Confidence: high - red/green proven against the pre-change implementation; diff additive-only

Typosaurus-Run: 3828c0c223934696842a70e9d9efb9e0
Typosaurus-Node: b827a89016b54505832d8529fbe887cd
Typosaurus-Agent: @sveta
Refs: #157
2026-08-04 16:55:58 +00:00
typosaurus f72f2edf6b feat(nadia): Add earned-by description to the profile badges API response
Outcome: done
Changed: devplacepy/routers/profile/index.py:205-208; devplacepy/schemas/content.py:71
Verified by: `python -c "from devplacepy.main import app"` clean; pyflakes clean on both touched files; `python -m pytest tests/api/profile/search.py` → 7 passed; disposable TestClient check → JSON badges each carry `description` equal to BADGE_CATALOG (Cheerleader → "Reacted 50 times") and HTML tooltip intact. Full `make test` not runnable here: only Python 3.11 installed, project requires >=3.12; CI runs the full suite.
Findings: index.py:205-208 enriches each badge dict with `icon` and `description` from `get_badge(b["badge_name"])`; BadgeOut (content.py:71) declares `description: Optional[str] = None`, required because `_Out` uses `extra="ignore"` (schemas/base.py:7). BadgeOut feeds only ProfileOut.badges (schemas/profile.py:32). profile.html:55 tooltips read only `badge_name` from the dict, so HTML is unchanged. tests/api/profile/awards_tab.py:81 fails on base state too (patch round-trip) — pre-existing, unrelated.
Open: testwriter may extend test_profile_badges_json_has_non_null_names with a description assertion; awards_tab failure has its own owner.
Confidence: high - both criteria implemented and verified end-to-end; full suite blocked by environment Python version.

Typosaurus-Run: 3828c0c223934696842a70e9d9efb9e0
Typosaurus-Node: 974d049e1b1e4a01923bdf9f583d07cd
Typosaurus-Agent: @nadia
Refs: #157
2026-08-04 16:40:19 +00:00
typosaurus 4bd420f38b feat(nadia): Add description to the badge dict and declare it on BadgeOut
Outcome: done
Changed: devplacepy/routers/profile/index.py:204-208, devplacepy/schemas/content.py:68-73
Verified by: verify() — py_compile OK, pyflakes clean, `from devplacepy.main import app` imports clean, pytest tests/api/profile/index.py tests/unit/utils.py → 42 passed
Findings:
- Badge loop (devplacepy/routers/profile/index.py:205-208) sets both `icon` and `description` from a single `get_badge(b["badge_name"])` lookup; `get_badge` always returns a dict with `description` (devplacepy/utils/badges.py:105-108).
- `BadgeOut` (devplacepy/schemas/content.py:71) declares `description: Optional[str] = None`; without it the dict key is dropped by `extra="ignore"` (devplacepy/schemas/base.py:10-11). BadgeOut is consumed only by ProfileOut (devplacepy/schemas/profile.py:32).
- Serialization verified: dict with `description` emits it; without one emits null; ProfileOut passes it through unchanged.
- Environment: workspace Python is 3.11.2, pyproject requires >=3.12, so full `make test` (e2e tier) could not run here; import + targeted tests pass on 3.11.
- 25 pre-existing tests/unit failures (e.g. zip_service KeyError `local_path`) reproduce identically on the stashed clean tree — not caused by this change.
- Direct pytest writes `__pycache__` (make exports PYTHONDONTWRITEBYTECODE=1); after a byte-level edit this caused a transient `cannot import name 'AttachmentOut'` in the uvicorn subprocess, gone after removing `__pycache__` — run tests via make targets.
Open: test extension asse

Typosaurus-Run: 3828c0c223934696842a70e9d9efb9e0
Typosaurus-Node: 527d1bad2be4464b886a71af0247378e
Typosaurus-Agent: @nadia
Refs: #157
2026-08-04 16:38:35 +00:00
retoor 13f9fb5a96 Merge pull request 'Fix #150: Show linked project on post details page and expose in API' (#151) from typosaurus/ticket-150 into master
DevPlace CI / test (push) Failing after 1h22m2s
Reviewed-on: #151
2026-08-02 00:25:48 +02:00
9 changed files with 199 additions and 17 deletions
+3 -1
View File
@@ -203,7 +203,9 @@ async def profile_page(
badges = list(get_table("badges").find(user_uid=profile_user["uid"]))
for b in badges:
b["icon"] = get_badge(b["badge_name"]).get("icon")
badge_meta = get_badge(b["badge_name"])
b["icon"] = badge_meta.get("icon")
b["description"] = badge_meta.get("description")
achievements = build_achievements({b["badge_name"] for b in badges})
badge_total = sum(group["total"] for group in achievements)
badge_earned = sum(group["earned"] for group in achievements)
+1
View File
@@ -68,6 +68,7 @@ class PollOut(_Out):
class BadgeOut(_Out):
name: Optional[str] = Field(None, alias="badge_name")
icon: Optional[str] = None
description: Optional[str] = None
created_at: Optional[str] = None
model_config = ConfigDict(populate_by_name=True)
+15 -12
View File
@@ -250,7 +250,7 @@
flex-direction: column;
align-items: flex-start;
flex: 1;
min-width: 0;
min-width: 80px;
overflow: hidden;
}
@@ -262,6 +262,7 @@
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 80px;
max-width: 100%;
}
@@ -269,23 +270,24 @@
color: var(--accent);
}
.game-lb-title {
display: inline-block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100px;
color: var(--accent);
font-size: 0.75rem;
font-style: italic;
vertical-align: baseline;
}
.game-lb-level {
flex-shrink: 0;
color: var(--text-secondary);
font-size: 0.8rem;
}
.game-lb-title {
color: var(--accent);
font-size: 0.7rem;
font-style: italic;
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.game-lb-score {
flex-shrink: 0;
color: var(--text-muted);
@@ -570,3 +572,4 @@
font-size: 0.75rem;
font-weight: 600;
}
+3 -2
View File
@@ -386,9 +386,9 @@ export class GameFarm {
const board = this.board || "score";
list.innerHTML = data.entries
.map((entry) => {
const title = entry.title ? `<span class="game-lb-title">${entry.title}</span>` : "";
const titleHtml = entry.title ? ` <span class="game-lb-title">${entry.title}</span>` : "";
const value = this._leaderboardValue(board, entry);
return `<li class="game-lb-row${entry.username === this.username ? " game-lb-self" : ""}"><span class="game-lb-rank">#${entry.rank}</span><div class="game-lb-name-group"><a class="game-lb-name" href="/game/farm/${entry.username}">${entry.username}</a>${title}</div><span class="game-lb-level">Lv ${entry.level}</span><span class="game-lb-score">${value}</span></li>`;
return `<li class="game-lb-row${entry.username === this.username ? " game-lb-self" : ""}"><span class="game-lb-rank">#${entry.rank}</span><a class="game-lb-name" href="/game/farm/${entry.username}">${entry.username}${titleHtml}</a><span class="game-lb-level">Lv ${entry.level}</span><span class="game-lb-score">${value}</span></li>`;
})
.join("");
} catch (error) {
@@ -402,3 +402,4 @@ export class GameFarm {
return Format.exact(entry.score);
}
}
+45
View File
@@ -111,6 +111,50 @@ def test_leaderboard_entries_carry_score_and_prestige(app_server, seeded_db):
assert mine["score"] >= 50 * economy.SCORE_PRESTIGE
def test_leaderboard_every_entry_has_username_and_title(app_server, seeded_db):
session, name = _signup()
_reset_farm(name)
_set_farm(name, prestige=1)
deadline = time.time() + 20
while True:
response = requests.get(f"{BASE_URL}/game/leaderboard", headers=JSON)
assert response.status_code == 200
entries = response.json()["entries"]
if any(e["username"] == name for e in entries) or time.time() >= deadline:
break
time.sleep(0.5)
assert entries
for entry in entries:
assert isinstance(entry["username"], str) and len(entry["username"]) > 0
assert isinstance(entry["title"], str)
def test_leaderboard_username_differs_from_title(app_server, seeded_db):
session, name = _signup()
_reset_farm(name)
_set_farm(name, prestige=1)
from devplacepy.services.game.store.cosmetics import equip_title
from devplacepy.database import get_table
user = get_table("users").find_one(username=name)
equip_title(user, "title_refactorer")
from devplacepy.services.game.store.farm import _leaderboard_cache, _board_cache
_leaderboard_cache.clear()
_board_cache.clear()
deadline = time.time() + 20
while True:
response = requests.get(f"{BASE_URL}/game/leaderboard", headers=JSON)
assert response.status_code == 200
entries = response.json()["entries"]
mine = next((e for e in entries if e["username"] == name), None)
if mine is not None or time.time() >= deadline:
break
time.sleep(0.5)
assert mine is not None
assert mine["title"] == "Serial Refactorer"
assert mine["username"] == name
assert mine["username"] != mine["title"]
def test_plant_returns_updated_farm(app_server, seeded_db):
session, name = _signup()
_reset_farm(name)
@@ -186,3 +230,4 @@ def test_prestige_below_level_returns_400(app_server, seeded_db):
_reset_farm(name)
response = session.post(f"{BASE_URL}/game/prestige", headers=JSON)
assert response.status_code == 400
+73
View File
@@ -499,3 +499,76 @@ def test_profile_json_xp_fields_boundary_xp(app_server):
)
def _signup_badge_user():
import time
name = f"bdgi{int(time.time() * 1000)}"
session = requests.Session()
session.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return session, name
def test_profile_badges_json_description_matches_catalog(app_server):
from devplacepy.database import get_table, refresh_snapshot
from devplacepy.utils import BADGE_CATALOG, award_badge
session, name = _signup_badge_user()
refresh_snapshot()
user = get_table("users").find_one(username=name)
assert user, f"user {name} not found after signup"
award_badge(user["uid"], "First Post")
award_badge(user["uid"], "Member")
refresh_snapshot()
r = session.get(
f"{BASE_URL}/profile/{name}", headers={"Accept": "application/json"}
)
assert r.status_code == 200
body = r.json()
assert "badges" in body, "badges key missing from profile JSON"
assert isinstance(body["badges"], list), "badges is not a list"
assert body["badges"], "expected at least one badge"
for badge in body["badges"]:
assert "description" in badge, f"badge missing description key: {badge}"
assert badge["description"] is not None, f"badge description is null: {badge}"
assert isinstance(badge["description"], str), (
f"badge description is not a string: {badge}"
)
assert badge["description"] == BADGE_CATALOG[badge["name"]]["description"], (
f"badge description mismatch: {badge['name']}"
)
def test_profile_badge_description_exact_string(app_server):
from devplacepy.database import get_table, refresh_snapshot
from devplacepy.utils import award_badge
session, name = _signup_badge_user()
refresh_snapshot()
user = get_table("users").find_one(username=name)
assert user, f"user {name} not found after signup"
award_badge(user["uid"], "Cheerleader")
refresh_snapshot()
r = session.get(
f"{BASE_URL}/profile/{name}", headers={"Accept": "application/json"}
)
assert r.status_code == 200
badges = r.json()["badges"]
cheerleader = [b for b in badges if b["name"] == "Cheerleader"]
assert cheerleader, f"Cheerleader badge missing from profile JSON: {badges}"
assert cheerleader[0]["description"] == "Reacted 50 times", (
f"expected 'Reacted 50 times', got {cheerleader[0]['description']!r}"
)
+10 -1
View File
@@ -276,7 +276,7 @@ def test_profile_renders_heatmap_and_streak(app_server):
def test_profile_badges_json_has_non_null_names(app_server):
import time
from devplacepy.database import get_table, refresh_snapshot
from devplacepy.utils import award_badge
from devplacepy.utils import BADGE_CATALOG, award_badge
name = f"bdg{int(time.time() * 1000)}"
session = requests.Session()
@@ -309,5 +309,14 @@ def test_profile_badges_json_has_non_null_names(app_server):
assert badge["name"] is not None, f"badge name is null: {badge}"
assert isinstance(badge["name"], str), f"badge name is not a string: {badge}"
assert len(badge["name"]) > 0, f"badge name is empty: {badge}"
assert "description" in badge, f"badge missing description key: {badge}"
assert badge["description"] is not None, f"badge description is null: {badge}"
assert isinstance(badge["description"], str), f"badge description is not a string: {badge}"
assert len(badge["description"]) > 0, f"badge description is empty: {badge}"
assert badge["description"] == BADGE_CATALOG[badge["name"]]["description"], (
f"badge description mismatch: {badge['name']}"
)
+24
View File
@@ -365,6 +365,29 @@ def test_cosmetics_buy_and_equip_flow(alice):
expect(page.locator(".game-cosmetics")).to_contain_text("Equipped")
def test_leaderboard_username_link_and_title_decorative(alice):
page, _ = alice
from devplacepy.database import get_table
from devplacepy.services.game import store
from devplacepy.services.game.store.cosmetics import equip_title
from devplacepy.services.game.store.farm import _board_cache
reset_farm("alice_test", coins=1_000_000)
user = get_table("users").find_one(username="alice_test")
store.buy_cosmetic(user, "title_refactorer")
equip_title(user, "title_refactorer")
_board_cache.clear()
open_game(page)
page.locator(".game-lb-row").first.wait_for(state="visible")
name_link = page.locator("a.game-lb-name:has-text('alice_test')")
expect(name_link).to_be_visible()
href = name_link.get_attribute("href")
assert href and "alice_test" in href
title = page.locator(".game-lb-title")
expect(title).to_be_visible()
assert title.inner_text() == "Serial Refactorer"
def test_mastery_panel_hidden_without_mastery_points(alice):
page, _ = alice
reset_farm("alice_test")
@@ -496,3 +519,4 @@ def test_mobile_no_horizontal_overflow(mobile_page):
client_w = page.evaluate("document.documentElement.clientWidth")
assert scroll_w - client_w <= 1, f"horizontal overflow: {scroll_w} vs {client_w}"
assert page.locator("[data-game-grid]").first.is_visible()
+25 -1
View File
@@ -755,7 +755,28 @@ def test_leaderboard_entry_exposes_score_and_prestige(local_db):
farm = store.get_farm(user["uid"])
assert entry["prestige"] == 3
assert entry["score"] == economy.farm_score(farm)
assert {"rank", "username", "level", "xp", "coins", "total_harvests", "prestige", "score"} <= set(entry)
assert {"rank", "username", "level", "xp", "coins", "total_harvests", "prestige", "score", "title"} <= set(entry)
def test_leaderboard_entry_title_empty_when_no_title_equipped(local_db):
user = _reset("unit_lb_no_title")
_set(user, prestige=1)
entry = next(e for e in store.leaderboard(50) if e["username"] == "unit_lb_no_title")
assert "title" in entry
assert entry["title"] == ""
def test_leaderboard_entry_title_is_display_name_when_equipped(local_db):
user = _reset("unit_lb_title_user", coins=300_000)
_set(user, prestige=1)
from devplacepy.services.game.store.cosmetics import equip_title
store.buy_cosmetic(user, "title_refactorer")
equip_title(user, "title_refactorer")
_clear_game_caches()
entry = next(e for e in store.leaderboard(50) if e["username"] == "unit_lb_title_user")
assert entry["title"] == "Serial Refactorer"
assert entry["username"] == "unit_lb_title_user"
assert entry["title"] != entry["username"]
# --- market saturation ---------------------------------------------------------
@@ -1177,3 +1198,6 @@ def test_claim_grant_partial_treasury(local_db):
result = store.claim_grant(user)
assert result["amount"] == 300
assert store.treasury_balance() == 0