246 lines
8.4 KiB
Python
Raw Normal View History

# retoor <retoor@molodetz.nl>
import types
import devplacepy.seo as seo
import devplacepy.attachments as attach
def _seo_request():
return types.SimpleNamespace(
base_url="https://x.test/",
url=types.SimpleNamespace(path="/posts/abc"),
query_params={},
)
def test_truncate_no_overflow():
assert len(seo.truncate("x" * 200)) <= 160
assert seo.truncate("short text") == "short text"
assert seo.truncate("") == ""
def test_software_application_url_is_per_project():
schema = seo.software_application_schema(
{"uid": "p1", "slug": "p1-foo", "title": "P"}, "https://x.test"
)
assert schema["url"] == "https://x.test/projects/p1-foo"
def test_schema_types():
assert seo.organization_schema("https://x.test")["@type"] == "Organization"
assert (
seo.news_article_schema(
{"uid": "n1", "title": "T", "synced_at": "2026-01-01"}, "https://x.test"
)["@type"]
== "NewsArticle"
)
assert (
seo.software_source_code_schema(
{"uid": "g1", "title": "G", "language": "python"}, "https://x.test"
)["@type"]
== "SoftwareSourceCode"
)
def test_news_article_publisher_is_organization():
schema = seo.news_article_schema(
{"uid": "n1", "title": "T", "synced_at": "2026-01-01"}, "https://x.test"
)
assert schema["publisher"]["@type"] == "Organization"
Make the project page an SEO-optimized overview with a devlog environment The project detail page becomes a professional project overview: hero with status/type/dates/forked-from, a stats strip (stars, updates, comments, files, forks with #devlog/#comments anchors), an About section, platforms, and the Devlog timeline under a proper h2 - now rendered with feed.css loaded so the post cards are actually styled. The owner posts updates from the page itself: a Post update button opens the shared create-post composer preset to the devlog topic and this project. The composer form is extracted into _post_composer_form.html and reused by feed.html - one form, two surfaces. SEO: software_application_schema is type-aware via project_schema_type (game -> VideoGame with gamePlatform, website -> WebApplication, software/mobile_app -> SoftwareApplication, game_asset -> CreativeWork) and now carries keywords, image, an aggregateRating from stars, and a comment InteractionCounter. project_devlog_schema emits a Blog node with one BlogPosting per devlog entry. The detail route feeds both, adds meta keywords, and exposes the devlog cursor as rel=next; the sitemap's project lastmod follows the newest devlog post via one grouped query. devlog_count/comment_count ride ProjectDetailOut, the docs projects-detail endpoint documents the before cursor, and the routers/projects CLAUDE.md, templates CLAUDE.md and README document the new surface. Unit, api and e2e tests cover the schema mapping, the JSON-LD in the rendered page, the stats strip, and the preset composer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 21:05:04 +02:00
def test_project_schema_type_mapping():
assert seo.project_schema_type("game") == "VideoGame"
assert seo.project_schema_type("game_asset") == "CreativeWork"
assert seo.project_schema_type("website") == "WebApplication"
assert seo.project_schema_type("software") == "SoftwareApplication"
assert seo.project_schema_type("mobile_app") == "SoftwareApplication"
assert seo.project_schema_type(None) == "SoftwareApplication"
assert seo.project_schema_type("unknown") == "SoftwareApplication"
def test_software_application_schema_is_type_aware():
game = seo.software_application_schema(
{"uid": "p1", "slug": "p1-g", "title": "G", "project_type": "game", "platforms": "PC, Web"},
"https://x.test",
)
assert game["@type"] == "VideoGame"
assert game["gamePlatform"] == ["PC", "Web"]
assert "applicationCategory" not in game
web = seo.software_application_schema(
{"uid": "p2", "slug": "p2-w", "title": "W", "project_type": "website"},
"https://x.test",
)
assert web["@type"] == "WebApplication"
assert web["browserRequirements"] == "Requires JavaScript"
assert web["applicationCategory"] == "DeveloperApplication"
def test_software_application_schema_rating_and_image():
project = {"uid": "p1", "slug": "p1-s", "title": "S", "project_type": "software"}
plain = seo.software_application_schema(project, "https://x.test")
assert "aggregateRating" not in plain
assert "image" not in plain
rich = seo.software_application_schema(
project,
"https://x.test",
image_url="https://x.test/img.png",
star_count=7,
comment_count=3,
)
assert rich["aggregateRating"]["ratingCount"] == "7"
assert rich["image"] == "https://x.test/img.png"
assert rich["interactionStatistic"]["userInteractionCount"] == 3
Dedicate the project page to the project: hero, tabs, screenshots, sidebar The project detail page becomes a full project showcase on the site's content measure. The hero card opens with a cover banner from the project's first image attachment (brand-gradient band as fallback), then title + status chip, type/platform chips, dates and forked-from meta, the author row with an owner-set Visit Website CTA, and the unchanged action row. A sticky anchor tab bar (Overview, Devlog, Screenshots when images exist, Comments, Files) navigates the page with plain server-rendered anchors so crawlers index one complete document. The two-column body keeps About (description + non-image attachments), the Devlog timeline and the comment thread in the main column, adds a Screenshots gallery built from image attachments (lightbox-wired thumbnails), and a sidebar with Links (website, files, fork source), the Stats card with a last-update line, and the Author card. New optional projects.website_url rides the whole stack: normalized and validated in models (scheme-less input gets https://, non-http(s) rejected), settable in the create and edit modals, on ProjectOut, in the Devii create/edit actions and the API docs, rendered as the hero CTA and Links entry with rel noopener nofollow, and emitted as schema.org sameAs. The app schema also gains screenshot urls from the image attachments. The e2e project comment/files tests scope their locators (.comment-form textarea, .project-detail-actions a) per the documented dual-control idiom - the composer modal made the bare selectors ambiguous - and new unit/api tests cover URL normalization, sameAs/screenshot schema output, the cover, and the gallery. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 21:56:50 +02:00
def test_software_application_schema_website_and_screenshots():
project = {
"uid": "p1",
"slug": "p1-s",
"title": "S",
"project_type": "software",
"website_url": "https://myproject.dev",
}
schema = seo.software_application_schema(
project,
"https://x.test",
screenshot_urls=["https://x.test/a.png", "https://x.test/b.png"],
)
assert schema["sameAs"] == ["https://myproject.dev"]
assert schema["screenshot"] == ["https://x.test/a.png", "https://x.test/b.png"]
bare = seo.software_application_schema(
{"uid": "p2", "slug": "p2-s", "title": "B"}, "https://x.test"
)
assert "sameAs" not in bare
assert "screenshot" not in bare
Make the project page an SEO-optimized overview with a devlog environment The project detail page becomes a professional project overview: hero with status/type/dates/forked-from, a stats strip (stars, updates, comments, files, forks with #devlog/#comments anchors), an About section, platforms, and the Devlog timeline under a proper h2 - now rendered with feed.css loaded so the post cards are actually styled. The owner posts updates from the page itself: a Post update button opens the shared create-post composer preset to the devlog topic and this project. The composer form is extracted into _post_composer_form.html and reused by feed.html - one form, two surfaces. SEO: software_application_schema is type-aware via project_schema_type (game -> VideoGame with gamePlatform, website -> WebApplication, software/mobile_app -> SoftwareApplication, game_asset -> CreativeWork) and now carries keywords, image, an aggregateRating from stars, and a comment InteractionCounter. project_devlog_schema emits a Blog node with one BlogPosting per devlog entry. The detail route feeds both, adds meta keywords, and exposes the devlog cursor as rel=next; the sitemap's project lastmod follows the newest devlog post via one grouped query. devlog_count/comment_count ride ProjectDetailOut, the docs projects-detail endpoint documents the before cursor, and the routers/projects CLAUDE.md, templates CLAUDE.md and README document the new surface. Unit, api and e2e tests cover the schema mapping, the JSON-LD in the rendered page, the stats strip, and the preset composer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 21:05:04 +02:00
def test_project_devlog_schema_builds_blog_graph():
project = {"uid": "p1", "slug": "p1-s", "title": "Nebula"}
assert seo.project_devlog_schema(project, [], "https://x.test") is None
posts = [
{
"post": {
"uid": "a1",
"slug": "a1-first",
"title": "First update",
"content": "Progress!",
"created_at": "2026-01-01T00:00:00+00:00",
},
"author": {"username": "dev"},
"comment_count": 2,
}
]
schema = seo.project_devlog_schema(project, posts, "https://x.test")
assert schema["@type"] == "Blog"
assert schema["url"] == "https://x.test/projects/p1-s#devlog"
entry = schema["blogPost"][0]
assert entry["@type"] == "BlogPosting"
assert entry["headline"] == "First update"
assert entry["url"] == "https://x.test/posts/a1-first"
assert entry["author"]["name"] == "dev"
assert entry["commentCount"] == 2
def test_site_url_precedence(monkeypatch):
import devplacepy.database as database
monkeypatch.setattr(seo, "SITE_URL", "https://constant.test")
monkeypatch.setattr(
database, "get_setting", lambda *a, **k: "https://setting.test"
)
assert seo.site_url(None) == "https://setting.test"
monkeypatch.setattr(database, "get_setting", lambda *a, **k: "")
assert seo.site_url(None) == "https://constant.test"
monkeypatch.setattr(seo, "SITE_URL", "")
request = types.SimpleNamespace(base_url="https://req.test/")
assert seo.site_url(request) == "https://req.test"
def test_site_url_falls_back_to_request(monkeypatch):
monkeypatch.setattr(seo, "SITE_URL", "")
request = types.SimpleNamespace(base_url="http://fallback.test/")
assert seo.site_url(request) == "http://fallback.test"
def test_upload_allowlist_excludes_dangerous_types():
assert ".svg" not in attach.ALLOWED_UPLOAD_TYPES
assert ".html" not in attach.ALLOWED_UPLOAD_TYPES
assert ".png" in attach.ALLOWED_UPLOAD_TYPES
assert ".svg" not in attach.POST_IMAGE_EXTENSIONS
def test_detect_mime_neutralizes_dangerous_types():
assert attach._detect_mime(b"", "x.svg") == "application/octet-stream"
assert attach._detect_mime(b"", "x.html") == "application/octet-stream"
assert attach._detect_mime(b"", "x.png") == "image/png"
def test_base_seo_context_emits_keywords_key():
ctx = seo.base_seo_context(
_seo_request(),
title="A Post",
description="Body text",
keywords="python, sqlite",
)
assert "meta_keywords" in ctx
assert ctx["meta_keywords"] == "python, sqlite"
def test_base_seo_context_default_keywords_empty_without_target():
ctx = seo.base_seo_context(
_seo_request(), title="A Post", description="Body text"
)
assert ctx["meta_keywords"] == ""
def test_base_seo_context_description_strips_markdown(monkeypatch):
monkeypatch.setattr(seo, "_ready_seo_metadata", lambda target: None)
ctx = seo.base_seo_context(
_seo_request(),
title="A Post",
description="## Heading\n\nThis is **bold** body with `code`.",
)
assert "##" not in ctx["meta_description"]
assert "**" not in ctx["meta_description"]
assert "`" not in ctx["meta_description"]
assert "bold" in ctx["meta_description"]
def test_base_seo_context_plain_default_fills_fields_for_target(monkeypatch):
monkeypatch.setattr(seo, "_ready_seo_metadata", lambda target: None)
ctx = seo.base_seo_context(
_seo_request(),
title="Async Python Database Tooling",
description="A **guide** to async python database tooling with sqlite.",
seo_target=("post", "abc"),
)
assert ctx["meta_keywords"]
assert "python" in ctx["meta_keywords"]
assert ctx["meta_description"]
assert "**" not in ctx["meta_description"]
def test_base_seo_context_consumes_ready_metadata(monkeypatch):
monkeypatch.setattr(
seo,
"_ready_seo_metadata",
lambda target: {
"seo_title": "Generated Title",
"seo_description": "Generated clean description",
"seo_keywords": "generated, metadata",
},
)
ctx = seo.base_seo_context(
_seo_request(),
title="Raw **markdown** title",
description="raw markdown body",
seo_target=("post", "abc"),
)
assert "Generated Title" in ctx["page_title"]
assert ctx["meta_description"] == "Generated clean description"
assert ctx["meta_keywords"] == "generated, metadata"