Merge pull request 'feat: Add devlog timeline of related posts to project page' (#141) from typosaurus/135-add-devlog-timeline-of-related-posts-to-project-page into master
Some checks are pending
DevPlace CI / test (push) Waiting to run

Reviewed-on: #141
This commit is contained in:
typosaurus 2026-07-27 00:52:07 +02:00
commit e05f97c924
9 changed files with 541 additions and 3 deletions

View File

@ -8,7 +8,7 @@ from .users import get_users_by_uids, _admins_cache, invalidate_admins_cache, ge
from .relations import _relations_cache, get_user_relations, get_blocked_uids, get_muted_uids, get_silenced_uids, invalidate_user_relations from .relations import _relations_cache, get_user_relations, get_blocked_uids, get_muted_uids, get_silenced_uids, invalidate_user_relations
from .pagination import PAGE_SIZE, paginate, interleave_by_author, paginate_diverse, get_user_post_count, clear_user_post_count, build_pagination from .pagination import PAGE_SIZE, paginate, interleave_by_author, paginate_diverse, get_user_post_count, clear_user_post_count, build_pagination
from .soft_delete import SOFT_DELETE_TABLES, ensure_soft_delete_columns, soft_delete, soft_delete_in, restore, purge, list_deleted, count_deleted, restore_event, purge_event from .soft_delete import SOFT_DELETE_TABLES, ensure_soft_delete_columns, soft_delete, soft_delete_in, restore, purge, list_deleted, count_deleted, restore_event, purge_event
from .engagement import _comment_count_cache, get_comment_counts_by_post_uids, get_post_counts_by_user_uids, get_vote_counts, get_user_votes, get_reactions_by_targets, get_user_bookmarks, get_polls_by_post_uids, get_poll_for_post from .engagement import _comment_count_cache, get_comment_counts_by_post_uids, get_post_counts_by_user_uids, get_project_devlog, get_vote_counts, get_user_votes, get_reactions_by_targets, get_user_bookmarks, get_polls_by_post_uids, get_poll_for_post
from .usage import _add_usage, _get_usage, add_correction_usage, get_correction_usage, add_modifier_usage, get_modifier_usage, NEWS_USAGE_KEY, add_news_usage, get_news_usage, ISSUE_USAGE_KEY, add_issue_usage, get_issue_usage, SEO_USAGE_KEY, add_seo_usage, get_seo_usage, AWARD_USAGE_KEY, add_award_usage, get_award_usage from .usage import _add_usage, _get_usage, add_correction_usage, get_correction_usage, add_modifier_usage, get_modifier_usage, NEWS_USAGE_KEY, add_news_usage, get_news_usage, ISSUE_USAGE_KEY, add_issue_usage, get_issue_usage, SEO_USAGE_KEY, add_seo_usage, get_seo_usage, AWARD_USAGE_KEY, add_award_usage, get_award_usage
from .awards import ( from .awards import (
AWARDS_PER_PAGE, AWARDS_PER_PAGE,
@ -115,6 +115,7 @@ __all__ = [
"_comment_count_cache", "_comment_count_cache",
"get_comment_counts_by_post_uids", "get_comment_counts_by_post_uids",
"get_post_counts_by_user_uids", "get_post_counts_by_user_uids",
"get_project_devlog",
"get_vote_counts", "get_vote_counts",
"get_user_votes", "get_user_votes",
"get_reactions_by_targets", "get_reactions_by_targets",
@ -254,3 +255,5 @@ __all__ = [
"backfill_api_keys", "backfill_api_keys",
"_backfill_gamification", "_backfill_gamification",
] ]

View File

@ -1,6 +1,9 @@
# retoor <retoor@molodetz.nl> # retoor <retoor@molodetz.nl>
from .core import TTLCache, _in_clause, db, defaultdict from .core import TTLCache, _in_clause, db, defaultdict, get_table
from .pagination import paginate
from devplacepy.content import enrich_items
from .users import get_users_by_uids
_comment_count_cache = TTLCache(ttl=15, max_size=10000) _comment_count_cache = TTLCache(ttl=15, max_size=10000)
@ -185,3 +188,25 @@ def get_polls_by_post_uids(post_uids, user=None):
def get_poll_for_post(post_uid, user=None): def get_poll_for_post(post_uid, user=None):
return get_polls_by_post_uids([post_uid], user).get(post_uid) return get_polls_by_post_uids([post_uid], user).get(post_uid)
def get_project_devlog(project_uid: str, before: str | None = None, viewer: dict | None = None) -> tuple[list, str | None]:
posts_table = get_table("posts")
posts, next_cursor = paginate(
posts_table,
before=before,
viewer_uid=viewer["uid"] if viewer else None,
project_uid=project_uid,
)
if not posts:
return [], None
authors = get_users_by_uids([p["user_uid"] for p in posts])
counts = get_comment_counts_by_post_uids([p["uid"] for p in posts])
result = enrich_items(
posts, "post", authors, {"comment_count": counts}, user=viewer
)
return result, next_cursor

View File

@ -42,6 +42,7 @@ def init_db():
_index(db, "posts", "idx_posts_created_at", ["created_at"]) _index(db, "posts", "idx_posts_created_at", ["created_at"])
_index(db, "posts", "idx_posts_topic", ["topic"]) _index(db, "posts", "idx_posts_topic", ["topic"])
_index(db, "posts", "idx_posts_slug", ["slug"]) _index(db, "posts", "idx_posts_slug", ["slug"])
_index(db, "posts", "idx_posts_project_uid", ["project_uid"])
if "posts" in tables: if "posts" in tables:
posts_table = get_table("posts") posts_table = get_table("posts")
if not posts_table.has_column("tags"): if not posts_table.has_column("tags"):
@ -1826,3 +1827,4 @@ def _backfill_gamification():
for user in pending: for user in pending:
check_milestone_badges(user["uid"]) check_milestone_badges(user["uid"])
logger.info(f"Gamification backfill processed {len(pending)} users") logger.info(f"Gamification backfill processed {len(pending)} users")

View File

@ -6,6 +6,7 @@ from sqlalchemy import or_
from fastapi import Depends, APIRouter, Request from fastapi import Depends, APIRouter, Request
from devplacepy.models import ProjectForm, ProjectEditForm, ProjectFlagForm, ForkForm from devplacepy.models import ProjectForm, ProjectEditForm, ProjectFlagForm, ForkForm
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
from devplacepy.attachments import get_attachments_batch
from devplacepy.database import ( from devplacepy.database import (
get_table, get_table,
get_users_by_uids, get_users_by_uids,
@ -13,6 +14,10 @@ from devplacepy.database import (
get_site_stats, get_site_stats,
get_user_votes, get_user_votes,
get_recent_comments_by_target_uids, get_recent_comments_by_target_uids,
get_reactions_by_targets,
get_user_bookmarks,
get_polls_by_post_uids,
get_project_devlog,
paginate, paginate,
text_search_clause, text_search_clause,
resolve_by_slug, resolve_by_slug,
@ -171,7 +176,7 @@ async def projects_page(
) )
@router.get("/{project_slug}", response_class=HTMLResponse) @router.get("/{project_slug}", response_class=HTMLResponse)
async def project_detail(request: Request, project_slug: str): async def project_detail(request: Request, project_slug: str, before: str = None):
user = get_current_user(request) user = get_current_user(request)
detail = load_detail("projects", "project", project_slug, user) detail = load_detail("projects", "project", project_slug, user)
if not detail: if not detail:
@ -216,6 +221,25 @@ async def project_detail(request: Request, project_slug: str):
if parent if parent
else None else None
) )
devlog_posts, devlog_next_cursor = get_project_devlog(
project["uid"], before=before, viewer=user
)
if devlog_posts:
post_uids = [item["post"]["uid"] for item in devlog_posts]
attachments_map = get_attachments_batch("post", post_uids)
reactions_map = get_reactions_by_targets("post", post_uids, user)
bookmark_set = (
get_user_bookmarks(user["uid"], "post", post_uids) if user else set()
)
polls_map = get_polls_by_post_uids(post_uids, user)
for item in devlog_posts:
uid = item["post"]["uid"]
item["attachments"] = attachments_map.get(uid, [])
item["reactions"] = reactions_map.get(uid, {"counts": {}, "mine": []})
item["bookmarked"] = uid in bookmark_set
item["poll"] = polls_map.get(uid)
return respond( return respond(
request, request,
"project_detail.html", "project_detail.html",
@ -235,6 +259,8 @@ async def project_detail(request: Request, project_slug: str):
"forked_from": forked_from, "forked_from": forked_from,
"fork_count": count_forks(project["uid"]), "fork_count": count_forks(project["uid"]),
"file_count": count_files(project["uid"]), "file_count": count_files(project["uid"]),
"devlog_posts": devlog_posts,
"devlog_next_cursor": devlog_next_cursor,
}, },
), ),
model=ProjectDetailOut, model=ProjectDetailOut,
@ -464,3 +490,5 @@ async def set_project_readonly(
request: Request, project_slug: str, data: Annotated[ProjectFlagForm, Depends(json_or_form(ProjectFlagForm))] request: Request, project_slug: str, data: Annotated[ProjectFlagForm, Depends(json_or_form(ProjectFlagForm))]
): ):
return _set_project_flag(request, project_slug, "read_only", data.value) return _set_project_flag(request, project_slug, "read_only", data.value)

