Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8ba5dee81 | ||
|
|
8dee1f2ffc | ||
|
|
0b8ea359d9 |
+5
-11
@@ -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>"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user