Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .github/ISSUE_TEMPLATE/submit-mod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,23 @@ body:
description: A few sentences for the reviewer, and anything the code doesn't make obvious (what it hooks, what it sends where).
validations:
required: true
- type: textarea
id: screenshots
attributes:
label: Screenshots
description: "Optional. One per line: an https:// link, a `|`, and what it shows. Up to 8. They go in your listing in the launcher's plugin browser."
placeholder: "https://example.com/menu.png | The mod's menu in the lobby"
- type: input
id: video
attributes:
label: Video
description: Optional. One https:// link to a video of the mod.
- type: textarea
id: credits
attributes:
label: Credits
description: "Everyone whose work the mod builds on, one per line: name, a `|`, what they did, and optionally a `|` and a link. Include yourself."
placeholder: "Dualgame | Wrote the original rad-links | https://github.com/Dualgame"
- type: checkboxes
id: rules
attributes:
Expand All @@ -51,3 +68,5 @@ body:
required: true
- label: I made this mod or may submit it for its authors.
required: true
- label: Everyone whose work the mod builds on is named in Credits, and their license files are in the repo.
required: true
20 changes: 20 additions & 0 deletions .github/workflows/submission-fields-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# The submission form's field parsing. Runs no submitted code and holds no secrets.
name: submission fields tests

on:
pull_request:
push:
branches: [main]

permissions: {}

jobs:
submission-fields-tests:
runs-on: ubuntu-24.04
permissions:
contents: read
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
persist-credentials: false
- run: python3 -I -m unittest discover -s tests -k test_submission_fields -v
12 changes: 12 additions & 0 deletions scripts/inspect_submission.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import tempfile
import urllib.error
import urllib.request
import submission_fields
from datetime import datetime, timezone
from pathlib import Path

Expand All @@ -38,6 +39,9 @@
"Mod id": "mod_id",
"EchoVRCE username": "echovrce",
"What does it do?": "about",
"Screenshots": "screenshots",
"Video": "video",
"Credits": "credits",
}


Expand Down Expand Up @@ -155,6 +159,14 @@ def check(args, report):
owner, name = m.group(1), m.group(2)
if not fields.get("echovrce"):
report.error("EchoVRCE username is missing.")
screenshots, e1 = submission_fields.parse_screenshots(fields.get("screenshots"))
video, e2 = submission_fields.parse_video(fields.get("video"))
credits, e3 = submission_fields.parse_credits(fields.get("credits"))
for text in e1 + e2 + e3:
report.error(text)
report.facts.update(media={"screenshots": screenshots, **({"video": video} if video else {})}, credits=credits)
if not credits:
report.warn("Credits is empty: name yourself, and everyone whose work the mod builds on.")

# ---- the repo ----
repo = gh_api(f"repos/{owner}/{name}", allow_404=True)
Expand Down
66 changes: 66 additions & 0 deletions scripts/submission_fields.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"""Parses the submission form's media and credits fields into the shapes the entry schema
(schema/entry.schema.json: media, credits) takes. One item per line, parts separated by ` | `:

Screenshots https://example.com/menu.png | The mod's menu in the lobby
Video https://example.com/demo.mp4
Credits Dualgame | Wrote the original rad-links | https://github.com/Dualgame

Every function returns (value, errors): errors are sentences for the author, never exceptions. The limits
are the schema's. Python 3.9+, standard library only.
"""
import re

HTTPS = re.compile(r"^https://[^\s/]+(?:/\S*)?$")
MAX_URL, MAX_SCREENSHOTS, MAX_CREDITS = 500, 8, 20


def lines(text):
"""The non-empty lines of a form answer; an unanswered field is none."""
text = (text or "").strip()
return [] if text == "_No response_" else [l.strip() for l in text.splitlines() if l.strip()]


def url_error(url):
if not HTTPS.match(url) or len(url) > MAX_URL:
return f"`{url[:80]}` isn't an https:// link"
return None


def parse_screenshots(text):
items, errors = [], []
for line in lines(text):
url, _, alt = (p.strip() for p in line.partition("|"))
if url_error(url):
errors.append(f"Screenshots: {url_error(url)}; write `https://link | what it shows`")
elif not alt or len(alt) > 200:
errors.append(f"Screenshots: say in a few words what {url[:60]} shows (up to 200 characters), after a `|`")
else:
items.append({"url": url, "alt": alt})
if len(items) > MAX_SCREENSHOTS:
errors.append(f"Screenshots: at most {MAX_SCREENSHOTS}")
return items, errors


def parse_video(text):
found = lines(text)
if not found:
return "", []
if len(found) > 1:
return "", ["Video: one link only"]
return ("", [f"Video: {url_error(found[0])}"]) if url_error(found[0]) else (found[0], [])


