feat: add leaderboard router, gamification backfill, and shared content creation helpers
Introduce a new `/leaderboard` endpoint ranking top 50 members by total stars, wire it into the app router and documentation. Implement `_backfill_gamification()` in `database.py` to compute XP/levels for existing accounts from prior posts, comments, votes, and follows. Extract `is_owner()`, `create_content_item()`, and `detail_context()` into `content.py` to centralize content creation, reward awarding, mention notifications, and attachment linking. Update comment creation/deletion in `comments.py` to use `award_rewards()` with `XP_COMMENT` and the new `is_owner()` helper. Add shared template partials documentation for vote bars, star buttons, post headers, and topic selectors to `AGENTS.md`.
This commit is contained in:
@@ -1,6 +1,24 @@
|
||||
import re
|
||||
|
||||
from playwright.sync_api import expect
|
||||
|
||||
from tests.conftest import BASE_URL, assert_share_copies
|
||||
|
||||
|
||||
def test_feed_vote_voted_state_persists(alice):
|
||||
page, _ = alice
|
||||
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
||||
page.locator(".feed-fab").first.click()
|
||||
page.fill("#post-content", "Feed vote persistence test content")
|
||||
page.locator("#create-post-modal button.btn-primary:has-text('Post')").click()
|
||||
page.wait_for_url(f"{BASE_URL}/posts/*", wait_until="domcontentloaded")
|
||||
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
||||
page.locator(".post-action-btn.vote-up").first.click()
|
||||
expect(page.locator(".post-vote-count").first).to_have_text("1")
|
||||
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
||||
expect(page.locator(".post-action-btn.vote-up").first).to_have_class(re.compile(r"\bvoted\b"))
|
||||
|
||||
|
||||
def test_feed_card_share_button(alice):
|
||||
page, _ = alice
|
||||
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import re
|
||||
import time
|
||||
|
||||
from playwright.sync_api import expect
|
||||
|
||||
from tests.conftest import BASE_URL, assert_share_copies
|
||||
|
||||
|
||||
@@ -195,3 +199,13 @@ def test_gist_listing_shows_created_gist(alice, app_server):
|
||||
_create_gist(page, title=title, source_code="show_in_list = True")
|
||||
page.goto(f"{BASE_URL}/gists", wait_until="domcontentloaded")
|
||||
assert page.is_visible(f"text={title}")
|
||||
|
||||
|
||||
def test_gist_voted_state_persists(alice):
|
||||
page, _ = alice
|
||||
_create_gist(page, title="Voted State Gist")
|
||||
star = "form[action*='/votes/gist/'] button"
|
||||
page.locator(star).first.click()
|
||||
page.wait_for_timeout(500)
|
||||
page.reload(wait_until="domcontentloaded")
|
||||
expect(page.locator(star).first).to_have_class(re.compile(r"\bvoted\b"))
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
from tests.conftest import BASE_URL
|
||||
|
||||
|
||||
def test_leaderboard_page_loads(alice):
|
||||
page, _ = alice
|
||||
page.goto(f"{BASE_URL}/leaderboard", wait_until="domcontentloaded")
|
||||
assert page.locator(".leaderboard-header h1").is_visible()
|
||||
assert page.is_visible("text=Ranked by total stars")
|
||||
|
||||
|
||||
def test_leaderboard_nav_link(alice):
|
||||
page, _ = alice
|
||||
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
||||
assert page.is_visible("a.topnav-link:has-text('Leaderboard')")
|
||||
|
||||
|
||||
def test_leaderboard_no_pagination(alice):
|
||||
page, _ = alice
|
||||
page.goto(f"{BASE_URL}/leaderboard", wait_until="domcontentloaded")
|
||||
assert page.locator(".pagination").count() == 0
|
||||
|
||||
|
||||
def test_profile_rank_stat(alice):
|
||||
page, user = alice
|
||||
page.goto(f"{BASE_URL}/profile/{user['username']}", wait_until="domcontentloaded")
|
||||
assert page.is_visible("text=Rank")
|
||||
|
||||
|
||||
def test_leaderboard_ranks_after_upvote(app_server, browser, seeded_db):
|
||||
from tests.conftest import login_user
|
||||
ctx_a = browser.new_context(viewport={"width": 1400, "height": 900})
|
||||
ctx_b = browser.new_context(viewport={"width": 1400, "height": 900})
|
||||
pa = ctx_a.new_page()
|
||||
pb = ctx_b.new_page()
|
||||
pa.set_default_timeout(15000)
|
||||
pb.set_default_timeout(15000)
|
||||
|
||||
login_user(pa, seeded_db["alice"])
|
||||
login_user(pb, seeded_db["bob"])
|
||||
|
||||
pb.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
||||
pb.locator(".feed-fab").first.wait_for(state="visible", timeout=10000)
|
||||
pb.locator(".feed-fab").first.click()
|
||||
pb.fill("#post-content", "Post for leaderboard ranking test")
|
||||
pb.locator("#create-post-modal button.btn-primary:has-text('Post')").click()
|
||||
pb.wait_for_url("**/posts/*", timeout=10000, wait_until="domcontentloaded")
|
||||
post_url = pb.url
|
||||
|
||||
pa.goto(post_url, wait_until="domcontentloaded")
|
||||
pa.wait_for_timeout(1000)
|
||||
vote_btn = pa.locator("button.post-action-btn").filter(has_text="+").first
|
||||
vote_btn.wait_for(state="visible", timeout=10000)
|
||||
vote_btn.click()
|
||||
pa.wait_for_timeout(1500)
|
||||
|
||||
pa.goto(f"{BASE_URL}/leaderboard", wait_until="domcontentloaded")
|
||||
body = pa.locator("body").text_content()
|
||||
assert "Internal Server Error" not in body, f"Got 500 on leaderboard: {body[:300]}"
|
||||
assert pa.is_visible("a.leaderboard-name:has-text('bob_test')")
|
||||
|
||||
ctx_a.close()
|
||||
ctx_b.close()
|
||||
@@ -1,3 +1,5 @@
|
||||
import re
|
||||
|
||||
from playwright.sync_api import expect
|
||||
|
||||
from tests.conftest import BASE_URL, assert_share_copies
|
||||
@@ -49,6 +51,39 @@ def test_post_vote_increment(alice):
|
||||
expect(page.locator(".post-vote-count").first).to_have_text("1")
|
||||
|
||||
|
||||
def test_post_upvote_voted_state_persists(alice):
|
||||
page, _ = alice
|
||||
create_post(page, "showcase", "Upvote persistence test")
|
||||
page.locator(".post-action-btn.vote-up").first.click()
|
||||
expect(page.locator(".post-vote-count").first).to_have_text("1")
|
||||
page.reload(wait_until="domcontentloaded")
|
||||
expect(page.locator(".post-action-btn.vote-up").first).to_have_class(re.compile(r"\bvoted\b"))
|
||||
assert "voted" not in (page.locator(".post-action-btn.vote-down").first.get_attribute("class") or "")
|
||||
|
||||
|
||||
def test_post_downvote_voted_state_persists(alice):
|
||||
page, _ = alice
|
||||
create_post(page, "showcase", "Downvote persistence test")
|
||||
page.locator(".post-action-btn.vote-down").first.click()
|
||||
expect(page.locator(".post-vote-count").first).to_have_text("-1")
|
||||
page.reload(wait_until="domcontentloaded")
|
||||
expect(page.locator(".post-action-btn.vote-down").first).to_have_class(re.compile(r"\bvoted\b"))
|
||||
assert "voted" not in (page.locator(".post-action-btn.vote-up").first.get_attribute("class") or "")
|
||||
|
||||
|
||||
def test_comment_voted_state_persists(alice):
|
||||
page, _ = alice
|
||||
create_post(page, "devlog", "Post for comment vote persistence")
|
||||
textarea = page.locator(".comment-form textarea[name='content']")
|
||||
textarea.fill("Comment whose vote should persist")
|
||||
page.locator(".comment-form button:has-text('Post')").click()
|
||||
page.wait_for_timeout(500)
|
||||
page.locator(".comment-vote-btn").first.click()
|
||||
page.wait_for_timeout(500)
|
||||
page.reload(wait_until="domcontentloaded")
|
||||
expect(page.locator(".comment-vote-btn").first).to_have_class(re.compile(r"\bvoted\b"))
|
||||
|
||||
|
||||
def _profile_stars(page, username):
|
||||
page.goto(f"{BASE_URL}/profile/{username}", wait_until="domcontentloaded")
|
||||
value = page.locator(".profile-stat:has(.profile-stat-label:has-text('Stars')) .profile-stat-value").first
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import re
|
||||
|
||||
from playwright.sync_api import expect
|
||||
|
||||
from tests.conftest import BASE_URL, assert_share_copies
|
||||
@@ -28,6 +30,16 @@ def test_project_vote(alice):
|
||||
assert after == before + 1
|
||||
|
||||
|
||||
def test_project_voted_state_persists(alice):
|
||||
page, _ = alice
|
||||
_create_project(page, "Voted State Project")
|
||||
star = "form[action*='/votes/project/'] button"
|
||||
page.locator(star).first.click()
|
||||
page.wait_for_timeout(500)
|
||||
page.reload(wait_until="domcontentloaded")
|
||||
expect(page.locator(star).first).to_have_class(re.compile(r"\bvoted\b"))
|
||||
|
||||
|
||||
def test_delete_own_project(alice):
|
||||
page, _ = alice
|
||||
_create_project(page, "Deletable Project XYZ")
|
||||
|
||||
+25
-1
@@ -1,4 +1,4 @@
|
||||
from devplacepy.utils import hash_password, verify_password, generate_uid, slugify, time_ago
|
||||
from devplacepy.utils import hash_password, verify_password, generate_uid, slugify, time_ago, level_for_xp, badge_info
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
|
||||
@@ -80,3 +80,27 @@ def test_time_ago_years():
|
||||
dt = (datetime.now(timezone.utc) - timedelta(days=400)).isoformat()
|
||||
result = time_ago(dt)
|
||||
assert "/" in result
|
||||
|
||||
|
||||
def test_level_for_xp_boundaries():
|
||||
assert level_for_xp(0) == 1
|
||||
assert level_for_xp(99) == 1
|
||||
assert level_for_xp(100) == 2
|
||||
assert level_for_xp(250) == 3
|
||||
assert level_for_xp(450) == 5
|
||||
|
||||
|
||||
def test_level_for_xp_negative():
|
||||
assert level_for_xp(-50) == 1
|
||||
|
||||
|
||||
def test_badge_info_known():
|
||||
meta = badge_info("Star Author")
|
||||
assert meta["icon"]
|
||||
assert meta["description"] == "Earned 100 stars"
|
||||
|
||||
|
||||
def test_badge_info_unknown_fallback():
|
||||
meta = badge_info("Nonexistent Badge")
|
||||
assert meta["icon"]
|
||||
assert meta["description"] == "Nonexistent Badge"
|
||||
|
||||
Reference in New Issue
Block a user