Compare commits

..
Author SHA1 Message Date
typosaurus a8ba5dee81 feat(sveta): Add tests verifying username remains clickable with decorative title badge on leaderboard
DevPlace CI / test (pull_request) Failing after 58m1s
2026-07-27 09:30:12 +00:00
typosaurus 8dee1f2ffc 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-07-27 09:30:12 +00:00
typosaurus 0b8ea359d9 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-07-27 09:29:04 +00:00
10 changed files with 120 additions and 88 deletions
+5 -11
View File
@@ -79,7 +79,6 @@ _EMAIL_RE = re.compile(r"\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b")
_EMAIL_KEEP_DOMAIN = "molodetz.nl"
_MEDIA_SKIP_TAGS = {"a", "code", "pre"}
_TRAILING_PUNCT_RE = re.compile(r"[.,;:!?)\]}\"']+$")
_TITLE_INLINE_TAGS = {
"b", "strong", "i", "em", "code", "del", "s", "mark", "sub", "sup", "span", "br",
}
@@ -140,11 +139,6 @@ def _alt_from_url(url: str) -> str:
def _embed_url(url: str) -> str:
trail = ""
punct_match = _TRAILING_PUNCT_RE.search(url)
if punct_match:
trail = punct_match.group()
url = url[: punct_match.start()]
youtube = _YOUTUBE_RE.search(url)
if youtube:
video_id = youtube.group(1)
@@ -152,19 +146,19 @@ def _embed_url(url: str) -> str:
f'<div class="embed-youtube"><iframe '
f'src="https://www.youtube.com/embed/{video_id}" '
f'frameborder="0" allowfullscreen allow="{_YOUTUBE_ALLOW}">'
f"</iframe></div>{trail}"
f"</iframe></div>"
)
escaped = html.escape(url, quote=True)
if _IMAGE_RE.search(url):
alt = html.escape(_alt_from_url(url), quote=True)
return f'<img src="{escaped}" alt="{alt}" loading="lazy" data-lightbox>{trail}'
return f'<img src="{escaped}" alt="{alt}" loading="lazy" data-lightbox>'
if _VIDEO_RE.search(url):
return f'<video src="{escaped}" controls preload="metadata"></video>{trail}'
return f'<video src="{escaped}" controls preload="metadata"></video>'
if _AUDIO_RE.search(url):
return f'<audio src="{escaped}" controls preload="metadata"></audio>{trail}'
return f'<audio src="{escaped}" controls preload="metadata"></audio>'
return (
f'<a href="{escaped}" target="_blank" rel="noopener noreferrer">'
f"{html.escape(url)}</a>{trail}"
f"{html.escape(url)}</a>"
)
-3
View File
@@ -36,7 +36,6 @@ from devplacepy.database.awards import (
from devplacepy.content import can_view_project, enrich_items
from devplacepy.utils import (
get_current_user,
get_badge,
require_user,
require_user_api,
time_ago,
@@ -202,8 +201,6 @@ async def profile_page(
item["poll"] = polls_map.get(uid)
badges = list(get_table("badges").find(user_uid=profile_user["uid"]))
for b in badges:
b["icon"] = get_badge(b["badge_name"]).get("icon")
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
@@ -67,7 +67,6 @@ class PollOut(_Out):
class BadgeOut(_Out):
name: Optional[str] = Field(None, alias="badge_name")
icon: 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 -15
View File
@@ -188,21 +188,17 @@ export class ContentRenderer {
a.textContent = "@" + part.username;
fragment.appendChild(a);
} else {
const { cleaned, trail } = this.stripTrailingPunct(part.value);
const el = this.urlToEmbed(cleaned);
const el = this.urlToEmbed(part.value);
if (el) {
fragment.appendChild(el);
} else {
const a = document.createElement("a");
a.href = cleaned;
a.href = part.value;
a.target = "_blank";
a.rel = "noopener noreferrer";
a.textContent = cleaned;
a.textContent = part.value;
fragment.appendChild(a);
}
if (trail) {
fragment.appendChild(document.createTextNode(trail));
}
}
}
@@ -286,14 +282,6 @@ export class ContentRenderer {
return null;
}
stripTrailingPunct(url) {
const m = url.match(/[.,;:!?)\]}\"']+$/);
if (m) {
return { cleaned: url.slice(0, m.index), trail: m[0] };
}
return { cleaned: url, trail: "" };
}
walkNodes(root, callback) {
const skipTags = new Set(["CODE", "PRE", "A", "IFRAME", "IMG", "VIDEO", "SCRIPT", "STYLE"]);
const iter = document.createNodeIterator(root, NodeFilter.SHOW_TEXT, null, false);
+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
+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()
-43
View File
@@ -263,46 +263,3 @@ def test_xss_legitimate_link_survives_audit():
out = str(render_content("see https://example.com/page ok"))
assert 'href="https://example.com/page"' in out
assert_no_executable_html(out)
def test_bare_url_with_trailing_period():
out = str(render_content("see https://example.com/page."))
assert 'href="https://example.com/page"' in out
assert "</a>." in out
assert "https://example.com/page.</a>" not in out
def test_bare_url_with_trailing_comma():
out = str(render_content("check https://example.com/page,"))
assert 'href="https://example.com/page"' in out
assert "</a>," in out
def test_bare_url_with_trailing_exclamation():
out = str(render_content("look https://example.com/page!"))
assert 'href="https://example.com/page"' in out
assert "</a>!" in out
def test_bare_url_with_trailing_question_mark():
out = str(render_content("did you see https://example.com/page?"))
assert 'href="https://example.com/page"' in out
assert "</a>?" in out
def test_bare_url_with_trailing_paren():
out = str(render_content("see (https://example.com/page)"))
assert 'href="https://example.com/page"' in out
assert "</a>)" in out
def test_bare_url_with_trailing_multiple_punctuation():
out = str(render_content("visit https://example.com/page..."))
assert 'href="https://example.com/page"' in out
assert "</a>..." in out
def test_bare_url_without_trailing_punctuation_unchanged():
out = str(render_content("see https://example.com/page ok"))
assert 'href="https://example.com/page"' in out
assert "https://example.com/page</a> " in out
+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