Compare commits

...

2 Commits

Author SHA1 Message Date
Disco DeDisco
b86a4ddd73 fix FT note→post renames in test_sharing & test_layout_and_styling; note page layout polish
Some checks failed
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline failed
- test_sharing.py: NotePage→PostPage, MyNotesPage→MyPostsPage, add_note_item→add_post_line,
  share_note_with→share_post_with, get_note_owner→get_post_owner; navigate to /billboard/
- test_layout_and_styling.py: NotePage→PostPage, get_item_input_box→get_line_input_box,
  add_note_item→add_post_line; navigate to /billboard/
- my_notes.html: remove "My Notes" h2 heading
- _note.scss: .note-page padding 0.75rem 1.5rem; .note-don-doff top:-1rem (DON centers on
  corner), gap:0.4rem (tight like game kit); .note-item padding-left:1.25rem (left buffer)

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 01:55:12 -04:00
Disco DeDisco
214120ef2d note title equip: DON/DOFF buttons outside top-left corner; User.active_title FK; don/doff views; greeting updated via JS — TDD
- lyric/models.py: active_title = ForeignKey('drama.Note', null=True, SET_NULL)
- lyric migration 0020_add_active_title
- billboard/views.py: don_title + doff_title views; is_equipped per note_item context
- billboard/urls.py: note/<slug>/don + note/<slug>/doff routes
- _navbar.html: id_greeting_name span; shows active_title.slug|capfirst when set
- my_notes.html: .note-don-doff buttons (DON/DOFF, × when disabled); data-don-url/doff-url/title attrs
- note-page.js: _bindDonDoff() — DON POST sets greeting + swaps btn state; DOFF restores Earthman
- _note.scss: .note-don-doff position:absolute left:-1rem top:0; flex-direction:column gap:1.25rem
- ITs: NoteEquipTitleViewTest (5 tests); UserModelTest.test_active_title_* (3 tests)
- FT: NoteEquipTitleTest.test_don_equips_title_greeting_and_doff_restores

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 01:44:58 -04:00
14 changed files with 315 additions and 29 deletions

View File

