DevPlace CI / test (pull_request) Has been cancelled
The project detail page becomes a full project showcase built entirely
from existing platform mechanisms. One encompassing dark card wraps the
page; inner panels (tab bar, sidebar cards, devlog entries, comments)
sit one elevation lighter. The hero opens with a cover banner and an
optional logo tile, both plain attachment references
(cover_attachment_uid/logo_attachment_uid) uploaded through the
standard dp-upload attachment widget and linked via the existing
link_attachments choke point - the route validates each uid belongs to
the actor and is an image, and an empty value on edit keeps the current
one. The title block, type/platform chips and author row overlay the
banner behind a scrim with a dark text shadow, next to an owner-set
Visit Website CTA; website_url and repo_url are normalized in models
and render with rel noopener nofollow.
An anchor tab bar (Overview, Devlog, Screenshots when present,
Comments, Files) navigates the page. The main column keeps About, the
devlog timeline (with devlog_count and an owner Post update button
opening the shared composer preset to the devlog topic + project - the
form now lives once in _post_composer_form.html, included by feed.html
and project_detail.html), a Screenshots gallery built from image
attachments minus the cover/logo (thumbnails, lightbox, 12 rendered),
and the comment thread; the sidebar holds Links, Stats and the Author
card. Owners add gallery images from the More menu via
POST /projects/{slug}/screenshots (owner-only, audit
project.screenshots.add, Devii action project_add_screenshots, docs id
projects-screenshots). comment_count/devlog_count ride
ProjectDetailOut, the new fields ride ProjectOut, and the create/edit
faces (modals, Devii actions, API docs) carry them. The project
comment/files e2e tests scope their locators per the documented
dual-control idiom, and new unit/api/e2e tests cover URL normalization,
the counts, the hero attachment guard, the screenshots flow and the
preset composer.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
229 lines
7.9 KiB
Python
229 lines
7.9 KiB
Python
# retoor <retoor@molodetz.nl>
|
|
|
|
from uuid import uuid4
|
|
from datetime import datetime, timezone, timedelta
|
|
from playwright.sync_api import expect
|
|
from tests.conftest import BASE_URL
|
|
from devplacepy.database import get_table
|
|
from devplacepy.utils import make_combined_slug
|
|
|
|
|
|
def _seed_project():
|
|
"""Create a project seeded directly into DB and return (slug, uid, user_uid)."""
|
|
owner = str(uuid4())
|
|
get_table("users").insert(
|
|
{
|
|
"uid": owner,
|
|
"username": f"dlowner_{owner[:8]}",
|
|
"terms_version": "1",
|
|
"email": f"{owner[:8]}@dl.test",
|
|
"password_hash": "x",
|
|
"role": "Member",
|
|
"is_active": True,
|
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
}
|
|
)
|
|
uid = str(uuid4())
|
|
slug = make_combined_slug("E2E Devlog Project", uid)
|
|
get_table("projects").insert(
|
|
{
|
|
"deleted_at": None,
|
|
"deleted_by": None,
|
|
"uid": uid,
|
|
"user_uid": owner,
|
|
"slug": slug,
|
|
"title": "E2E Devlog Project",
|
|
"description": "Project for devlog browser tests.",
|
|
"project_type": "software",
|
|
"platforms": "",
|
|
"status": "In Development",
|
|
"stars": 0,
|
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
}
|
|
)
|
|
return slug, uid, owner
|
|
|
|
|
|
def _seed_project_post(project_uid, user_uid, order, title=None):
|
|
"""Insert a post linked to a project with precise ordering."""
|
|
uid = str(uuid4())
|
|
marker = title or f"dlpost-{uid[:8]}"
|
|
get_table("posts").insert(
|
|
{
|
|
"deleted_at": None,
|
|
"deleted_by": None,
|
|
"uid": uid,
|
|
"user_uid": user_uid,
|
|
"slug": make_combined_slug(marker, uid),
|
|
"title": marker,
|
|
"content": f"Devlog post content {order}",
|
|
"topic": "devlog",
|
|
"project_uid": project_uid,
|
|
"image": None,
|
|
"stars": 0,
|
|
"created_at": (datetime(2026, 1, 1, tzinfo=timezone.utc) + timedelta(seconds=order)).isoformat(),
|
|
}
|
|
)
|
|
return marker, uid
|
|
|
|
|
|
def _create_project_ui(page, title):
|
|
"""Create a project via the UI."""
|
|
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
|
|
page.locator("#create-project-btn").click()
|
|
page.fill("#title", title)
|
|
page.fill("#description", "Project for devlog UI test")
|
|
page.click("button:has-text('Create Project')")
|
|
page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
|
|
return page.url
|
|
|
|
|
|
def test_devlog_empty_state_on_project_page(alice):
|
|
"""Project with no linked posts displays 'No devlog posts yet'."""
|
|
page, _ = alice
|
|
_create_project_ui(page, "Empty Devlog Project")
|
|
|
|
devlog_section = page.locator(".project-devlog")
|
|
expect(devlog_section).to_be_visible()
|
|
expect(devlog_section.locator("h2:has-text('Devlog')")).to_be_visible()
|
|
expect(page.locator(".empty-state:has-text('No devlog posts yet.')")).to_be_visible()
|
|
|
|
|
|
def test_devlog_shows_linked_post_title(alice):
|
|
"""Linked post title renders in the devlog section."""
|
|
page, _ = alice
|
|
slug, project_uid, owner_uid = _seed_project()
|
|
|
|
marker = f"visible-dl-{uuid4().hex[:8]}"
|
|
_seed_project_post(project_uid, owner_uid, 0, title=marker)
|
|
|
|
page.goto(f"{BASE_URL}/projects/{slug}", wait_until="domcontentloaded")
|
|
|
|
devlog = page.locator(".project-devlog")
|
|
expect(devlog.locator(f"h3:has-text('{marker}')")).to_be_visible()
|
|
|
|
|
|
def test_devlog_shows_author_info(alice):
|
|
"""Author avatar, name and level appear on devlog posts."""
|
|
page, _ = alice
|
|
slug, project_uid, owner_uid = _seed_project()
|
|
|
|
marker = f"auth-dl-{uuid4().hex[:8]}"
|
|
_seed_project_post(project_uid, owner_uid, 0, title=marker)
|
|
|
|
page.goto(f"{BASE_URL}/projects/{slug}", wait_until="domcontentloaded")
|
|
|
|
post_card = page.locator(".project-devlog .post-card").first
|
|
expect(post_card.locator(".post-header")).to_be_visible()
|
|
expect(post_card.locator(".post-author-link")).to_be_visible()
|
|
expect(post_card.locator(".post-time")).to_be_visible()
|
|
|
|
|
|
def test_devlog_shows_vote_and_comment_buttons(alice):
|
|
"""Devlog post renders vote buttons and comment count."""
|
|
page, _ = alice
|
|
slug, project_uid, owner_uid = _seed_project()
|
|
|
|
marker = f"action-dl-{uuid4().hex[:8]}"
|
|
_seed_project_post(project_uid, owner_uid, 0, title=marker)
|
|
|
|
page.goto(f"{BASE_URL}/projects/{slug}", wait_until="domcontentloaded")
|
|
|
|
post_card = page.locator(".project-devlog .post-card").first
|
|
expect(post_card.locator(".post-action-btn.vote-up")).to_be_visible()
|
|
expect(post_card.locator(".post-action-btn.vote-down")).to_be_visible()
|
|
expect(post_card.locator(".post-vote-count")).to_be_visible()
|
|
expect(post_card.locator("form[action*='/posts/delete/']")).to_be_visible()
|
|
|
|
|
|
def test_devlog_load_more_appears_with_many_posts(alice):
|
|
"""More than PAGE_SIZE posts produces a Load More link."""
|
|
from devplacepy.database.pagination import PAGE_SIZE
|
|
|
|
page, _ = alice
|
|
slug, project_uid, owner_uid = _seed_project()
|
|
|
|
count = PAGE_SIZE + 1
|
|
for i in range(count):
|
|
_seed_project_post(project_uid, owner_uid, i, title=f"loadmore-{i}")
|
|
|
|
page.goto(f"{BASE_URL}/projects/{slug}", wait_until="domcontentloaded")
|
|
|
|
devlog = page.locator(".project-devlog")
|
|
expect(devlog.locator(".post-card")).to_have_count(PAGE_SIZE)
|
|
expect(devlog.locator(".load-more-wrap")).to_be_visible()
|
|
|
|
|
|
def test_devlog_guest_sees_devlog_section(app_server):
|
|
"""Unauthenticated visitors can see the devlog section."""
|
|
import requests
|
|
|
|
slug, _, _ = _seed_project()
|
|
|
|
r = requests.get(f"{BASE_URL}/projects/{slug}")
|
|
assert r.status_code == 200, r.text[:200]
|
|
|
|
assert "No devlog posts yet." in r.text
|
|
assert 'class="project-devlog"' in r.text
|
|
|
|
|
|
def test_devlog_multiple_posts_order(alice):
|
|
"""Posts appear newest-first in the devlog."""
|
|
page, _ = alice
|
|
slug, project_uid, owner_uid = _seed_project()
|
|
|
|
markers = []
|
|
for i in range(3):
|
|
marker = f"order-{i}-{uuid4().hex[:8]}"
|
|
markers.append(marker)
|
|
_seed_project_post(project_uid, owner_uid, i, title=marker)
|
|
|
|
page.goto(f"{BASE_URL}/projects/{slug}", wait_until="domcontentloaded")
|
|
|
|
titles = page.locator(".project-devlog .post-card .post-title")
|
|
expect(titles).to_have_count(3)
|
|
first_text = titles.nth(0).inner_text()
|
|
assert markers[-1] == first_text, (
|
|
f"Expected newest post first: {markers[-1]}, got: {first_text}"
|
|
)
|
|
|
|
|
|
def test_project_stats_card_visible(alice):
|
|
"""The sidebar Stats card shows stars, updates, comments, files, forks."""
|
|
page, _ = alice
|
|
slug, project_uid, owner_uid = _seed_project()
|
|
_seed_project_post(project_uid, owner_uid, 0)
|
|
|
|
page.goto(f"{BASE_URL}/projects/{slug}", wait_until="domcontentloaded")
|
|
|
|
stats = page.locator(".project-stats")
|
|
expect(stats).to_be_visible()
|
|
expect(stats.locator(".project-stat")).to_have_count(5)
|
|
expect(stats.locator(".project-stat:has-text('update')")).to_contain_text("1")
|
|
|
|
|
|
def test_owner_post_update_button_opens_preset_composer(alice):
|
|
"""The owner's Post update button opens the composer preselected to this project."""
|
|
page, _ = alice
|
|
_create_project_ui(page, f"Composer Project {uuid4().hex[:6]}")
|
|
|
|
button = page.locator(".project-devlog-post-btn")
|
|
expect(button).to_be_visible()
|
|
button.click()
|
|
|
|
modal = page.locator("#create-post-modal")
|
|
expect(modal).to_be_visible()
|
|
expect(modal.locator("input[name='topic'][value='devlog']")).to_be_checked()
|
|
selected = modal.locator("#project_uid").input_value()
|
|
assert selected != "", "Expected the project preselected in the composer"
|
|
|
|
|
|
def test_guest_sees_no_post_update_button(app_server):
|
|
"""Guests and non-owners get no Post update control."""
|
|
import requests
|
|
|
|
slug, _, _ = _seed_project()
|
|
r = requests.get(f"{BASE_URL}/projects/{slug}")
|
|
assert r.status_code == 200
|
|
assert "project-devlog-post-btn" not in r.text
|