View File

@ -163,6 +163,8 @@ class ProjectDetailOut(_Out):
forked_from: Optional[dict] = None forked_from: Optional[dict] = None
fork_count: int = 0 fork_count: int = 0
file_count: int = 0 file_count: int = 0
devlog_posts: list[FeedItemOut] = []
devlog_next_cursor: Optional[str] = None
class GistsOut(_Out): class GistsOut(_Out):
@ -232,3 +234,4 @@ class LeaderboardOut(_Out):
class SavedOut(_Out): class SavedOut(_Out):
items: list[SavedItemOut] = [] items: list[SavedItemOut] = []
next_cursor: Optional[str] = None next_cursor: Optional[str] = None

View File

@ -305,6 +305,10 @@
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
} }
.project-devlog {
margin-top: 1.5rem;
}
.project-section-label { .project-section-label {
font-size: 0.75rem; font-size: 0.75rem;
font-weight: 700; font-weight: 700;
@ -313,3 +317,4 @@
color: var(--text-muted); color: var(--text-muted);
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }

View File

@ -102,6 +102,18 @@
</div> </div>
</article> </article>
<section class="project-devlog">
<h3 class="project-section-label">Devlog</h3>
{% if devlog_posts %}
{% for item in devlog_posts %}
{% set _author = item.author %}{% set _time = item.time_ago %}{% set _show_share = false %}{% set _show_comment_form = false %}{% include "_post_card.html" %}
{% endfor %}
{% set next_cursor = devlog_next_cursor %}{% include "_load_more.html" %}
{% else %}
<p class="empty-state">No devlog posts yet.</p>
{% endif %}
</section>
{% if is_owner %} {% if is_owner %}
{% call modal('edit-project-modal', 'Edit Project') %} {% call modal('edit-project-modal', 'Edit Project') %}
<form method="POST" action="/projects/edit/{{ project['slug'] or project['uid'] }}"> <form method="POST" action="/projects/edit/{{ project['slug'] or project['uid'] }}">
@ -184,3 +196,6 @@ if (actions) {
} }
</script> </script>
{% endblock %} {% endblock %}