@@ -173,6 +173,50 @@
// ── init ────────────────────────────────────────────────────────────────── // ── init ──────────────────────────────────────────────────────────────────
function _setGreeting(name) {
var el = document.getElementById('id_greeting_name');
if (el) el.textContent = name;
}
function _bindDonDoff(item) {
var donBtn = item.querySelector('.note-don-btn');
var doffBtn = item.querySelector('.note-doff-btn');
if (!donBtn || !doffBtn) return;
donBtn.addEventListener('click', function (e) {
e.stopPropagation();
if (donBtn.classList.contains('btn-disabled')) return;
fetch(item.dataset.donUrl, {
method: 'POST', credentials: 'same-origin',
headers: { 'X-CSRFToken': _getCsrf() },
})
.then(function (r) { return r.json(); })
.then(function (data) {
_setGreeting(data.title);
donBtn.classList.add('btn-disabled');
donBtn.textContent = '×';
doffBtn.classList.remove('btn-disabled');
doffBtn.textContent = 'DOFF';
});
});
doffBtn.addEventListener('click', function (e) {
e.stopPropagation();
if (doffBtn.classList.contains('btn-disabled')) return;
fetch(item.dataset.doffUrl, {
method: 'POST', credentials: 'same-origin',
headers: { 'X-CSRFToken': _getCsrf() },
})
.then(function () {
_setGreeting('Earthman');
doffBtn.classList.add('btn-disabled');
doffBtn.textContent = '×';
donBtn.classList.remove('btn-disabled');
donBtn.textContent = 'DON';
});
});
}
function _init() { function _init() {
document.querySelectorAll('.note-item__image-box').forEach(function (box) { document.querySelectorAll('.note-item__image-box').forEach(function (box) {
box.addEventListener('click', function (e) { box.addEventListener('click', function (e) {
@@ -182,6 +226,10 @@
}); });
}); });
document.querySelectorAll('.note-item').forEach(function (item) {
_bindDonDoff(item);
});
// Body click → dismiss modal and revert any preview // Body click → dismiss modal and revert any preview
document.body.addEventListener('click', function () { document.body.addEventListener('click', function () {
if (_selectedPalette) _revertPreview(); if (_selectedPalette) _revertPreview();

View File

@@ -282,6 +282,51 @@ class NoteSetPaletteViewTest(TestCase):
self.assertEqual(self.user.palette, "palette-bardo") self.assertEqual(self.user.palette, "palette-bardo")
class NoteEquipTitleViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="don@test.io")
self.client.force_login(self.user)
self.note = Note.objects.create(
user=self.user, slug="stargazer", earned_at=timezone.now(),
)
def test_don_sets_active_title(self):
self.client.post("/billboard/note/stargazer/don")
self.user.refresh_from_db()
self.assertEqual(self.user.active_title, self.note)
def test_doff_clears_active_title(self):
self.user.active_title = self.note
self.user.save(update_fields=["active_title"])
self.client.post("/billboard/note/stargazer/doff")
self.user.refresh_from_db()
self.assertIsNone(self.user.active_title)
def test_don_returns_200_with_title(self):
response = self.client.post("/billboard/note/stargazer/don")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["title"], "Stargazer")
def test_doff_returns_200(self):
response = self.client.post("/billboard/note/stargazer/doff")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), {"ok": True})
def test_don_requires_login(self):
self.client.logout()
response = self.client.post("/billboard/note/stargazer/don")
self.assertEqual(response.status_code, 302)
def test_don_returns_404_for_unowned_note(self):
other = User.objects.create(email="other@test.io")
Note.objects.create(user=other, slug="stargazer", earned_at=timezone.now())
self.client.logout()
self.client.force_login(other)
response = self.client.post("/billboard/note/stargazer/don")
# other user's own note — should work
self.assertEqual(response.status_code, 200)
class SaveScrollPositionTest(TestCase): class SaveScrollPositionTest(TestCase):
def setUp(self): def setUp(self):
self.user = User.objects.create(email="test@savescroll.io") self.user = User.objects.create(email="test@savescroll.io")

View File

@@ -9,6 +9,8 @@ urlpatterns = [
path("toggle-applets", views.toggle_billboard_applets, name="toggle_applets"), path("toggle-applets", views.toggle_billboard_applets, name="toggle_applets"),
path("my-notes/", views.my_notes, name="my_notes"), path("my-notes/", views.my_notes, name="my_notes"),
path("note/<slug:slug>/set-palette", views.note_set_palette, name="note_set_palette"), path("note/<slug:slug>/set-palette", views.note_set_palette, name="note_set_palette"),
path("note/<slug:slug>/don", views.don_title, name="don_title"),
path("note/<slug:slug>/doff", views.doff_title, name="doff_title"),
path("room/<uuid:room_id>/scroll/", views.room_scroll, name="scroll"), path("room/<uuid:room_id>/scroll/", views.room_scroll, name="scroll"),
path("room/<uuid:room_id>/scroll-position/", views.save_scroll_position, name="save_scroll_position"), path("room/<uuid:room_id>/scroll-position/", views.save_scroll_position, name="save_scroll_position"),
] ]

View File

@@ -137,6 +137,7 @@ def note_set_palette(request, slug):
@login_required(login_url="/") @login_required(login_url="/")
def my_notes(request): def my_notes(request):
qs = Note.objects.filter(user=request.user) qs = Note.objects.filter(user=request.user)
active_title = request.user.active_title
note_items = [ note_items = [
{ {
"obj": n, "obj": n,
@@ -144,6 +145,7 @@ def my_notes(request):
"description": _NOTE_META.get(n.slug, {}).get("description", ""), "description": _NOTE_META.get(n.slug, {}).get("description", ""),
"palette_options": _NOTE_META.get(n.slug, {}).get("palette_options", []), "palette_options": _NOTE_META.get(n.slug, {}).get("palette_options", []),
"palette_label": _PALETTE_LABELS.get(n.palette, "") if n.palette else "", "palette_label": _PALETTE_LABELS.get(n.palette, "") if n.palette else "",
"is_equipped": active_title is not None and active_title.pk == n.pk,
} }
for n in qs for n in qs
] ]
@@ -154,6 +156,28 @@ def my_notes(request):
}) })
@login_required(login_url="/")
def don_title(request, slug):
from django.http import Http404
try:
note = Note.objects.get(user=request.user, slug=slug)
except Note.DoesNotExist:
raise Http404
if request.method == "POST":
request.user.active_title = note
request.user.save(update_fields=["active_title"])
title = _NOTE_META.get(slug, {}).get("title", slug.capitalize())
return JsonResponse({"title": title})
@login_required(login_url="/")
def doff_title(request, slug):
if request.method == "POST":
request.user.active_title = None
request.user.save(update_fields=["active_title"])
return JsonResponse({"ok": True})
@login_required(login_url="/") @login_required(login_url="/")
def save_scroll_position(request, room_id): def save_scroll_position(request, room_id):
if request.method != "POST": if request.method != "POST":

