2026-06-13 16:32:33 +02:00
|
|
|
# retoor <retoor@molodetz.nl>
|
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`.
2026-05-30 20:16:39 +02:00
|
|
|
|
2026-06-13 16:32:33 +02:00
|
|
|
import re
|
2026-05-27 21:06:18 +02:00
|
|
|
from playwright.sync_api import expect
|
Show a post's image on its card, and show it full size
_attachment_display.html iterates a context variable named
`attachments`, so every caller binds it before the include.
_post_card.html was the one caller that did not: it guarded on
item.attachments but included the partial with nothing bound, so the
gallery looped over whatever `attachments` happened to be in the
surrounding page context and rendered empty. Every post with an image
looked image-less on the feed and on profiles, and on a project page -
where project_detail.html sets `attachments` at template scope for the
project's own files - a devlog card would have rendered the project's
files as its own.
With the image actually reaching the card, render a lone one properly:
a gallery holding exactly one item gets a `single` class and takes the
full content column (max-height 480px, object-fit contain, no hover
scale), matching the original DevPlace. That branch serves the stored
original rather than thumbnail_url, because a thumbnail is 200px on its
longest side and stretching it to the column width is visibly blurry.
Animated GIFs needed no change and now have a test proving it: they
never had a thumbnail to flatten, so they already took the original-file
path and simply render larger.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 23:12:39 +02:00
|
|
|
from tests.conftest import BASE_URL, assert_share_copies, create_post_with_files
|
2026-05-10 09:08:12 +02:00
|
|
|
def create_post(page, topic="random", content="Test post content", title=None):
|
2026-05-11 00:41:41 +02:00
|
|
|
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
|
|
|
|
page.locator(".feed-fab").first.wait_for(state="visible", timeout=10000)
|
|
|
|
|
page.locator(".feed-fab").first.click()
|
2026-05-10 09:08:12 +02:00
|
|
|
page.check(f"input[value='{topic}']")
|
|
|
|
|
page.fill("#post-content", content)
|
|
|
|
|
if title:
|
|
|
|
|
page.fill("#post-title", title)
|
2026-05-11 07:02:06 +02:00
|
|
|
page.locator("#create-post-modal button.btn-primary:has-text('Post')").click()
|
2026-05-11 00:41:41 +02:00
|
|
|
page.wait_for_url(f"{BASE_URL}/posts/*", wait_until="domcontentloaded")
|
Show a post's image on its card, and show it full size
_attachment_display.html iterates a context variable named
`attachments`, so every caller binds it before the include.
_post_card.html was the one caller that did not: it guarded on
item.attachments but included the partial with nothing bound, so the
gallery looped over whatever `attachments` happened to be in the
surrounding page context and rendered empty. Every post with an image
looked image-less on the feed and on profiles, and on a project page -
where project_detail.html sets `attachments` at template scope for the
project's own files - a devlog card would have rendered the project's
files as its own.
With the image actually reaching the card, render a lone one properly:
a gallery holding exactly one item gets a `single` class and takes the
full content column (max-height 480px, object-fit contain, no hover
scale), matching the original DevPlace. That branch serves the stored
original rather than thumbnail_url, because a thumbnail is 200px on its
longest side and stretching it to the column width is visibly blurry.
Animated GIFs needed no change and now have a test proving it: they
never had a thumbnail to flatten, so they already took the original-file
path and simply render larger.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 23:12:39 +02:00
|
|
|
def _png_bytes(color=(0, 128, 255), size=(600, 400)):
|
|
|
|
|
import io
|
|
|
|
|
from PIL import Image
|
|
|
|
|
|
|
|
|
|
buf = io.BytesIO()
|
|
|
|
|
Image.new("RGB", size, color).save(buf, "PNG")
|
|
|
|
|
return buf.getvalue()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _gif_bytes():
|
|
|
|
|
import io
|
|
|
|
|
from PIL import Image
|
|
|
|
|
|
|
|
|
|
frames = [Image.new("RGB", (40, 40), c) for c in ((255, 0, 0), (0, 0, 255))]
|
|
|
|
|
buf = io.BytesIO()
|
|
|
|
|
frames[0].save(
|
|
|
|
|
buf, "GIF", save_all=True, append_images=frames[1:], duration=120, loop=0
|
|
|
|
|
)
|
|
|
|
|
return buf.getvalue()
|
|
|
|
|
|
|
|
|
|
|
2026-06-13 16:32:33 +02:00
|
|
|
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
|
|
|
|
|
return int(value.text_content().strip())
|
2026-05-10 09:08:12 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_post_detail_page(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
create_post(page, "random", "Detail page test content")
|
|
|
|
|
assert page.is_visible("text=Detail page test content")
|
|
|
|
|
assert page.is_visible("text=Back to Feed")
|
|
|
|
|
|
|
|
|
|
|
2026-06-16 00:44:12 +02:00
|
|
|
def test_create_post_modal_targets_modal_textarea_on_populated_feed(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
create_post(page, "devlog", "Seed post so the feed renders comment forms")
|
|
|
|
|
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
|
|
|
|
page.locator(".feed-fab").first.wait_for(state="visible", timeout=10000)
|
|
|
|
|
assert page.locator("textarea[name='content']").count() > 1
|
|
|
|
|
page.locator(".feed-fab").first.click()
|
|
|
|
|
modal_content = page.locator("#create-post-modal textarea[name='content']")
|
|
|
|
|
modal_content.wait_for(state="visible", timeout=10000)
|
|
|
|
|
modal_content.click()
|
|
|
|
|
modal_content.fill("Modal scoped body well over ten characters long.")
|
|
|
|
|
page.locator("#create-post-modal button.btn-primary:has-text('Post')").click()
|
|
|
|
|
page.wait_for_url(f"{BASE_URL}/posts/*", wait_until="domcontentloaded")
|
|
|
|
|
assert page.is_visible("text=Modal scoped body well over ten characters long.")
|
|
|
|
|
|
|
|
|
|
|
2026-05-10 09:08:12 +02:00
|
|
|
def test_post_topic_badge(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
create_post(page, "question", "Question post for badge check")
|
|
|
|
|
badge = page.locator(".badge-question")
|
|
|
|
|
assert badge.is_visible()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_post_author_info(alice):
|
|
|
|
|
page, user = alice
|
|
|
|
|
create_post(page, "fun", "Post with author check")
|
|
|
|
|
assert page.is_visible(f"text={user['username']}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_post_vote_button(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
create_post(page, "devlog", "Votable post")
|
2026-05-23 03:21:55 +02:00
|
|
|
vote_btn = page.locator(".post-action-btn.vote-up").first
|
2026-05-10 09:08:12 +02:00
|
|
|
assert vote_btn.is_visible()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_post_vote_increment(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
create_post(page, "showcase", "Vote increment test")
|
2026-05-23 03:21:55 +02:00
|
|
|
page.locator(".post-action-btn.vote-up").first.click()
|
2026-05-27 21:06:18 +02:00
|
|
|
expect(page.locator(".post-vote-count").first).to_have_text("1")
|
2026-05-10 09:08:12 +02:00
|
|
|
|
|
|
|
|
|
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`.
2026-05-30 20:16:39 +02:00
|
|
|
def test_post_upvote_voted_state_persists(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
create_post(page, "showcase", "Upvote persistence test")
|
2026-06-14 16:46:36 +02:00
|
|
|
with page.expect_response(
|
|
|
|
|
lambda r: "/votes/" in r.url and r.request.method == "POST"
|
|
|
|
|
):
|
|
|
|
|
page.locator(".post-action-btn.vote-up").first.click()
|
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`.
2026-05-30 20:16:39 +02:00
|
|
|
expect(page.locator(".post-vote-count").first).to_have_text("1")
|
|
|
|
|
page.reload(wait_until="domcontentloaded")
|
2026-06-09 18:48:08 +02:00
|
|
|
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 ""
|
|
|
|
|
)
|
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`.
2026-05-30 20:16:39 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_post_downvote_voted_state_persists(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
create_post(page, "showcase", "Downvote persistence test")
|
2026-06-14 16:46:36 +02:00
|
|
|
with page.expect_response(
|
|
|
|
|
lambda r: "/votes/" in r.url and r.request.method == "POST"
|
|
|
|
|
):
|
|
|
|
|
page.locator(".post-action-btn.vote-down").first.click()
|
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`.
2026-05-30 20:16:39 +02:00
|
|
|
expect(page.locator(".post-vote-count").first).to_have_text("-1")
|
|
|
|
|
page.reload(wait_until="domcontentloaded")
|
2026-06-09 18:48:08 +02:00
|
|
|
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 ""
|
|
|
|
|
)
|
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`.
2026-05-30 20:16:39 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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()
|
2026-06-09 18:48:08 +02:00
|
|
|
expect(
|
|
|
|
|
page.locator(".comment-text:has-text('Comment whose vote should persist')")
|
|
|
|
|
).to_be_visible()
|
2026-08-16 02:02:34 +02:00
|
|
|
with page.expect_response(
|
|
|
|
|
lambda r: "/votes/" in r.url and r.request.method == "POST"
|
|
|
|
|
):
|
|
|
|
|
page.locator(".comment-vote-btn").first.click()
|
2026-06-09 18:48:08 +02:00
|
|
|
expect(page.locator(".comment-vote-btn").first).to_have_class(
|
|
|
|
|
re.compile(r"\bvoted\b")
|
|
|
|
|
)
|
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`.
2026-05-30 20:16:39 +02:00
|
|
|
page.reload(wait_until="domcontentloaded")
|
2026-06-09 18:48:08 +02:00
|
|
|
expect(page.locator(".comment-vote-btn").first).to_have_class(
|
|
|
|
|
re.compile(r"\bvoted\b")
|
|
|
|
|
)
|
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`.
2026-05-30 20:16:39 +02:00
|
|
|
|
|
|
|
|
|
2026-06-19 10:06:09 +02:00
|
|
|
def test_mention_first_match_highlighted(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
create_post(page, "random", "Mention highlight test post")
|
|
|
|
|
textarea = page.locator(".comment-form textarea[name='content']").first
|
|
|
|
|
textarea.wait_for(state="visible", timeout=10000)
|
|
|
|
|
textarea.fill("@bob")
|
|
|
|
|
item = page.locator(".mention-dropdown-item:has-text('bob_test')").first
|
|
|
|
|
item.wait_for(state="visible", timeout=5000)
|
|
|
|
|
expect(item).to_have_class(re.compile(r"\bactive\b"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_mention_tab_inserts_first_match(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
create_post(page, "random", "Mention tab insert test post")
|
|
|
|
|
textarea = page.locator(".comment-form textarea[name='content']").first
|
|
|
|
|
textarea.wait_for(state="visible", timeout=10000)
|
|
|
|
|
textarea.fill("@bob")
|
|
|
|
|
page.locator(".mention-dropdown-item:has-text('bob_test')").first.wait_for(
|
|
|
|
|
state="visible", timeout=5000
|
|
|
|
|
)
|
|
|
|
|
textarea.press("Tab")
|
|
|
|
|
expect(textarea).to_have_value(re.compile(r"^@bob_test\s"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_mention_arrow_then_enter_inserts(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
create_post(page, "random", "Mention arrow nav test post")
|
|
|
|
|
textarea = page.locator(".comment-form textarea[name='content']").first
|
|
|
|
|
textarea.wait_for(state="visible", timeout=10000)
|
|
|
|
|
textarea.fill("@bob")
|
|
|
|
|
page.locator(".mention-dropdown-item:has-text('bob_test')").first.wait_for(
|
|
|
|
|
state="visible", timeout=5000
|
|
|
|
|
)
|
|
|
|
|
textarea.press("ArrowDown")
|
2026-06-20 01:21:53 +02:00
|
|
|
selected = page.locator(".mention-dropdown-item.active").first
|
|
|
|
|
selected.wait_for(state="visible", timeout=5000)
|
|
|
|
|
username = selected.get_attribute("data-username")
|
2026-06-19 10:06:09 +02:00
|
|
|
textarea.press("Enter")
|
2026-06-20 01:21:53 +02:00
|
|
|
expect(textarea).to_have_value(re.compile(rf"@{re.escape(username)}\s"))
|
2026-06-19 10:06:09 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_mention_escape_closes_dropdown(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
create_post(page, "random", "Mention escape test post")
|
|
|
|
|
textarea = page.locator(".comment-form textarea[name='content']").first
|
|
|
|
|
textarea.wait_for(state="visible", timeout=10000)
|
|
|
|
|
textarea.fill("@bob")
|
|
|
|
|
item = page.locator(".mention-dropdown-item:has-text('bob_test')").first
|
|
|
|
|
item.wait_for(state="visible", timeout=5000)
|
|
|
|
|
textarea.press("Escape")
|
|
|
|
|
expect(item).to_be_hidden()
|
|
|
|
|
|
|
|
|
|
|
2026-05-23 10:54:45 +02:00
|
|
|
def test_profile_stars_reflect_content_votes(alice):
|
|
|
|
|
page, user = alice
|
|
|
|
|
before = _profile_stars(page, user["username"])
|
|
|
|
|
create_post(page, "devlog", "Reputation contribution post")
|
2026-06-19 10:06:09 +02:00
|
|
|
with page.expect_response(
|
|
|
|
|
lambda r: "/votes/" in r.url and r.request.method == "POST"
|
|
|
|
|
):
|
|
|
|
|
page.locator(".post-action-btn.vote-up").first.click()
|
2026-05-27 21:06:18 +02:00
|
|
|
expect(page.locator(".post-vote-count").first).to_have_text("1")
|
2026-05-23 10:54:45 +02:00
|
|
|
after = _profile_stars(page, user["username"])
|
|
|
|
|
assert after == before + 1, f"expected stars {before + 1}, got {after}"
|
|
|
|
|
|
|
|
|
|
|
2026-05-10 09:08:12 +02:00
|
|
|
def test_post_comments_section(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
create_post(page, "rant", "Comment section test")
|
|
|
|
|
assert page.is_visible("text=Comments")
|
|
|
|
|
assert page.is_visible("textarea[placeholder='Your opinion goes here...']")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_add_comment(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
create_post(page, "random", "Post for commenting")
|
2026-05-11 07:02:06 +02:00
|
|
|
textarea = page.locator(".comment-form textarea[name='content']")
|
2026-05-10 09:08:12 +02:00
|
|
|
textarea.fill("This is a test comment from Playwright")
|
2026-05-11 07:02:06 +02:00
|
|
|
page.locator(".comment-form button:has-text('Post')").click()
|
2026-06-09 18:48:08 +02:00
|
|
|
expect(
|
|
|
|
|
page.locator(".comment-text:has-text('This is a test comment from Playwright')")
|
|
|
|
|
).to_be_visible()
|
2026-05-10 09:08:12 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_comment_voting(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
create_post(page, "devlog", "Post for comment voting")
|
2026-05-11 07:02:06 +02:00
|
|
|
textarea = page.locator(".comment-form textarea[name='content']")
|
2026-05-10 09:08:12 +02:00
|
|
|
textarea.fill("Votable comment")
|
2026-05-11 07:02:06 +02:00
|
|
|
page.locator(".comment-form button:has-text('Post')").click()
|
2026-06-02 23:17:51 +02:00
|
|
|
expect(page.locator(".comment-text:has-text('Votable comment')")).to_be_visible()
|
2026-05-10 09:08:12 +02:00
|
|
|
vote_btns = page.locator(".comment-vote-btn")
|
|
|
|
|
upvote = vote_btns.first
|
|
|
|
|
upvote.click()
|
2026-06-02 23:17:51 +02:00
|
|
|
expect(vote_btns.first).to_have_class(re.compile(r"\bvoted\b"))
|
2026-05-10 09:08:12 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_delete_own_comment(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
create_post(page, "fun", "Post for comment deletion")
|
2026-05-11 07:02:06 +02:00
|
|
|
textarea = page.locator(".comment-form textarea[name='content']")
|
2026-05-10 09:08:12 +02:00
|
|
|
textarea.fill("Comment to delete")
|
2026-05-11 07:02:06 +02:00
|
|
|
page.locator(".comment-form button:has-text('Post')").click()
|
2026-06-02 23:17:51 +02:00
|
|
|
comment = page.locator(".comment-text:has-text('Comment to delete')")
|
|
|
|
|
expect(comment).to_be_visible()
|
2026-05-11 05:30:51 +02:00
|
|
|
delete_btn = page.locator(".comment-action-btn:has-text('Delete')").last
|
2026-06-02 23:17:51 +02:00
|
|
|
expect(delete_btn).to_be_visible()
|
|
|
|
|
delete_btn.click()
|
feat: add project file system with CRUD, upload, inline editing, and video attachment support
- Add new `/projects/{slug}/files` endpoint group for per-project filesystem operations including directory and file CRUD, upload, and inline editing with public read and owner write access
- Extend attachment system to support video formats (webm, ogv, mov, m4v) with proper file icons and MIME types
- Implement configurable allowed file types via `allowed_file_types` site setting, replacing hardcoded `ALLOWED_UPLOAD_TYPES` with dynamic `allowed_extensions()` and `is_extension_allowed()` functions
- Add `delete_all_project_files()` call in `delete_content_item()` to clean up project files when a project is deleted
- Create database indexes on `project_files` table for `(project_uid, path)` and `(project_uid, parent_path)` to optimize file lookups
- Introduce `docs_prose.py` module with `render_prose()` function that renders Markdown content inside `data-render` divs using mistune, enabling dynamic prose rendering in documentation pages
- Enhance docs search with Markdown-aware text stripping (`_demarkdown()`) and improved HTML/script/style sanitization for better search indexing
- Update documentation API samples to reflect new attachment response fields (`is_image`, `is_video`, `mime_type`) and note video format support
- Update README to document the new project files endpoint and clarify AI gateway attribution for guest Devii sessions
2026-06-08 22:51:09 +02:00
|
|
|
page.locator(".dialog-overlay.visible .dialog-confirm").click()
|
2026-06-02 23:17:51 +02:00
|
|
|
expect(comment).to_have_count(0)
|
2026-05-10 09:08:12 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_comment_form_elements(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
create_post(page, "random", "Post for checking comment form")
|
|
|
|
|
assert page.is_visible("textarea[placeholder='Your opinion goes here...']")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_share_button(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
create_post(page, "showcase", "Share button check")
|
2026-05-23 03:21:55 +02:00
|
|
|
assert_share_copies(page, "/posts/")
|
2026-05-10 09:08:12 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_multiple_comments_on_post(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
create_post(page, "devlog", "Post with many comments")
|
|
|
|
|
for i in range(3):
|
2026-06-09 18:48:08 +02:00
|
|
|
page.locator(".comment-form textarea[name='content']").fill(
|
|
|
|
|
f"Comment number {i + 1}"
|
|
|
|
|
)
|
2026-05-11 07:02:06 +02:00
|
|
|
page.locator(".comment-form button:has-text('Post')").click()
|
2026-06-09 18:48:08 +02:00
|
|
|
expect(
|
|
|
|
|
page.locator(f".comment-text:has-text('Comment number {i + 1}')")
|
|
|
|
|
).to_be_visible()
|
2026-05-10 09:08:12 +02:00
|
|
|
assert page.is_visible("text=Comment number 1")
|
|
|
|
|
assert page.is_visible("text=Comment number 3")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_comment_and_vote_then_delete(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
create_post(page, "showcase", "Full comment lifecycle post")
|
2026-05-11 07:02:06 +02:00
|
|
|
page.locator(".comment-form textarea[name='content']").fill("Lifecycle comment")
|
|
|
|
|
page.locator(".comment-form button:has-text('Post')").click()
|
2026-06-02 23:17:51 +02:00
|
|
|
comment = page.locator(".comment-text:has-text('Lifecycle comment')")
|
|
|
|
|
expect(comment).to_be_visible()
|
2026-05-10 09:08:12 +02:00
|
|
|
|
|
|
|
|
vote_up = page.locator(".comment-vote-btn").first
|
|
|
|
|
vote_up.click()
|
2026-06-09 18:48:08 +02:00
|
|
|
expect(page.locator(".comment-vote-btn").first).to_have_class(
|
|
|
|
|
re.compile(r"\bvoted\b")
|
|
|
|
|
)
|
2026-05-10 09:08:12 +02:00
|
|
|
|
2026-06-02 23:17:51 +02:00
|
|
|
delete_btn = page.locator(".comment-action-btn:has-text('Delete')").last
|
|
|
|
|
expect(delete_btn).to_be_visible()
|
|
|
|
|
delete_btn.click()
|
feat: add project file system with CRUD, upload, inline editing, and video attachment support
- Add new `/projects/{slug}/files` endpoint group for per-project filesystem operations including directory and file CRUD, upload, and inline editing with public read and owner write access
- Extend attachment system to support video formats (webm, ogv, mov, m4v) with proper file icons and MIME types
- Implement configurable allowed file types via `allowed_file_types` site setting, replacing hardcoded `ALLOWED_UPLOAD_TYPES` with dynamic `allowed_extensions()` and `is_extension_allowed()` functions
- Add `delete_all_project_files()` call in `delete_content_item()` to clean up project files when a project is deleted
- Create database indexes on `project_files` table for `(project_uid, path)` and `(project_uid, parent_path)` to optimize file lookups
- Introduce `docs_prose.py` module with `render_prose()` function that renders Markdown content inside `data-render` divs using mistune, enabling dynamic prose rendering in documentation pages
- Enhance docs search with Markdown-aware text stripping (`_demarkdown()`) and improved HTML/script/style sanitization for better search indexing
- Update documentation API samples to reflect new attachment response fields (`is_image`, `is_video`, `mime_type`) and note video format support
- Update README to document the new project files endpoint and clarify AI gateway attribution for guest Devii sessions
2026-06-08 22:51:09 +02:00
|
|
|
page.locator(".dialog-overlay.visible .dialog-confirm").click()
|
2026-06-02 23:17:51 +02:00
|
|
|
expect(comment).to_have_count(0)
|
2026-05-10 09:08:12 +02:00
|
|
|
|
|
|
|
|
|
2026-06-05 19:22:29 +02:00
|
|
|
def test_comment_reply_inline_form(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
create_post(page, "devlog", "Post for inline reply")
|
|
|
|
|
page.locator(".comment-form textarea[name='content']").fill("Parent comment here")
|
|
|
|
|
page.locator(".comment-form button:has-text('Post')").click()
|
2026-06-09 18:48:08 +02:00
|
|
|
expect(
|
|
|
|
|
page.locator(".comment-text:has-text('Parent comment here')")
|
|
|
|
|
).to_be_visible()
|
2026-06-05 19:22:29 +02:00
|
|
|
page.locator(".comment [data-action='reply']").first.click()
|
|
|
|
|
reply_form = page.locator(".comment-reply-form").first
|
|
|
|
|
expect(reply_form).to_be_visible()
|
|
|
|
|
reply_form.locator("textarea[name='content']").fill("Inline reply text")
|
|
|
|
|
reply_form.locator("button:has-text('Post')").click()
|
2026-06-09 18:48:08 +02:00
|
|
|
expect(
|
|
|
|
|
page.locator(".comment-replies .comment-text:has-text('Inline reply text')")
|
|
|
|
|
).to_be_visible()
|
2026-06-05 19:22:29 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_comment_reply_toggle_and_cancel(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
create_post(page, "devlog", "Post for reply toggle")
|
|
|
|
|
page.locator(".comment-form textarea[name='content']").fill("Toggle parent")
|
|
|
|
|
page.locator(".comment-form button:has-text('Post')").click()
|
|
|
|
|
expect(page.locator(".comment-text:has-text('Toggle parent')")).to_be_visible()
|
|
|
|
|
reply_btn = page.locator(".comment [data-action='reply']").first
|
|
|
|
|
reply_btn.click()
|
|
|
|
|
expect(page.locator(".comment-reply-form")).to_have_count(1)
|
|
|
|
|
reply_btn.click()
|
|
|
|
|
expect(page.locator(".comment-reply-form")).to_have_count(0)
|
|
|
|
|
reply_btn.click()
|
|
|
|
|
expect(page.locator(".comment-reply-form")).to_have_count(1)
|
|
|
|
|
page.locator(".comment-reply-cancel").first.click()
|
|
|
|
|
expect(page.locator(".comment-reply-form")).to_have_count(0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_comment_create_scrolls_to_anchor(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
create_post(page, "devlog", "Post for comment anchor")
|
|
|
|
|
page.locator(".comment-form textarea[name='content']").fill("Anchor comment text")
|
|
|
|
|
page.locator(".comment-form button:has-text('Post')").click()
|
2026-06-09 18:48:08 +02:00
|
|
|
expect(
|
|
|
|
|
page.locator(".comment-text:has-text('Anchor comment text')")
|
|
|
|
|
).to_be_visible()
|
2026-06-05 19:22:29 +02:00
|
|
|
assert "#comment-" in page.url
|
|
|
|
|
comment_uid = page.url.split("#comment-")[1]
|
|
|
|
|
page.wait_for_selector(f"#comment-{comment_uid}.comment-highlight", timeout=5000)
|
2026-06-09 18:48:08 +02:00
|
|
|
expect(page.locator(f"#comment-{comment_uid}")).to_contain_text(
|
|
|
|
|
"Anchor comment text"
|
|
|
|
|
)
|
2026-06-05 19:22:29 +02:00
|
|
|
|
|
|
|
|
|
2026-05-10 09:08:12 +02:00
|
|
|
def test_post_across_all_topics(alice):
|
|
|
|
|
page, _ = alice
|
2026-07-06 05:57:47 +02:00
|
|
|
topics = ["devlog", "showcase", "question", "rant", "fun", "politics"]
|
2026-05-10 09:08:12 +02:00
|
|
|
for topic in topics:
|
|
|
|
|
create_post(page, topic, f"Topic test post for {topic}")
|
|
|
|
|
badge = page.locator(f".badge-{topic}")
|
2026-06-02 23:17:51 +02:00
|
|
|
expect(badge).to_be_visible()
|
feat: replace raw form parsing with pydantic models for auth, admin, bugs, comments, and gists
Migrate all route handlers from manual `request.form()` parsing to typed `Annotated[Model, Form()]` dependencies using new pydantic models (SignupForm, LoginForm, ForgotPasswordForm, AdminRoleForm, AdminPasswordForm, BugForm, CommentForm, GistForm). Remove inline validation logic from signup, login, password reset, admin role/password change, bug creation, comment creation, and gist creation endpoints, delegating validation to model validators and field constraints. Add `RequestValidationError` exception handler with friendly error messages for auth form pages. Update model definitions to use `field_validator` and `model_validator` for cross-field checks like password matching.
2026-05-23 05:44:04 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_content_rendering_markdown_and_highlight(alice):
|
|
|
|
|
page, _ = alice
|
2026-06-09 18:48:08 +02:00
|
|
|
create_post(
|
|
|
|
|
page, "random", "Hello **world bold** text\n\n```python\nprint('hi')\n```"
|
|
|
|
|
)
|
feat: replace raw form parsing with pydantic models for auth, admin, bugs, comments, and gists
Migrate all route handlers from manual `request.form()` parsing to typed `Annotated[Model, Form()]` dependencies using new pydantic models (SignupForm, LoginForm, ForgotPasswordForm, AdminRoleForm, AdminPasswordForm, BugForm, CommentForm, GistForm). Remove inline validation logic from signup, login, password reset, admin role/password change, bug creation, comment creation, and gist creation endpoints, delegating validation to model validators and field constraints. Add `RequestValidationError` exception handler with friendly error messages for auth form pages. Update model definitions to use `field_validator` and `model_validator` for cross-field checks like password matching.
2026-05-23 05:44:04 +02:00
|
|
|
page.locator(".rendered-content strong").first.wait_for(state="visible")
|
|
|
|
|
assert page.locator(".rendered-content pre code").count() >= 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_mention_autocomplete(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
create_post(page, "random", "Post for mention autocomplete here")
|
|
|
|
|
ta = page.locator(".comment-form textarea[data-mention]").first
|
|
|
|
|
ta.click()
|
|
|
|
|
ta.press_sequentially("@bob")
|
|
|
|
|
item = page.locator(".mention-dropdown-item").first
|
|
|
|
|
item.wait_for(state="visible")
|
|
|
|
|
assert "bob" in item.inner_text().lower()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_emoji_picker_opens(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
create_post(page, "random", "Post for emoji picker here")
|
|
|
|
|
page.locator(".emoji-toggle-btn").first.click()
|
|
|
|
|
page.locator(".emoji-picker-wrapper").first.wait_for(state="visible")
|
|
|
|
|
assert page.locator("emoji-picker").first.is_visible()
|
|
|
|
|
|
|
|
|
|
|
Show a post's image on its card, and show it full size
_attachment_display.html iterates a context variable named
`attachments`, so every caller binds it before the include.
_post_card.html was the one caller that did not: it guarded on
item.attachments but included the partial with nothing bound, so the
gallery looped over whatever `attachments` happened to be in the
surrounding page context and rendered empty. Every post with an image
looked image-less on the feed and on profiles, and on a project page -
where project_detail.html sets `attachments` at template scope for the
project's own files - a devlog card would have rendered the project's
files as its own.
With the image actually reaching the card, render a lone one properly:
a gallery holding exactly one item gets a `single` class and takes the
full content column (max-height 480px, object-fit contain, no hover
scale), matching the original DevPlace. That branch serves the stored
original rather than thumbnail_url, because a thumbnail is 200px on its
longest side and stretching it to the column width is visibly blurry.
Animated GIFs needed no change and now have a test proving it: they
never had a thumbnail to flatten, so they already took the original-file
path and simply render larger.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 23:12:39 +02:00
|
|
|
def test_single_image_post_shows_the_original_full_size(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
create_post_with_files(
|
|
|
|
|
page,
|
|
|
|
|
"Post carrying exactly one image",
|
|
|
|
|
[{"name": "shot.png", "mimeType": "image/png", "buffer": _png_bytes()}],
|
|
|
|
|
)
|
|
|
|
|
gallery = page.locator(".attachment-gallery.single")
|
|
|
|
|
gallery.wait_for(state="visible", timeout=10000)
|
|
|
|
|
img = gallery.locator(".gallery-thumb").first
|
|
|
|
|
src = img.get_attribute("src")
|
|
|
|
|
assert "_thumb" not in src, f"hero image served the 200px thumbnail: {src}"
|
|
|
|
|
assert src == img.get_attribute("data-full")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_multiple_image_post_keeps_thumbnails(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
create_post_with_files(
|
|
|
|
|
page,
|
|
|
|
|
"Post carrying two images",
|
|
|
|
|
[
|
|
|
|
|
{"name": "one.png", "mimeType": "image/png", "buffer": _png_bytes()},
|
|
|
|
|
{
|
|
|
|
|
"name": "two.png",
|
|
|
|
|
"mimeType": "image/png",
|
|
|
|
|
"buffer": _png_bytes((200, 30, 90)),
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
expected_count=2,
|
|
|
|
|
)
|
|
|
|
|
page.locator(".attachment-gallery").first.wait_for(state="visible", timeout=10000)
|
|
|
|
|
assert page.locator(".attachment-gallery.single").count() == 0
|
|
|
|
|
thumbs = page.locator(".attachment-gallery .gallery-thumb")
|
|
|
|
|
assert thumbs.count() == 2
|
|
|
|
|
for i in range(thumbs.count()):
|
|
|
|
|
assert "_thumb" in thumbs.nth(i).get_attribute("src")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_animated_gif_post_serves_the_original_file(alice):
|
|
|
|
|
page, _ = alice
|
|
|
|
|
create_post_with_files(
|
|
|
|
|
page,
|
|
|
|
|
"Post carrying an animated gif",
|
|
|
|
|
[{"name": "loop.gif", "mimeType": "image/gif", "buffer": _gif_bytes()}],
|
|
|
|
|
)
|
|
|
|
|
img = page.locator(".attachment-gallery .gallery-thumb").first
|
|
|
|
|
img.wait_for(state="visible", timeout=10000)
|
|
|
|
|
src = img.get_attribute("src")
|
|
|
|
|
assert src.endswith(".gif"), f"animation lost, served {src}"
|
|
|
|
|
|
|
|
|
|
|
feat: replace raw form parsing with pydantic models for auth, admin, bugs, comments, and gists
Migrate all route handlers from manual `request.form()` parsing to typed `Annotated[Model, Form()]` dependencies using new pydantic models (SignupForm, LoginForm, ForgotPasswordForm, AdminRoleForm, AdminPasswordForm, BugForm, CommentForm, GistForm). Remove inline validation logic from signup, login, password reset, admin role/password change, bug creation, comment creation, and gist creation endpoints, delegating validation to model validators and field constraints. Add `RequestValidationError` exception handler with friendly error messages for auth form pages. Update model definitions to use `field_validator` and `model_validator` for cross-field checks like password matching.
2026-05-23 05:44:04 +02:00
|
|
|
def test_attachment_upload_ui(alice):
|
|
|
|
|
import io
|
|
|
|
|
from PIL import Image
|
2026-06-09 18:48:08 +02:00
|
|
|
|
feat: replace raw form parsing with pydantic models for auth, admin, bugs, comments, and gists
Migrate all route handlers from manual `request.form()` parsing to typed `Annotated[Model, Form()]` dependencies using new pydantic models (SignupForm, LoginForm, ForgotPasswordForm, AdminRoleForm, AdminPasswordForm, BugForm, CommentForm, GistForm). Remove inline validation logic from signup, login, password reset, admin role/password change, bug creation, comment creation, and gist creation endpoints, delegating validation to model validators and field constraints. Add `RequestValidationError` exception handler with friendly error messages for auth form pages. Update model definitions to use `field_validator` and `model_validator` for cross-field checks like password matching.
2026-05-23 05:44:04 +02:00
|
|
|
page, _ = alice
|
|
|
|
|
create_post(page, "random", "Post for attachment upload UI")
|
|
|
|
|
buf = io.BytesIO()
|
|
|
|
|
Image.new("RGB", (4, 4), (0, 128, 255)).save(buf, "PNG")
|
feat: add project file system with CRUD, upload, inline editing, and video attachment support
- Add new `/projects/{slug}/files` endpoint group for per-project filesystem operations including directory and file CRUD, upload, and inline editing with public read and owner write access
- Extend attachment system to support video formats (webm, ogv, mov, m4v) with proper file icons and MIME types
- Implement configurable allowed file types via `allowed_file_types` site setting, replacing hardcoded `ALLOWED_UPLOAD_TYPES` with dynamic `allowed_extensions()` and `is_extension_allowed()` functions
- Add `delete_all_project_files()` call in `delete_content_item()` to clean up project files when a project is deleted
- Create database indexes on `project_files` table for `(project_uid, path)` and `(project_uid, parent_path)` to optimize file lookups
- Introduce `docs_prose.py` module with `render_prose()` function that renders Markdown content inside `data-render` divs using mistune, enabling dynamic prose rendering in documentation pages
- Enhance docs search with Markdown-aware text stripping (`_demarkdown()`) and improved HTML/script/style sanitization for better search indexing
- Update documentation API samples to reflect new attachment response fields (`is_image`, `is_video`, `mime_type`) and note video format support
- Update README to document the new project files endpoint and clarify AI gateway attribution for guest Devii sessions
2026-06-08 22:51:09 +02:00
|
|
|
file_input = page.locator(".comment-form dp-upload .dp-upload-input").first
|
2026-06-09 18:48:08 +02:00
|
|
|
file_input.set_input_files(
|
|
|
|
|
{"name": "pic.png", "mimeType": "image/png", "buffer": buf.getvalue()}
|
|
|
|
|
)
|
2026-06-19 00:09:34 +02:00
|
|
|
page.locator(".comment-form dp-upload .dp-upload-count").first.wait_for(
|
2026-06-09 18:48:08 +02:00
|
|
|
state="visible", timeout=15000
|
|
|
|
|
)
|
|
|
|
|
expect(
|
|
|
|
|
page.locator(".comment-form dp-upload input[name='attachment_uids']")
|
|
|
|
|
).to_have_value(re.compile(r".+"))
|