View File

@ -0,0 +1,271 @@
# retoor <retoor@molodetz.nl>
import time
from datetime import datetime, timezone, timedelta
import pytest
import requests
from tests.conftest import BASE_URL
from devplacepy.database import get_table, refresh_snapshot, set_setting
from devplacepy.utils import generate_uid
JSON = {"Accept": "application/json"}
_counter = [0]
@pytest.fixture(scope="module", autouse=True)
def _devlog_test_settings(app_server):
for key, value in {
"rate_limit_per_minute": "1000000",
"rate_limit_window_seconds": "60",
"registration_open": "1",
"maintenance_mode": "0",
}.items():
set_setting(key, value)
yield
def _unique(prefix="dl"):
_counter[0] += 1
return f"{prefix}{int(time.time() * 1000)}{_counter[0]}"
def _member():
name = _unique("dlmem")
s = requests.Session()
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return s, name
def _db_user(name):
refresh_snapshot()
return get_table("users").find_one(username=name)
def _create_project(session, title=None):
title = title or _unique("dlproj")
r = session.post(
f"{BASE_URL}/projects/create",
headers=JSON,
data={
"title": title,
"description": "Devlog test project",
"project_type": "software",
"status": "In Development",
"platforms": "",
},
)
assert r.status_code == 200, r.text[:300]
return r.json()["data"]
def _create_post(session, content, project_uid, title=None):
title = title or _unique("dlpost")
r = session.post(
f"{BASE_URL}/posts/create",
headers=JSON,
data={
"title": title,
"content": content,
"topic": "devlog",
"project_uid": project_uid,
},
)
assert r.status_code == 200, r.text[:300]
return r.json()["data"]
def _create_post_direct(project_uid, user_uid, order, marker=None):
"""Insert a post directly into DB with precise created_at ordering."""
uid = generate_uid()
marker = marker or f"dlpost-{uid[:8]}"
get_table("posts").insert(
{
"deleted_at": None,
"deleted_by": None,
"uid": uid,
"user_uid": user_uid,
"slug": f"{uid[:8]}-devlog-post",
"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(),
}
)
refresh_snapshot()
return uid, marker
def test_devlog_empty_state_when_no_posts(app_server):
"""Project with no linked posts returns empty devlog_posts list."""
session, _ = _member()
project = _create_project(session)
slug = project["slug"] or project["uid"]
r = session.get(f"{BASE_URL}/projects/{slug}", headers=JSON)
assert r.status_code == 200, r.text[:300]
body = r.json()
assert body["devlog_posts"] == []
assert body["devlog_next_cursor"] is None
def test_devlog_shows_linked_post(app_server):
"""A post linked via project_uid appears in the project's devlog."""
session, name = _member()
project = _create_project(session)
slug = project["slug"] or project["uid"]
marker = _unique("dllink")
_create_post(session, marker, project["uid"], title=marker)
r = session.get(f"{BASE_URL}/projects/{slug}", headers=JSON)
assert r.status_code == 200, r.text[:300]
body = r.json()
assert len(body["devlog_posts"]) == 1
assert body["devlog_next_cursor"] is None
post_item = body["devlog_posts"][0]
assert post_item["post"]["title"] == marker
assert post_item["author"]["username"] == name
def test_devlog_reverse_chronological_order(app_server):
"""Multiple linked posts appear newest-first."""
session, name = _member()
project = _create_project(session)
slug = project["slug"] or project["uid"]
user = _db_user(name)
project_uid = project["uid"]
markers = []
for i in range(3):
marker = f"dlorder-{i}-{generate_uid()[:8]}"
markers.append(marker)
_create_post_direct(project_uid, user["uid"], i, marker=marker)
r = session.get(f"{BASE_URL}/projects/{slug}", headers=JSON)
assert r.status_code == 200, r.text[:300]
body = r.json()
titles = [item["post"]["title"] for item in body["devlog_posts"]]
assert titles == list(reversed(markers)), (
f"Expected newest-first order: {list(reversed(markers))}, got: {titles}"
)
def test_devlog_pagination(app_server):
"""More than PAGE_SIZE posts produce next_cursor."""
from devplacepy.database.pagination import PAGE_SIZE
session, name = _member()
project = _create_project(session)
slug = project["slug"] or project["uid"]
user = _db_user(name)
project_uid = project["uid"]
count = PAGE_SIZE + 1
for i in range(count):
_create_post_direct(project_uid, user["uid"], i, marker=f"dlpag-{i}")
r = session.get(f"{BASE_URL}/projects/{slug}", headers=JSON)
assert r.status_code == 200, r.text[:300]
body = r.json()
assert len(body["devlog_posts"]) == PAGE_SIZE, (
f"Expected {PAGE_SIZE} posts on first page, got {len(body['devlog_posts'])}"
)
assert body["devlog_next_cursor"] is not None, (
"Expected next_cursor when more than PAGE_SIZE posts exist"
)
before = body["devlog_next_cursor"]
r2 = session.get(f"{BASE_URL}/projects/{slug}", headers=JSON, params={"before": before})
assert r2.status_code == 200, r2.text[:300]
body2 = r2.json()
assert len(body2["devlog_posts"]) == 1, (
f"Expected 1 post on second page, got {len(body2['devlog_posts'])}"
)
assert body2["devlog_next_cursor"] is None, (
"Expected no next_cursor on last page"
)
r_html = session.get(f"{BASE_URL}/projects/{slug}")
assert r_html.status_code == 200
assert 'class="load-more-wrap"' in r_html.text, (
"Expected Load More button in HTML for paginated devlog"
)
def test_devlog_excludes_unlinked_posts(app_server):
"""Posts without project_uid do not appear in any project's devlog."""
session, _ = _member()
project = _create_project(session)
slug = project["slug"] or project["uid"]
unlinked = _unique("dlnolink")
session.post(
f"{BASE_URL}/posts/create",
headers=JSON,
data={
"title": unlinked,
"content": "This post has no project",
"topic": "devlog",
},
)
r = session.get(f"{BASE_URL}/projects/{slug}", headers=JSON)
assert r.status_code == 200, r.text[:300]
body = r.json()
titles = [item["post"]["title"] for item in body["devlog_posts"]]
assert unlinked not in titles, (
"Post without project_uid must not appear in devlog"
)
def test_devlog_enriches_author_and_metadata(app_server):
"""Devlog posts include author data, comment count, and vote info."""
session, name = _member()
user = _db_user(name)
project = _create_project(session)
slug = project["slug"] or project["uid"]
marker = _unique("dlenrich")
post_data = _create_post(session, marker, project["uid"], title=marker)
r = session.get(f"{BASE_URL}/projects/{slug}", headers=JSON)
assert r.status_code == 200, r.text[:300]
body = r.json()
assert len(body["devlog_posts"]) == 1
item = body["devlog_posts"][0]
assert item["author"]["username"] == name
assert item["author"]["uid"] == user["uid"]
assert isinstance(item["my_vote"], int)
assert isinstance(item["comment_count"], int)
assert item["comment_count"] == 0
assert item["post"]["uid"] == post_data["uid"]
assert item["post"]["slug"] == post_data["slug"]
assert item["post"]["title"] == marker
assert item["time_ago"] is not None
def test_devlog_works_for_guest_visitor(app_server):
"""Unauthenticated visitors can see the devlog section."""
session, _ = _member()
project = _create_project(session)
slug = project["slug"] or project["uid"]
r = requests.get(f"{BASE_URL}/projects/{slug}", headers=JSON)
assert r.status_code == 200, r.text[:300]
body = r.json()
assert "devlog_posts" in body
assert body["devlog_posts"] == []

View File

@ -0,0 +1,186 @@
# 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]}",
"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("h3: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")).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}"
)