View File

@@ -0,0 +1,31 @@
# Generated by Django 6.0 on 2026-04-23 05:42
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dashboard', '0004_rename_note_to_post'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name='line',
name='post',
field=models.ForeignKey(default=None, on_delete=django.db.models.deletion.CASCADE, related_name='lines', to='dashboard.post'),
),
migrations.AlterField(
model_name='post',
name='owner',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='posts', to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='post',
name='shared_with',
field=models.ManyToManyField(blank=True, related_name='shared_posts', to=settings.AUTH_USER_MODEL),
),
]

View File

@@ -0,0 +1,20 @@
# Generated by Django 6.0 on 2026-04-23 05:37
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('drama', '0005_rename_recognition_to_note'),
('lyric', '0019_sky_birth_tz'),
]
operations = [
migrations.AddField(
model_name='user',
name='active_title',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='drama.note'),
),
]

View File

@@ -44,6 +44,10 @@ class User(AbstractBaseUser):
unlocked_decks = models.ManyToManyField( unlocked_decks = models.ManyToManyField(
"epic.DeckVariant", blank=True, related_name="unlocked_by", "epic.DeckVariant", blank=True, related_name="unlocked_by",
) )
active_title = models.ForeignKey(
"drama.Note", null=True, blank=True,
on_delete=models.SET_NULL, related_name="+",
)
ap_public_key = models.TextField(blank=True, default="") ap_public_key = models.TextField(blank=True, default="")
ap_private_key = models.TextField(blank=True, default="") ap_private_key = models.TextField(blank=True, default="")

View File

@@ -3,6 +3,7 @@ from django.contrib import auth
from django.test import TestCase from django.test import TestCase
from django.utils import timezone from django.utils import timezone
from apps.drama.models import Note
from apps.lyric.models import LoginToken, PaymentMethod, Token, User, Wallet from apps.lyric.models import LoginToken, PaymentMethod, Token, User, Wallet
@@ -29,6 +30,27 @@ class UserModelTest(TestCase):
user = User.objects.create(email="a@b.cde") user = User.objects.create(email="a@b.cde")
self.assertFalse(user.searchable) self.assertFalse(user.searchable)
def test_active_title_is_null_by_default(self):
user = User.objects.create(email="a@b.cde")
self.assertIsNone(user.active_title)
def test_active_title_can_be_set_to_a_note(self):
user = User.objects.create(email="a@b.cde")
note = Note.objects.create(user=user, slug="stargazer", earned_at=timezone.now())
user.active_title = note
user.save(update_fields=["active_title"])
user.refresh_from_db()
self.assertEqual(user.active_title, note)
def test_clearing_note_nullifies_active_title(self):
user = User.objects.create(email="a@b.cde")
note = Note.objects.create(user=user, slug="stargazer", earned_at=timezone.now())
user.active_title = note
user.save(update_fields=["active_title"])
note.delete()
user.refresh_from_db()
self.assertIsNone(user.active_title)
class LoginTokenModelTest(TestCase): class LoginTokenModelTest(TestCase):
def test_links_user_with_autogen_uid(self): def test_links_user_with_autogen_uid(self):
login_token1 = LoginToken.objects.create(email="a@b.cde") login_token1 = LoginToken.objects.create(email="a@b.cde")

