Issues: per-project numbers, statuses (open, accepted, rejected, withdrawn, duplicate), feature links, edits, and a timeline of comments and events. From an issue, collaborators create or update a feature with the revision tied to the issue and an option to accept it. Comments: general and line-anchored threads on features, one discussion thread per issue, edit history, tombstone deletes, resolve, and promoting a feature thread to an issue. Line threads attach to top-level Markdown blocks and go outdated only when the text changes. HTMX swaps threads in place with focus management; every action also works as a plain form post. Markdown headings are re-levelled so user text never skips heading levels on the page. The example flow from concept.md (steps 1-7) is covered end to end in tests/test_example_flow.py. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
136 lines
5.1 KiB
Python
136 lines
5.1 KiB
Python
"""The example flow from docs/concept.md, steps 1–7, driven through the views like a browser."""
|
||
|
||
from django.urls import reverse
|
||
|
||
from intent.comments.models import CommentThread
|
||
from intent.features.models import Feature
|
||
from intent.issues.models import Issue, IssueStatus
|
||
|
||
P = {"org_slug": "catbriar", "project_slug": "intent"}
|
||
|
||
|
||
def issue_url(name, number, **kwargs):
|
||
return reverse(f"issues:{name}", kwargs={**P, "number": number, **kwargs})
|
||
|
||
|
||
def feature_url(name, key, **kwargs):
|
||
return reverse(f"features:{name}", kwargs={**P, "feature_key": key, **kwargs})
|
||
|
||
|
||
def reply_url(thread):
|
||
return reverse("comments:reply", kwargs={**P, "thread_id": thread.pk})
|
||
|
||
|
||
def test_example_flow(client, open_project, outsider, editor):
|
||
# 1. A general user creates a new issue in a project.
|
||
client.force_login(outsider)
|
||
client.post(
|
||
reverse("issues:create", kwargs=P),
|
||
{"title": "Remember me on shared computers", "body": "Sessions last too long."},
|
||
)
|
||
issue = Issue.objects.get()
|
||
|
||
# 2. Users, including team members, discuss in comments on the issue.
|
||
issue_thread = issue.threads.get()
|
||
client.post(reply_url(issue_thread), {"body": "Public library computers especially."})
|
||
client.force_login(editor)
|
||
client.post(reply_url(issue_thread), {"body": "Agreed, let's define this."})
|
||
|
||
# 3. A team member accepts the issue and creates a feature from it, linked to the issue.
|
||
create = reverse("features:create", kwargs=P) + f"?issue={issue.number}"
|
||
client.post(
|
||
create,
|
||
{
|
||
"title": "Session length",
|
||
"description": "Sessions end after 12 hours.\n\nShared computers end at close.\n",
|
||
"note": "",
|
||
"accept_issue": "on",
|
||
},
|
||
)
|
||
issue.refresh_from_db()
|
||
feature = Feature.objects.get()
|
||
assert issue.status == IssueStatus.ACCEPTED
|
||
assert list(issue.features.all()) == [feature]
|
||
assert feature.revisions.get().issues.get() == issue
|
||
|
||
# 4. The team member updates the feature's status manually.
|
||
client.post(
|
||
feature_url("edit", feature.key),
|
||
{
|
||
"title": feature.title,
|
||
"description": feature.description,
|
||
"status": "withdrawn",
|
||
"note": "Paused until the login rework.",
|
||
},
|
||
)
|
||
feature.refresh_from_db()
|
||
assert (feature.status, feature.current_revision) == ("withdrawn", 2)
|
||
client.post(
|
||
feature_url("edit", feature.key),
|
||
{
|
||
"title": feature.title,
|
||
"description": feature.description,
|
||
"status": "active",
|
||
"note": "Back on.",
|
||
},
|
||
)
|
||
|
||
# 5. Later, a user starts a comment thread on lines of the feature.
|
||
client.force_login(outsider)
|
||
client.post(
|
||
feature_url("thread_create", feature.key),
|
||
{"body": "What counts as close?", "revision": 3, "line_start": 3, "line_end": 3},
|
||
)
|
||
thread = CommentThread.objects.get(feature=feature)
|
||
|
||
# 6. After some discussion, the user promotes the thread to an issue.
|
||
client.force_login(editor)
|
||
client.post(reply_url(thread), {"body": "Browser close, I think."})
|
||
client.force_login(outsider)
|
||
promote = reverse("comments:promote", kwargs={**P, "thread_id": thread.pk})
|
||
client.post(promote, {"title": "Define 'close'", "body": "From the thread."})
|
||
follow_up = Issue.objects.get(number=2)
|
||
thread.refresh_from_db()
|
||
assert thread.promoted_to == follow_up and thread.is_resolved
|
||
assert list(follow_up.features.all()) == [feature]
|
||
|
||
# 7. More discussion, then a team member accepts the issue and updates the feature.
|
||
client.post(reply_url(follow_up.threads.get()), {"body": "Tab close too?"})
|
||
client.force_login(editor)
|
||
client.post(
|
||
feature_url("edit", feature.key) + f"?issue={follow_up.number}",
|
||
{
|
||
"title": feature.title,
|
||
"description": (
|
||
"Sessions end after 12 hours.\n\nShared computers end when the browser closes.\n"
|
||
),
|
||
"status": "active",
|
||
"note": "Define close as browser close.",
|
||
"accept_issue": "on",
|
||
},
|
||
)
|
||
follow_up.refresh_from_db()
|
||
feature.refresh_from_db()
|
||
assert follow_up.status == IssueStatus.ACCEPTED
|
||
assert feature.current_revision == 4
|
||
assert feature.revisions.get(revision=4).issues.get() == follow_up
|
||
|
||
# The line thread is now outdated, and the feature page shows both issues.
|
||
page = client.get(feature_url("detail", feature.key)).content.decode()
|
||
assert "Comments on earlier revisions" in page
|
||
assert "#1 Remember me on shared computers" in page and "#2 Define" in page
|
||
|
||
# 7, alternative: a third issue is rejected but stays linked, so the proposal is visible.
|
||
client.force_login(outsider)
|
||
client.post(
|
||
reverse("issues:create", kwargs=P),
|
||
{"title": "Never expire sessions", "features": [feature.pk]},
|
||
)
|
||
client.force_login(editor)
|
||
client.post(issue_url("status", 3), {"status": "rejected"})
|
||
rejected = Issue.objects.get(number=3)
|
||
assert rejected.status == IssueStatus.REJECTED
|
||
assert (
|
||
"#3 Never expire sessions"
|
||
in client.get(feature_url("detail", feature.key)).content.decode()
|
||
)
|