def parse_credits(text):
items, errors = [], []
for line in lines(text):
parts = [p.strip() for p in line.split("|")]
name, role, url = (parts + ["", ""])[:3]
if len(parts) > 3 or not name or len(name) > 80 or not role or len(role) > 120:
errors.append(f"Credits: `{line[:80]}` isn't `name | what they did | link (optional)`")
elif url and url_error(url):
errors.append(f"Credits: {url_error(url)} (the link for {name})")
else:
items.append({"name": name, "role": role, **({"url": url} if url else {})})
if len(items) > MAX_CREDITS:
errors.append(f"Credits: at most {MAX_CREDITS}")
return items, errors
138 changes: 138 additions & 0 deletions tests/test_submission_fields.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
"""The submission form's media and credits fields, and that the check reads every field the form has.

python3 -I -m unittest discover -s tests -v
"""
import re
import sys
import unittest
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "scripts"))
import inspect_submission # noqa: E402
import submission_fields as sf # noqa: E402

BODY = """### Repository

https://github.com/you/my-cool-mod

### Mod id

my-cool-mod

### EchoVRCE username

You

### What does it do?

Things.

### Screenshots

https://example.com/menu.png | The menu in the lobby
https://example.com/hud.png | The HUD during a match

### Video

https://example.com/demo.mp4

### Credits

You | Wrote the mod | https://github.com/you
Dualgame | Wrote the original rad-links

### Rules

- [x] ok
"""


class Form(unittest.TestCase):
def test_every_form_field_is_read_by_the_check(self):
form = (ROOT / ".github" / "ISSUE_TEMPLATE" / "submit-mod.yml").read_text(encoding="utf-8")
# top-level field labels are indented by 6; checkbox option labels start with `- label:`
labels = set(re.findall(r"^ label: (.+)$", form, re.M)) - {"Rules"}
self.assertEqual(labels, set(inspect_submission.SUBMISSION_HEADINGS), labels)

def test_the_new_fields_parse_from_an_issue_body(self):
fields = inspect_submission.parse_form(BODY)
self.assertEqual(sf.parse_screenshots(fields["screenshots"])[0], [
{"url": "https://example.com/menu.png", "alt": "The menu in the lobby"},
{"url": "https://example.com/hud.png", "alt": "The HUD during a match"}])
self.assertEqual(sf.parse_video(fields["video"]), ("https://example.com/demo.mp4", []))
self.assertEqual(sf.parse_credits(fields["credits"])[0], [
{"name": "You", "role": "Wrote the mod", "url": "https://github.com/you"},
{"name": "Dualgame", "role": "Wrote the original rad-links"}])

def test_unanswered_fields_are_empty(self):
body = (BODY.split("### Screenshots")[0]
+ "### Screenshots\n\n_No response_\n\n### Video\n\n_No response_\n\n### Credits\n\n_No response_\n")
fields = inspect_submission.parse_form(body)
self.assertEqual(sf.parse_screenshots(fields["screenshots"]), ([], []))
self.assertEqual(sf.parse_video(fields["video"]), ("", []))
self.assertEqual(sf.parse_credits(fields["credits"]), ([], []))


class Screenshots(unittest.TestCase):
def errors(self, text):
return sf.parse_screenshots(text)[1]

def test_http_refused(self):
self.assertTrue(self.errors("http://example.com/a.png | x"))

def test_not_a_link_refused(self):
self.assertTrue(self.errors("menu.png | x"))

def test_alt_required(self):
self.assertTrue(self.errors("https://example.com/a.png"))
self.assertTrue(self.errors("https://example.com/a.png | "))

def test_alt_length(self):
self.assertTrue(self.errors("https://example.com/a.png | " + "x" * 201))

def test_at_most_eight(self):
many = "\n".join(f"https://example.com/{i}.png | x" for i in range(9))
self.assertEqual(self.errors(many), ["Screenshots: at most 8"])

def test_a_bad_line_does_not_hide_the_good_ones(self):
items, errors = sf.parse_screenshots("https://example.com/a.png | ok\nbad")
self.assertEqual(len(items), 1)
self.assertEqual(len(errors), 1)


class Video(unittest.TestCase):
def test_one_https_link(self):
self.assertEqual(sf.parse_video("https://youtu.be/x")[1], [])

def test_http_refused(self):
self.assertTrue(sf.parse_video("http://youtu.be/x")[1])

def test_two_links_refused(self):
self.assertEqual(sf.parse_video("https://a.example/x\nhttps://b.example/y")[1], ["Video: one link only"])


class Credits(unittest.TestCase):
def errors(self, text):
return sf.parse_credits(text)[1]

def test_role_required(self):
self.assertTrue(self.errors("Dualgame"))
self.assertTrue(self.errors("Dualgame | "))

def test_name_required(self):
self.assertTrue(self.errors(" | wrote it"))

def test_link_must_be_https(self):
self.assertTrue(self.errors("Dualgame | wrote it | http://github.com/Dualgame"))

def test_too_many_parts(self):
self.assertTrue(self.errors("a | b | https://example.com | d"))

def test_at_most_twenty(self):
many = "\n".join(f"P{i} | did a thing" for i in range(21))
self.assertEqual(self.errors(many), ["Credits: at most 20"])


if __name__ == "__main__":
unittest.main()
Loading