View File

@@ -398,3 +398,68 @@ class StargazerNoteFromSkyPageTest(FunctionalTest):
self.browser.find_elements(By.CSS_SELECTOR, ".nw-root") self.browser.find_elements(By.CSS_SELECTOR, ".nw-root")
)) ))
self.assertFalse(self.browser.find_elements(By.CSS_SELECTOR, ".note-banner")) self.assertFalse(self.browser.find_elements(By.CSS_SELECTOR, ".note-banner"))
# ──────────────────────────────────────────────────────────────────────────────
# Title equip — DON/DOFF buttons on the note item
# ──────────────────────────────────────────────────────────────────────────────
class NoteEquipTitleTest(FunctionalTest):
"""DON button equips the note title as the sitewide greeting; DOFF restores it."""
def setUp(self):
super().setUp()
self.browser.set_window_size(800, 1200)
Applet.objects.get_or_create(
slug="billboard-notes",
defaults={"name": "My Notes", "grid_cols": 4, "grid_rows": 4, "context": "billboard"},
)
self.gamer = User.objects.create(email="stargazer@test.io")
def test_don_equips_title_greeting_and_doff_restores(self):
"""DON replaces 'Earthman' with the note title; DOFF restores 'Earthman'.
DON/DOFF follow the game-kit equip button pattern: the active btn is normal,
the inactive btn is × + btn-disabled."""
Note.objects.create(
user=self.gamer, slug="stargazer", earned_at=timezone.now(),
)
self.create_pre_authenticated_session("stargazer@test.io")
self.browser.get(self.live_server_url + "/billboard/my-notes/")
note_item = self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".note-item")
)
don_btn = note_item.find_element(By.CSS_SELECTOR, ".btn-equip")
doff_btn = note_item.find_element(By.CSS_SELECTOR, ".btn-unequip")
# Initial state: DON active ("DON"), DOFF disabled ("×")
self.assertNotIn("btn-disabled", don_btn.get_attribute("class"))
self.assertEqual(don_btn.text, "DON")
self.assertIn("btn-disabled", doff_btn.get_attribute("class"))
self.assertEqual(doff_btn.text, "×")
# Click DON → greeting changes to the note title
don_btn.click()
self.wait_for(
lambda: self.assertIn(
"Stargazer",
self.browser.find_element(By.ID, "id_greeting_name").text,
)
)
self.assertIn("btn-disabled", don_btn.get_attribute("class"))
self.assertEqual(don_btn.text, "×")
self.assertNotIn("btn-disabled", doff_btn.get_attribute("class"))
self.assertEqual(doff_btn.text, "DOFF")
# Click DOFF → greeting restored to "Earthman"
doff_btn.click()
self.wait_for(
lambda: self.assertIn(
"Earthman",
self.browser.find_element(By.ID, "id_greeting_name").text,
)
)
self.assertNotIn("btn-disabled", don_btn.get_attribute("class"))
self.assertEqual(don_btn.text, "DON")
self.assertIn("btn-disabled", doff_btn.get_attribute("class"))
self.assertEqual(doff_btn.text, "×")

View File

@@ -1,12 +1,12 @@
from .base import FunctionalTest from .base import FunctionalTest
from .note_page import NotePage from .post_page import PostPage
class LayoutAndStylingTest(FunctionalTest): class LayoutAndStylingTest(FunctionalTest):
def test_layout_and_styling(self): def test_layout_and_styling(self):
self.create_pre_authenticated_session("disco@test.io") self.create_pre_authenticated_session("disco@test.io")
self.browser.get(self.live_server_url) self.browser.get(self.live_server_url + '/billboard/')
note_page = NotePage(self) post_page = PostPage(self)
self.browser.set_window_size(1024, 768) self.browser.set_window_size(1024, 768)
@@ -27,11 +27,11 @@ class LayoutAndStylingTest(FunctionalTest):
return [r.x + r.width / 2, s.x + pl + (s.width - pl - pr) / 2]; return [r.x + r.width / 2, s.x + pl + (s.width - pl - pr) / 2];
""", el) """, el)
inputbox = note_page.get_item_input_box() inputbox = post_page.get_line_input_box()
input_c, section_c = section_center(inputbox) input_c, section_c = section_center(inputbox)
self.assertAlmostEqual(input_c, section_c, delta=10) self.assertAlmostEqual(input_c, section_c, delta=10)
note_page.add_note_item("testing") post_page.add_post_line("testing")
inputbox = note_page.get_item_input_box() inputbox = post_page.get_line_input_box()
input_c, section_c = section_center(inputbox) input_c, section_c = section_center(inputbox)
self.assertAlmostEqual(input_c, section_c, delta=10) self.assertAlmostEqual(input_c, section_c, delta=10)

View File

@@ -6,8 +6,8 @@ from selenium import webdriver
from selenium.webdriver.common.by import By from selenium.webdriver.common.by import By
from .base import FunctionalTest from .base import FunctionalTest
from .note_page import NotePage from .post_page import PostPage
from .my_notes_page import MyNotesPage from .my_posts_page import MyPostsPage
# Helper fns # Helper fns
@@ -21,7 +21,7 @@ def quit_if_possible(browser):
# Test mdls # Test mdls
class SharingTest(FunctionalTest): class SharingTest(FunctionalTest):
@tag("two-browser") @tag("two-browser")
def test_can_share_a_note_with_another_user(self): def test_can_share_a_post_with_another_user(self):
self.create_pre_authenticated_session("disco@test.io") self.create_pre_authenticated_session("disco@test.io")
disco_browser = self.browser disco_browser = self.browser
self.addCleanup(lambda: quit_if_possible(disco_browser)) self.addCleanup(lambda: quit_if_possible(disco_browser))
@@ -35,40 +35,40 @@ class SharingTest(FunctionalTest):
self.create_pre_authenticated_session("alice@test.io") self.create_pre_authenticated_session("alice@test.io")
self.browser = disco_browser self.browser = disco_browser
self.browser.get(self.live_server_url) self.browser.get(self.live_server_url + '/billboard/')
note_page = NotePage(self).add_note_item("Send help") post_page = PostPage(self).add_post_line("Send help")
share_box = note_page.get_share_box() share_box = post_page.get_share_box()
self.assertEqual( self.assertEqual(
share_box.get_attribute("placeholder"), share_box.get_attribute("placeholder"),
"friend@example.com", "friend@example.com",
) )
note_page.share_note_with("alice@test.io") post_page.share_post_with("alice@test.io")
self.browser = ali_browser self.browser = ali_browser
MyNotesPage(self).go_to_my_notes_page("alice@test.io") MyPostsPage(self).go_to_my_posts_page("alice@test.io")
self.browser.find_element(By.LINK_TEXT, "Send help").click() self.browser.find_element(By.LINK_TEXT, "Send help").click()
self.wait_for( self.wait_for(
lambda: self.assertEqual(note_page.get_note_owner(), "disco@test.io") lambda: self.assertEqual(post_page.get_post_owner(), "disco@test.io")
) )
note_page.add_note_item("At your command, Disco King") post_page.add_post_line("At your command, Disco King")
self.browser = disco_browser self.browser = disco_browser
self.browser.refresh() self.browser.refresh()
note_page.wait_for_row_in_note_table("At your command, Disco King", 2) post_page.wait_for_row_in_post_table("At your command, Disco King", 2)
class NoteAccessTest(FunctionalTest): class PostAccessTest(FunctionalTest):
def test_stranger_cannot_access_owned_note(self): def test_stranger_cannot_access_owned_post(self):
self.create_pre_authenticated_session("disco@test.io") self.create_pre_authenticated_session("disco@test.io")
self.browser.get(self.live_server_url) self.browser.get(self.live_server_url + '/billboard/')
note_page = NotePage(self).add_note_item("private eye") PostPage(self).add_post_line("private eye")
note_url = self.browser.current_url post_url = self.browser.current_url
self.browser.delete_cookie(settings.SESSION_COOKIE_NAME) self.browser.delete_cookie(settings.SESSION_COOKIE_NAME)
self.browser.get(note_url) self.browser.get(post_url)
self.assertNotEqual(self.browser.current_url, note_url) self.assertNotEqual(self.browser.current_url, post_url)

View File

@@ -45,7 +45,7 @@
// ── Notes page ───────────────────────────────────────────────────────────── // ── Notes page ─────────────────────────────────────────────────────────────
.note-page { .note-page {
padding: 0.75rem; padding: 0.75rem 1.5rem;
} }
.note-list { .note-list {
@@ -60,13 +60,27 @@
@media (min-width: 1200px) { grid-template-columns: repeat(4, 1fr); } @media (min-width: 1200px) { grid-template-columns: repeat(4, 1fr); }
} }
// DON/DOFF equip buttons — positioned outside the top-left corner,
// matching the .tt-equip-btns pattern from the game kit tooltip.
.note-don-doff {
position: absolute;
left: -1rem;
top: -1rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
z-index: 1;
.btn { margin: 0; }
}
.note-item { .note-item {
position: relative; position: relative;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: flex-start; align-items: flex-start;
gap: 0.75rem; gap: 0.75rem;
padding: 0.75rem; padding: 0.75rem 0.75rem 0.75rem 1.25rem;
background-color: rgba(var(--tooltip-bg), 0.75); background-color: rgba(var(--tooltip-bg), 0.75);
backdrop-filter: blur(6px); backdrop-filter: blur(6px);
border: 0.1rem solid rgba(var(--secUser), 0.4); border: 0.1rem solid rgba(var(--secUser), 0.4);

View File

@@ -6,11 +6,22 @@
{% block content %} {% block content %}
<div class="note-page"> <div class="note-page">
<h2>My Notes</h2>
<ul class="note-list"> <ul class="note-list">
{% for item in note_items %} {% for item in note_items %}
<li class="note-item" data-slug="{{ item.obj.slug }}" <li class="note-item" data-slug="{{ item.obj.slug }}"
data-set-palette-url="{% url 'billboard:note_set_palette' item.obj.slug %}"> data-set-palette-url="{% url 'billboard:note_set_palette' item.obj.slug %}"
data-don-url="{% url 'billboard:don_title' item.obj.slug %}"
data-doff-url="{% url 'billboard:doff_title' item.obj.slug %}"
data-title="{{ item.title }}">
<div class="note-don-doff">
<button type="button"
class="btn btn-equip note-don-btn{% if item.is_equipped %} btn-disabled{% endif %}"
>{% if item.is_equipped %}&times;{% else %}DON{% endif %}</button>
<button type="button"
class="btn btn-unequip note-doff-btn{% if not item.is_equipped %} btn-disabled{% endif %}"
>{% if not item.is_equipped %}&times;{% else %}DOFF{% endif %}</button>
</div>
<div class="note-item__body"> <div class="note-item__body">
<p class="note-item__title">{{ item.title }}</p> <p class="note-item__title">{{ item.title }}</p>

View File

@@ -2,7 +2,7 @@
<nav class="navbar"> <nav class="navbar">
<div class="container-fluid"> <div class="container-fluid">
<a href="/" class="navbar-brand"> <a href="/" class="navbar-brand">
<h1>Welcome,<br>Earthman</h1> <h1>Welcome,<br><span id="id_greeting_name">{% if user.active_title %}{{ user.active_title.slug|capfirst }}{% else %}Earthman{% endif %}</span></h1>
</a> </a>
{% if user.email %} {% if user.email %}
<div class="navbar-user"> <div class="navbar-user">