Compare commits
11 Commits
fd94a72435
...
d79380faa5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d79380faa5 | ||
|
|
e78bbb873b | ||
|
|
763d555f0c | ||
|
|
6ad736413b | ||
|
|
1c2b8f96ab | ||
|
|
eaff2a1edb | ||
|
|
e512e94056 | ||
|
|
fa68c74b51 | ||
|
|
94a864b05b | ||
|
|
42be0c63dc | ||
|
|
e6e2bd10c5 |
@@ -5,6 +5,8 @@
|
|||||||
var _activeItem = null;
|
var _activeItem = null;
|
||||||
var _originalPalette = null;
|
var _originalPalette = null;
|
||||||
var _dismissTimer = null;
|
var _dismissTimer = null;
|
||||||
|
var _lockedItem = null; // click-locked note (glow + DON/DOFF pinned)
|
||||||
|
var _donnedItem = null; // currently DONned note (persistent glow)
|
||||||
|
|
||||||
// ── helpers ──────────────────────────────────────────────────────────────
|
// ── helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -47,6 +49,23 @@
|
|||||||
if (el) el.style.display = 'none';
|
if (el) el.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── lock helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function _clearLock() {
|
||||||
|
if (_lockedItem) {
|
||||||
|
_lockedItem.classList.remove('note-item--locked');
|
||||||
|
_lockedItem = null;
|
||||||
|
}
|
||||||
|
document.body.classList.remove('notes-locked');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _setGreeting(greeting, name) {
|
||||||
|
var prefix = document.getElementById('id_greeting_prefix');
|
||||||
|
var nameEl = document.getElementById('id_greeting_name');
|
||||||
|
if (prefix) prefix.innerHTML = greeting;
|
||||||
|
if (nameEl) nameEl.textContent = name;
|
||||||
|
}
|
||||||
|
|
||||||
// ── modal lifecycle ───────────────────────────────────────────────────────
|
// ── modal lifecycle ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
function _openModal() {
|
function _openModal() {
|
||||||
@@ -88,47 +107,31 @@
|
|||||||
_originalPalette = null;
|
_originalPalette = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wire event listeners onto the freshly-cloned modal DOM.
|
|
||||||
function _wireModal() {
|
function _wireModal() {
|
||||||
var modal = _activeModal();
|
var modal = _activeModal();
|
||||||
if (!modal) return;
|
if (!modal) return;
|
||||||
|
|
||||||
// Swatch body click → preview palette sitewide + show confirm
|
|
||||||
modal.querySelectorAll('.note-swatch-body').forEach(function (body) {
|
modal.querySelectorAll('.note-swatch-body').forEach(function (body) {
|
||||||
body.addEventListener('click', function (e) {
|
body.addEventListener('click', function (e) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (_selectedPalette) _revertPreview();
|
if (_selectedPalette) _revertPreview();
|
||||||
|
|
||||||
_selectedPalette = _paletteClass(body.parentElement);
|
_selectedPalette = _paletteClass(body.parentElement);
|
||||||
_originalPalette = _currentBodyPalette();
|
_originalPalette = _currentBodyPalette();
|
||||||
|
|
||||||
body.classList.add('previewing');
|
body.classList.add('previewing');
|
||||||
_swapBodyPalette(_selectedPalette);
|
_swapBodyPalette(_selectedPalette);
|
||||||
_showConfirm(modal);
|
_showConfirm(modal);
|
||||||
|
_dismissTimer = setTimeout(function () { _revertPreview(); }, 10000);
|
||||||
_dismissTimer = setTimeout(function () {
|
|
||||||
_revertPreview();
|
|
||||||
}, 10000);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Confirm OK → commit palette sitewide
|
|
||||||
modal.querySelectorAll('.note-palette-confirm .btn.btn-confirm').forEach(function (btn) {
|
modal.querySelectorAll('.note-palette-confirm .btn.btn-confirm').forEach(function (btn) {
|
||||||
btn.addEventListener('click', function (e) {
|
btn.addEventListener('click', function (e) { e.stopPropagation(); _doSetPalette(); });
|
||||||
e.stopPropagation();
|
|
||||||
_doSetPalette();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Confirm NVM → revert preview only; main swatch modal stays open
|
|
||||||
modal.querySelectorAll('.note-palette-confirm .btn.btn-cancel').forEach(function (btn) {
|
modal.querySelectorAll('.note-palette-confirm .btn.btn-cancel').forEach(function (btn) {
|
||||||
btn.addEventListener('click', function (e) {
|
btn.addEventListener('click', function (e) { e.stopPropagation(); _revertPreview(); });
|
||||||
e.stopPropagation();
|
|
||||||
_revertPreview();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Stop modal clicks from reaching the body dismiss handler.
|
|
||||||
modal.addEventListener('click', function (e) { e.stopPropagation(); });
|
modal.addEventListener('click', function (e) { e.stopPropagation(); });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,18 +141,13 @@
|
|||||||
var url = _activeItem.dataset.setPaletteUrl;
|
var url = _activeItem.dataset.setPaletteUrl;
|
||||||
var palette = _selectedPalette;
|
var palette = _selectedPalette;
|
||||||
var item = _activeItem;
|
var item = _activeItem;
|
||||||
// Read label from swatch row while modal is still in DOM
|
|
||||||
var swatchRow = _activeModal() && _activeModal().querySelector('.' + palette + '[data-palette-label]');
|
var swatchRow = _activeModal() && _activeModal().querySelector('.' + palette + '[data-palette-label]');
|
||||||
var paletteLabel = swatchRow
|
var paletteLabel = swatchRow
|
||||||
? swatchRow.dataset.paletteLabel
|
? swatchRow.dataset.paletteLabel
|
||||||
: palette.slice(8).replace(/^\w/, function (c) { return c.toUpperCase(); });
|
: palette.slice(8).replace(/^\w/, function (c) { return c.toUpperCase(); });
|
||||||
fetch(url, {
|
fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST', credentials: 'same-origin',
|
||||||
credentials: 'same-origin',
|
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': _getCsrf() },
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-CSRFToken': _getCsrf(),
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ palette: palette }),
|
body: JSON.stringify({ palette: palette }),
|
||||||
})
|
})
|
||||||
.then(function (r) { return r.json(); })
|
.then(function (r) { return r.json(); })
|
||||||
@@ -171,12 +169,7 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── init ──────────────────────────────────────────────────────────────────
|
// ── DON/DOFF ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function _setGreeting(name) {
|
|
||||||
var el = document.getElementById('id_greeting_name');
|
|
||||||
if (el) el.textContent = name;
|
|
||||||
}
|
|
||||||
|
|
||||||
function _bindDonDoff(item) {
|
function _bindDonDoff(item) {
|
||||||
var donBtn = item.querySelector('.note-don-btn');
|
var donBtn = item.querySelector('.note-don-btn');
|
||||||
@@ -192,11 +185,21 @@
|
|||||||
})
|
})
|
||||||
.then(function (r) { return r.json(); })
|
.then(function (r) { return r.json(); })
|
||||||
.then(function (data) {
|
.then(function (data) {
|
||||||
_setGreeting(data.title);
|
// Auto-DOFF any previously DONned note (UI only — backend replaces active_title)
|
||||||
donBtn.classList.add('btn-disabled');
|
if (_donnedItem && _donnedItem !== item) {
|
||||||
donBtn.textContent = '×';
|
_donnedItem.classList.remove('note-item--donned');
|
||||||
doffBtn.classList.remove('btn-disabled');
|
var prevDon = _donnedItem.querySelector('.note-don-btn');
|
||||||
doffBtn.textContent = 'DOFF';
|
var prevDoff = _donnedItem.querySelector('.note-doff-btn');
|
||||||
|
if (prevDon) { prevDon.classList.remove('btn-disabled'); prevDon.textContent = 'DON'; }
|
||||||
|
if (prevDoff) { prevDoff.classList.add('btn-disabled'); prevDoff.textContent = '×'; }
|
||||||
|
}
|
||||||
|
_donnedItem = item;
|
||||||
|
item.classList.add('note-item--donned');
|
||||||
|
// Clear lock so hover is restored for other notes
|
||||||
|
_clearLock();
|
||||||
|
donBtn.classList.add('btn-disabled'); donBtn.textContent = '×';
|
||||||
|
doffBtn.classList.remove('btn-disabled'); doffBtn.textContent = 'DOFF';
|
||||||
|
_setGreeting(data.greeting || 'Welcome,', data.title || 'Earthman');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -207,33 +210,60 @@
|
|||||||
method: 'POST', credentials: 'same-origin',
|
method: 'POST', credentials: 'same-origin',
|
||||||
headers: { 'X-CSRFToken': _getCsrf() },
|
headers: { 'X-CSRFToken': _getCsrf() },
|
||||||
})
|
})
|
||||||
.then(function () {
|
.then(function (r) { return r.json(); })
|
||||||
_setGreeting('Earthman');
|
.then(function (data) {
|
||||||
doffBtn.classList.add('btn-disabled');
|
_donnedItem = null;
|
||||||
doffBtn.textContent = '×';
|
item.classList.remove('note-item--donned');
|
||||||
donBtn.classList.remove('btn-disabled');
|
_clearLock();
|
||||||
donBtn.textContent = 'DON';
|
doffBtn.classList.add('btn-disabled'); doffBtn.textContent = '×';
|
||||||
|
donBtn.classList.remove('btn-disabled'); donBtn.textContent = 'DON';
|
||||||
|
_setGreeting(data.greeting || 'Welcome,', data.title || 'Earthman');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── init ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function _init() {
|
function _init() {
|
||||||
document.querySelectorAll('.note-item__image-box').forEach(function (box) {
|
document.querySelectorAll('.note-item').forEach(function (item) {
|
||||||
box.addEventListener('click', function (e) {
|
// Detect already-DONned note on load (DON btn is disabled = currently equipped)
|
||||||
|
var don = item.querySelector('.note-don-btn');
|
||||||
|
if (don && don.classList.contains('btn-disabled')) {
|
||||||
|
item.classList.add('note-item--donned');
|
||||||
|
_donnedItem = item;
|
||||||
|
}
|
||||||
|
|
||||||
|
_bindDonDoff(item);
|
||||||
|
|
||||||
|
// Image box click → palette modal (for notes that have one)
|
||||||
|
var box = item.querySelector('.note-item__image-box:not(.note-item__image-box--label)');
|
||||||
|
if (box) {
|
||||||
|
box.addEventListener('click', function (e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
_activeItem = item;
|
||||||
|
_openModal();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note click → toggle lock
|
||||||
|
item.addEventListener('click', function (e) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
_activeItem = box.closest('.note-item');
|
if (_lockedItem === item) {
|
||||||
_openModal();
|
_clearLock();
|
||||||
|
} else {
|
||||||
|
_clearLock();
|
||||||
|
_lockedItem = item;
|
||||||
|
item.classList.add('note-item--locked');
|
||||||
|
document.body.classList.add('notes-locked');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
document.querySelectorAll('.note-item').forEach(function (item) {
|
// Body click → dismiss modal and clear lock
|
||||||
_bindDonDoff(item);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Body click → dismiss modal and revert any preview
|
|
||||||
document.body.addEventListener('click', function () {
|
document.body.addEventListener('click', function () {
|
||||||
if (_selectedPalette) _revertPreview();
|
if (_selectedPalette) _revertPreview();
|
||||||
_closeModal();
|
_closeModal();
|
||||||
|
_clearLock();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,4 +272,20 @@
|
|||||||
} else {
|
} else {
|
||||||
_init();
|
_init();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Expose test API
|
||||||
|
window.NotePage = {
|
||||||
|
_init: _init,
|
||||||
|
_testReset: function () {
|
||||||
|
_selectedPalette = null;
|
||||||
|
_activeItem = null;
|
||||||
|
_originalPalette = null;
|
||||||
|
_dismissTimer = null;
|
||||||
|
_lockedItem = null;
|
||||||
|
_donnedItem = null;
|
||||||
|
document.body.classList.remove('notes-locked');
|
||||||
|
},
|
||||||
|
get _donnedItem() { return _donnedItem; },
|
||||||
|
set _donnedItem(v) { _donnedItem = v; },
|
||||||
|
};
|
||||||
}());
|
}());
|
||||||
|
|||||||
@@ -310,7 +310,10 @@ class NoteEquipTitleViewTest(TestCase):
|
|||||||
def test_doff_returns_200(self):
|
def test_doff_returns_200(self):
|
||||||
response = self.client.post("/billboard/note/stargazer/doff")
|
response = self.client.post("/billboard/note/stargazer/doff")
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertEqual(response.json(), {"ok": True})
|
data = response.json()
|
||||||
|
self.assertTrue(data["ok"])
|
||||||
|
self.assertEqual(data["greeting"], "Welcome,")
|
||||||
|
self.assertEqual(data["title"], "Earthman")
|
||||||
|
|
||||||
def test_don_requires_login(self):
|
def test_don_requires_login(self):
|
||||||
self.client.logout()
|
self.client.logout()
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.utils.html import mark_safe
|
||||||
from django.db.models import Max, Q
|
from django.db.models import Max, Q
|
||||||
from django.http import JsonResponse
|
from django.http import JsonResponse
|
||||||
from django.shortcuts import redirect, render
|
from django.shortcuts import redirect, render
|
||||||
@@ -100,16 +101,31 @@ _NOTE_META = {
|
|||||||
"title": "Stargazer",
|
"title": "Stargazer",
|
||||||
"description": "You saved your first personal sky chart.",
|
"description": "You saved your first personal sky chart.",
|
||||||
"palette_options": _palette_opts(["palette-bardo", "palette-sheol"]),
|
"palette_options": _palette_opts(["palette-bardo", "palette-sheol"]),
|
||||||
|
"swatch_label": None,
|
||||||
},
|
},
|
||||||
"schizo": {
|
"schizo": {
|
||||||
"title": "Schizo",
|
"title": "Schizo",
|
||||||
"description": "The socius recognizes the line of flight.",
|
"description": "The socius recognizes the line of flight.",
|
||||||
"palette_options": [],
|
"palette_options": [],
|
||||||
|
"swatch_label": None,
|
||||||
},
|
},
|
||||||
"nomad": {
|
"nomad": {
|
||||||
"title": "Nomad",
|
"title": "Nomad",
|
||||||
"description": "The socius recognizes the smooth space.",
|
"description": "The socius recognizes the smooth space.",
|
||||||
"palette_options": [],
|
"palette_options": [],
|
||||||
|
"swatch_label": None,
|
||||||
|
},
|
||||||
|
"super-schizo": {
|
||||||
|
"title": "Super-Schizo",
|
||||||
|
"description": mark_safe('Admin access granted to <span class="card-ref">I. The Schizo</span> as Significator'),
|
||||||
|
"palette_options": [],
|
||||||
|
"swatch_label": "I",
|
||||||
|
},
|
||||||
|
"super-nomad": {
|
||||||
|
"title": "Super-Nomad",
|
||||||
|
"description": mark_safe('Admin access granted to <span class="card-ref">0. The Nomad</span> as Significator'),
|
||||||
|
"palette_options": [],
|
||||||
|
"swatch_label": "0",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,12 +156,14 @@ def my_notes(request):
|
|||||||
active_title = request.user.active_title
|
active_title = request.user.active_title
|
||||||
note_items = [
|
note_items = [
|
||||||
{
|
{
|
||||||
"obj": n,
|
"obj": n,
|
||||||
"title": _NOTE_META.get(n.slug, {}).get("title", n.slug),
|
"title": _NOTE_META.get(n.slug, {}).get("title", n.slug),
|
||||||
"description": _NOTE_META.get(n.slug, {}).get("description", ""),
|
"recognition_title": n.display_title,
|
||||||
"palette_options": _NOTE_META.get(n.slug, {}).get("palette_options", []),
|
"description": _NOTE_META.get(n.slug, {}).get("description", ""),
|
||||||
"palette_label": _PALETTE_LABELS.get(n.palette, "") if n.palette else "",
|
"palette_options": _NOTE_META.get(n.slug, {}).get("palette_options", []),
|
||||||
"is_equipped": active_title is not None and active_title.pk == n.pk,
|
"swatch_label": _NOTE_META.get(n.slug, {}).get("swatch_label"),
|
||||||
|
"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
|
||||||
]
|
]
|
||||||
@@ -166,8 +184,7 @@ def don_title(request, slug):
|
|||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
request.user.active_title = note
|
request.user.active_title = note
|
||||||
request.user.save(update_fields=["active_title"])
|
request.user.save(update_fields=["active_title"])
|
||||||
title = _NOTE_META.get(slug, {}).get("title", slug.capitalize())
|
return JsonResponse({"title": note.display_title, "greeting": note.display_greeting})
|
||||||
return JsonResponse({"title": title})
|
|
||||||
|
|
||||||
|
|
||||||
@login_required(login_url="/")
|
@login_required(login_url="/")
|
||||||
@@ -175,7 +192,7 @@ def doff_title(request, slug):
|
|||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
request.user.active_title = None
|
request.user.active_title = None
|
||||||
request.user.save(update_fields=["active_title"])
|
request.user.save(update_fields=["active_title"])
|
||||||
return JsonResponse({"ok": True})
|
return JsonResponse({"ok": True, "greeting": "Welcome,", "title": "Earthman"})
|
||||||
|
|
||||||
|
|
||||||
@login_required(login_url="/")
|
@login_required(login_url="/")
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""Backfill super-schizo and super-nomad Notes for all existing superusers."""
|
||||||
|
from django.db import migrations
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
|
||||||
|
def grant_super_notes(apps, schema_editor):
|
||||||
|
User = apps.get_model("lyric", "User")
|
||||||
|
Note = apps.get_model("drama", "Note")
|
||||||
|
now = timezone.now()
|
||||||
|
for user in User.objects.filter(is_superuser=True):
|
||||||
|
for slug in ("super-schizo", "super-nomad"):
|
||||||
|
Note.objects.get_or_create(
|
||||||
|
user=user, slug=slug,
|
||||||
|
defaults={"earned_at": now},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("drama", "0002_initial"),
|
||||||
|
("lyric", "0001_initial"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(grant_super_notes, migrations.RunPython.noop),
|
||||||
|
]
|
||||||
@@ -170,6 +170,15 @@ def record(room, verb, actor=None, **data):
|
|||||||
return GameEvent.objects.create(room=room, actor=actor, verb=verb, data=data)
|
return GameEvent.objects.create(room=room, actor=actor, verb=verb, data=data)
|
||||||
|
|
||||||
|
|
||||||
|
_NOTE_DISPLAY = {
|
||||||
|
"stargazer": {"greeting": "Welcome,", "title": "Stargazer"},
|
||||||
|
"schizo": {"greeting": "Welcome,", "title": "Schizo"},
|
||||||
|
"nomad": {"greeting": "Welcome,", "title": "Nomad"},
|
||||||
|
"super-schizo": {"greeting": "21<span class='ord'>st</span> Century", "title": "Schizoid Man"},
|
||||||
|
"super-nomad": {"greeting": "Howdy,", "title": "Stranger"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class Note(models.Model):
|
class Note(models.Model):
|
||||||
user = models.ForeignKey(
|
user = models.ForeignKey(
|
||||||
settings.AUTH_USER_MODEL, on_delete=models.CASCADE,
|
settings.AUTH_USER_MODEL, on_delete=models.CASCADE,
|
||||||
@@ -186,6 +195,14 @@ class Note(models.Model):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.user.email} — {self.slug}"
|
return f"{self.user.email} — {self.slug}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def display_title(self):
|
||||||
|
return _NOTE_DISPLAY.get(self.slug, {}).get("title", self.slug.replace("-", " ").title())
|
||||||
|
|
||||||
|
@property
|
||||||
|
def display_greeting(self):
|
||||||
|
return _NOTE_DISPLAY.get(self.slug, {}).get("greeting", "Welcome,")
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def grant_if_new(cls, user, slug):
|
def grant_if_new(cls, user, slug):
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Generated by Django 6.0 on 2026-04-28 02:35
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('epic', '0005_seed_astro_reference_tables'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='tableseat',
|
||||||
|
name='deck_variant',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='active_seats', to='epic.deckvariant'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -185,6 +185,10 @@ class TableSeat(models.Model):
|
|||||||
"TarotCard", null=True, blank=True,
|
"TarotCard", null=True, blank=True,
|
||||||
on_delete=models.SET_NULL, related_name="significator_seats",
|
on_delete=models.SET_NULL, related_name="significator_seats",
|
||||||
)
|
)
|
||||||
|
deck_variant = models.ForeignKey(
|
||||||
|
"DeckVariant", null=True, blank=True,
|
||||||
|
on_delete=models.SET_NULL, related_name="active_seats",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class DeckVariant(models.Model):
|
class DeckVariant(models.Model):
|
||||||
@@ -417,6 +421,19 @@ class SigReservation(models.Model):
|
|||||||
|
|
||||||
# ── Significator deck helpers ─────────────────────────────────────────────────
|
# ── Significator deck helpers ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _room_deck_variant(room):
|
||||||
|
"""Return the DeckVariant in use for this room.
|
||||||
|
|
||||||
|
Looks up the deck committed to any TableSeat in the room (all seats share the
|
||||||
|
same deck per game). Falls back to the room owner's equipped_deck for rooms
|
||||||
|
created before deck contribution was wired.
|
||||||
|
"""
|
||||||
|
seat = room.table_seats.filter(deck_variant__isnull=False).first()
|
||||||
|
if seat:
|
||||||
|
return seat.deck_variant
|
||||||
|
return room.owner.equipped_deck
|
||||||
|
|
||||||
|
|
||||||
def sig_deck_cards(room):
|
def sig_deck_cards(room):
|
||||||
"""Return 36 TarotCard objects forming the Significator deck (18 unique × 2).
|
"""Return 36 TarotCard objects forming the Significator deck (18 unique × 2).
|
||||||
|
|
||||||
@@ -425,7 +442,7 @@ def sig_deck_cards(room):
|
|||||||
NC/EC pair → MAJOR arcana numbers 0 and 1: 2 unique
|
NC/EC pair → MAJOR arcana numbers 0 and 1: 2 unique
|
||||||
Total: 18 unique × 2 (levity + gravity piles) = 36 cards.
|
Total: 18 unique × 2 (levity + gravity piles) = 36 cards.
|
||||||
"""
|
"""
|
||||||
deck_variant = room.owner.equipped_deck
|
deck_variant = _room_deck_variant(room)
|
||||||
if deck_variant is None:
|
if deck_variant is None:
|
||||||
return []
|
return []
|
||||||
wands_crowns = list(TarotCard.objects.filter(
|
wands_crowns = list(TarotCard.objects.filter(
|
||||||
@@ -451,7 +468,7 @@ def sig_deck_cards(room):
|
|||||||
|
|
||||||
def _sig_unique_cards(room):
|
def _sig_unique_cards(room):
|
||||||
"""Return the 18 unique TarotCard objects that form one sig pile."""
|
"""Return the 18 unique TarotCard objects that form one sig pile."""
|
||||||
deck_variant = room.owner.equipped_deck
|
deck_variant = _room_deck_variant(room)
|
||||||
if deck_variant is None:
|
if deck_variant is None:
|
||||||
return []
|
return []
|
||||||
wands_crowns = list(TarotCard.objects.filter(
|
wands_crowns = list(TarotCard.objects.filter(
|
||||||
@@ -474,14 +491,27 @@ def _sig_unique_cards(room):
|
|||||||
return wands_crowns + swords_cups + major
|
return wands_crowns + swords_cups + major
|
||||||
|
|
||||||
|
|
||||||
def levity_sig_cards(room):
|
def _filter_major_unlocks(cards, user):
|
||||||
"""The 18 cards available to the levity group (PC/NC/SC)."""
|
"""Remove Nomad (0) and Schizo (1) unless the user has the matching Note unlock."""
|
||||||
return _sig_unique_cards(room)
|
if user is None or not user.is_authenticated:
|
||||||
|
return [c for c in cards if c.arcana != TarotCard.MAJOR]
|
||||||
|
earned = set(user.notes.values_list("slug", flat=True))
|
||||||
|
return [
|
||||||
|
c for c in cards
|
||||||
|
if c.arcana != TarotCard.MAJOR
|
||||||
|
or (c.number == 0 and earned & {"nomad", "super-nomad"})
|
||||||
|
or (c.number == 1 and earned & {"schizo", "super-schizo"})
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def gravity_sig_cards(room):
|
def levity_sig_cards(room, user=None):
|
||||||
"""The 18 cards available to the gravity group (BC/EC/AC)."""
|
"""Cards available to the levity group (PC/NC/SC), filtered by user's Note unlocks."""
|
||||||
return _sig_unique_cards(room)
|
return _filter_major_unlocks(_sig_unique_cards(room), user)
|
||||||
|
|
||||||
|
|
||||||
|
def gravity_sig_cards(room, user=None):
|
||||||
|
"""Cards available to the gravity group (BC/EC/AC), filtered by user's Note unlocks."""
|
||||||
|
return _filter_major_unlocks(_sig_unique_cards(room), user)
|
||||||
|
|
||||||
|
|
||||||
def sig_seat_order(room):
|
def sig_seat_order(room):
|
||||||
|
|||||||
@@ -41,6 +41,29 @@ var RoleSelect = (function () {
|
|||||||
if (backdrop) backdrop.remove();
|
if (backdrop) backdrop.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _showNoDeckWarning(stack) {
|
||||||
|
if (document.querySelector(".role-no-deck-warning")) return;
|
||||||
|
var el = document.createElement("div");
|
||||||
|
el.className = "role-no-deck-warning";
|
||||||
|
el.innerHTML =
|
||||||
|
"<p>Equip card deck before Role select</p>" +
|
||||||
|
"<div class=\"guard-actions\">" +
|
||||||
|
"<a class=\"btn btn-caution\" href=\"/gameboard/\">FYI</a>" +
|
||||||
|
"<button class=\"btn btn-cancel\">NVM</button>" +
|
||||||
|
"</div>";
|
||||||
|
el.querySelector(".btn-cancel").addEventListener("click", function () {
|
||||||
|
el.remove();
|
||||||
|
});
|
||||||
|
if (stack) {
|
||||||
|
var rect = stack.getBoundingClientRect();
|
||||||
|
el.style.position = "fixed";
|
||||||
|
el.style.left = Math.round(rect.left + rect.width / 2) + "px";
|
||||||
|
el.style.top = Math.round(rect.top + rect.height / 2) + "px";
|
||||||
|
el.style.transform = "translate(-50%, -50%)";
|
||||||
|
}
|
||||||
|
document.body.appendChild(el);
|
||||||
|
}
|
||||||
|
|
||||||
function selectRole(roleCode) {
|
function selectRole(roleCode) {
|
||||||
_turnChangedBeforeFetch = false; // fresh selection, reset the race flag
|
_turnChangedBeforeFetch = false; // fresh selection, reset the race flag
|
||||||
closeFan();
|
closeFan();
|
||||||
@@ -138,6 +161,12 @@ var RoleSelect = (function () {
|
|||||||
function openFan() {
|
function openFan() {
|
||||||
if (document.querySelector(".role-select-backdrop")) return;
|
if (document.querySelector(".role-select-backdrop")) return;
|
||||||
|
|
||||||
|
var stack = document.querySelector(".card-stack[data-starter-roles]");
|
||||||
|
if (stack && stack.dataset.equippedDeck === "") {
|
||||||
|
_showNoDeckWarning(stack);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var taken = getStarterRoles();
|
var taken = getStarterRoles();
|
||||||
var available = ROLES.filter(function (r) { return taken.indexOf(r.code) === -1; });
|
var available = ROLES.filter(function (r) { return taken.indexOf(r.code) === -1; });
|
||||||
|
|
||||||
|
|||||||
@@ -118,7 +118,9 @@ var SigSelect = (function () {
|
|||||||
stageCard.querySelector('.fan-card-arcana').textContent = arcana;
|
stageCard.querySelector('.fan-card-arcana').textContent = arcana;
|
||||||
stageCard.querySelector('.fan-card-correspondence').textContent = ''; // shown in game-kit only
|
stageCard.querySelector('.fan-card-correspondence').textContent = ''; // shown in game-kit only
|
||||||
|
|
||||||
var qualifier = userPolarity === 'levity' ? 'Leavened' : 'Graven';
|
var qualifier = userPolarity === 'levity'
|
||||||
|
? (cardEl.dataset.levityQualifier || '')
|
||||||
|
: (cardEl.dataset.gravityQualifier || '');
|
||||||
var isMajor = arcana.toLowerCase().indexOf('major') !== -1;
|
var isMajor = arcana.toLowerCase().indexOf('major') !== -1;
|
||||||
// Major arcana: qualifier sits below the title — append comma so it reads as a subtitle.
|
// Major arcana: qualifier sits below the title — append comma so it reads as a subtitle.
|
||||||
stageCard.querySelector('.fan-card-name').textContent = isMajor ? title + ',' : title;
|
stageCard.querySelector('.fan-card-name').textContent = isMajor ? title + ',' : title;
|
||||||
|
|||||||
@@ -445,25 +445,44 @@ class SigReservationModelTest(TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class SigCardHelperTest(TestCase):
|
class SigCardHelperTest(TestCase):
|
||||||
"""levity_sig_cards() and gravity_sig_cards() return 18 cards each.
|
"""levity_sig_cards() and gravity_sig_cards() return 16 courts by default;
|
||||||
|
Nomad/Schizo added when the user has the matching Note unlock.
|
||||||
Relies on the Earthman deck seeded by migrations (no manual card creation).
|
Relies on the Earthman deck seeded by migrations (no manual card creation).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
# Earthman deck is already seeded by migrations
|
from django.utils import timezone
|
||||||
self.earthman = DeckVariant.objects.get(slug="earthman")
|
self.earthman = DeckVariant.objects.get(slug="earthman")
|
||||||
self.owner = User.objects.create(email="founder@test.io")
|
self.owner = User.objects.create(email="founder@test.io")
|
||||||
self.owner.equipped_deck = self.earthman
|
TableSeat.objects.create(
|
||||||
self.owner.save()
|
room=Room.objects.create(name="Card Test", owner=self.owner),
|
||||||
self.room = Room.objects.create(name="Card Test", owner=self.owner)
|
gamer=self.owner, slot_number=1, role="PC",
|
||||||
|
deck_variant=self.earthman,
|
||||||
|
)
|
||||||
|
self.room = self.owner.table_seats.first().room
|
||||||
|
self._tz = timezone
|
||||||
|
|
||||||
def test_levity_sig_cards_returns_18(self):
|
def test_levity_sig_cards_returns_16_without_notes(self):
|
||||||
cards = levity_sig_cards(self.room)
|
cards = levity_sig_cards(self.room, self.owner)
|
||||||
self.assertEqual(len(cards), 18)
|
self.assertEqual(len(cards), 16)
|
||||||
|
|
||||||
def test_gravity_sig_cards_returns_18(self):
|
def test_gravity_sig_cards_returns_16_without_notes(self):
|
||||||
cards = gravity_sig_cards(self.room)
|
cards = gravity_sig_cards(self.room, self.owner)
|
||||||
self.assertEqual(len(cards), 18)
|
self.assertEqual(len(cards), 16)
|
||||||
|
|
||||||
|
def test_nomad_note_includes_nomad(self):
|
||||||
|
from apps.drama.models import Note
|
||||||
|
Note.objects.create(user=self.owner, slug="nomad", earned_at=self._tz.now())
|
||||||
|
cards = levity_sig_cards(self.room, self.owner)
|
||||||
|
self.assertEqual(len(cards), 17)
|
||||||
|
self.assertTrue(any(c.number == 0 and c.arcana == "MAJOR" for c in cards))
|
||||||
|
|
||||||
|
def test_schizo_note_includes_schizo(self):
|
||||||
|
from apps.drama.models import Note
|
||||||
|
Note.objects.create(user=self.owner, slug="schizo", earned_at=self._tz.now())
|
||||||
|
cards = levity_sig_cards(self.room, self.owner)
|
||||||
|
self.assertEqual(len(cards), 17)
|
||||||
|
self.assertTrue(any(c.number == 1 and c.arcana == "MAJOR" for c in cards))
|
||||||
|
|
||||||
def test_levity_and_gravity_share_same_card_objects(self):
|
def test_levity_and_gravity_share_same_card_objects(self):
|
||||||
"""Both piles draw from the same 18 TarotCard instances — visual distinction
|
"""Both piles draw from the same 18 TarotCard instances — visual distinction
|
||||||
@@ -475,11 +494,13 @@ class SigCardHelperTest(TestCase):
|
|||||||
sorted(c.pk for c in gravity),
|
sorted(c.pk for c in gravity),
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_returns_empty_when_no_equipped_deck(self):
|
def test_returns_empty_when_no_deck_on_seats_or_owner(self):
|
||||||
|
"""Falls back to empty list when neither seats nor owner have a deck."""
|
||||||
|
self.room.table_seats.update(deck_variant=None)
|
||||||
self.owner.equipped_deck = None
|
self.owner.equipped_deck = None
|
||||||
self.owner.save()
|
self.owner.save()
|
||||||
self.assertEqual(levity_sig_cards(self.room), [])
|
self.assertEqual(levity_sig_cards(self.room, self.owner), [])
|
||||||
self.assertEqual(gravity_sig_cards(self.room), [])
|
self.assertEqual(gravity_sig_cards(self.room, self.owner), [])
|
||||||
|
|
||||||
|
|
||||||
class TarotCardCautionsTest(TestCase):
|
class TarotCardCautionsTest(TestCase):
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from django.test import TestCase
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from apps.drama.models import GameEvent
|
from apps.drama.models import GameEvent, Note
|
||||||
from apps.lyric.models import Token, User
|
from apps.lyric.models import Token, User
|
||||||
from apps.epic.models import (
|
from apps.epic.models import (
|
||||||
Character, DeckVariant, GateSlot, Room, RoomInvite, SigReservation, TableSeat, TarotCard,
|
Character, DeckVariant, GateSlot, Room, RoomInvite, SigReservation, TableSeat, TarotCard,
|
||||||
@@ -142,6 +142,39 @@ class DropTokenViewTest(TestCase):
|
|||||||
response, reverse("epic:gatekeeper", args=[self.room.id])
|
response, reverse("epic:gatekeeper", args=[self.room.id])
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_carte_drop_sets_current_room(self):
|
||||||
|
carte = Token.objects.create(user=self.gamer, token_type=Token.CARTE)
|
||||||
|
self.client.post(
|
||||||
|
reverse("epic:drop_token", kwargs={"room_id": self.room.id}),
|
||||||
|
data={"token_id": carte.pk},
|
||||||
|
)
|
||||||
|
carte.refresh_from_db()
|
||||||
|
self.assertEqual(carte.current_room, self.room)
|
||||||
|
|
||||||
|
def test_carte_drop_unequips_trinket(self):
|
||||||
|
carte = Token.objects.create(user=self.gamer, token_type=Token.CARTE)
|
||||||
|
self.gamer.equipped_trinket = carte
|
||||||
|
self.gamer.save(update_fields=["equipped_trinket"])
|
||||||
|
self.client.post(
|
||||||
|
reverse("epic:drop_token", kwargs={"room_id": self.room.id}),
|
||||||
|
data={"token_id": carte.pk},
|
||||||
|
)
|
||||||
|
self.gamer.refresh_from_db()
|
||||||
|
self.assertIsNone(self.gamer.equipped_trinket)
|
||||||
|
|
||||||
|
def test_carte_drop_rejected_when_already_in_different_room(self):
|
||||||
|
other_room = Room.objects.create(name="Other Room", owner=self.gamer)
|
||||||
|
carte = Token.objects.create(
|
||||||
|
user=self.gamer, token_type=Token.CARTE, current_room=other_room,
|
||||||
|
)
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("epic:drop_token", kwargs={"room_id": self.room.id}),
|
||||||
|
data={"token_id": carte.pk},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 409)
|
||||||
|
carte.refresh_from_db()
|
||||||
|
self.assertEqual(carte.current_room, other_room) # unchanged
|
||||||
|
|
||||||
|
|
||||||
class ConfirmTokenViewTest(TestCase):
|
class ConfirmTokenViewTest(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
@@ -680,6 +713,36 @@ class SelectRoleViewTest(TestCase):
|
|||||||
)
|
)
|
||||||
mock_notify.assert_called_once_with(self.room.id)
|
mock_notify.assert_called_once_with(self.room.id)
|
||||||
|
|
||||||
|
def test_select_role_assigns_equipped_deck_to_seat(self):
|
||||||
|
earthman = DeckVariant.objects.get(slug="earthman")
|
||||||
|
self.founder.equipped_deck = earthman
|
||||||
|
self.founder.save(update_fields=["equipped_deck"])
|
||||||
|
self.client.post(
|
||||||
|
reverse("epic:select_role", kwargs={"room_id": self.room.id}),
|
||||||
|
data={"role": "PC"},
|
||||||
|
)
|
||||||
|
seat = TableSeat.objects.get(room=self.room, slot_number=1)
|
||||||
|
self.assertEqual(seat.deck_variant, earthman)
|
||||||
|
|
||||||
|
def test_select_role_no_deck_leaves_deck_variant_null(self):
|
||||||
|
self.founder.equipped_deck = None
|
||||||
|
self.founder.save(update_fields=["equipped_deck"])
|
||||||
|
self.client.post(
|
||||||
|
reverse("epic:select_role", kwargs={"room_id": self.room.id}),
|
||||||
|
data={"role": "PC"},
|
||||||
|
)
|
||||||
|
seat = TableSeat.objects.get(room=self.room, slot_number=1)
|
||||||
|
self.assertIsNone(seat.deck_variant)
|
||||||
|
|
||||||
|
def test_select_role_unequips_deck_from_user(self):
|
||||||
|
earthman = DeckVariant.objects.get(slug="earthman")
|
||||||
|
self.client.post(
|
||||||
|
reverse("epic:select_role", kwargs={"room_id": self.room.id}),
|
||||||
|
data={"role": "PC"},
|
||||||
|
)
|
||||||
|
self.founder.refresh_from_db()
|
||||||
|
self.assertIsNone(self.founder.equipped_deck)
|
||||||
|
|
||||||
def test_select_role_requires_login(self):
|
def test_select_role_requires_login(self):
|
||||||
self.client.logout()
|
self.client.logout()
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
@@ -743,6 +806,55 @@ class SelectRoleViewTest(TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SelectRoleMultiSeatTest(TestCase):
|
||||||
|
"""Carte Blanche multi-seat: second role reuses the deck from the first seat."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.founder = User.objects.create(email="founder@test.io")
|
||||||
|
self.client.force_login(self.founder)
|
||||||
|
self.room = Room.objects.create(name="Test Room", owner=self.founder)
|
||||||
|
self.room.gate_status = Room.OPEN
|
||||||
|
self.room.table_status = Room.ROLE_SELECT
|
||||||
|
self.room.save()
|
||||||
|
self.earthman = DeckVariant.objects.get(slug="earthman")
|
||||||
|
|
||||||
|
def test_second_role_inherits_deck_from_first_seat_in_room(self):
|
||||||
|
# Founder's first seat: PC already taken with deck assigned
|
||||||
|
TableSeat.objects.create(
|
||||||
|
room=self.room, gamer=self.founder, slot_number=1,
|
||||||
|
role="PC", deck_variant=self.earthman,
|
||||||
|
)
|
||||||
|
# Deck unequipped after first role
|
||||||
|
self.founder.equipped_deck = None
|
||||||
|
self.founder.save(update_fields=["equipped_deck"])
|
||||||
|
# Founder's second seat (Carte Blanche): no role yet
|
||||||
|
second_seat = TableSeat.objects.create(
|
||||||
|
room=self.room, gamer=self.founder, slot_number=2,
|
||||||
|
)
|
||||||
|
self.client.post(
|
||||||
|
reverse("epic:select_role", kwargs={"room_id": self.room.id}),
|
||||||
|
data={"role": "BC"},
|
||||||
|
)
|
||||||
|
second_seat.refresh_from_db()
|
||||||
|
self.assertEqual(second_seat.deck_variant, self.earthman)
|
||||||
|
|
||||||
|
def test_second_role_does_not_unequip_again(self):
|
||||||
|
"""No-op unequip when deck was already cleared by the first role."""
|
||||||
|
TableSeat.objects.create(
|
||||||
|
room=self.room, gamer=self.founder, slot_number=1,
|
||||||
|
role="PC", deck_variant=self.earthman,
|
||||||
|
)
|
||||||
|
self.founder.equipped_deck = None
|
||||||
|
self.founder.save(update_fields=["equipped_deck"])
|
||||||
|
TableSeat.objects.create(room=self.room, gamer=self.founder, slot_number=2)
|
||||||
|
self.client.post(
|
||||||
|
reverse("epic:select_role", kwargs={"room_id": self.room.id}),
|
||||||
|
data={"role": "BC"},
|
||||||
|
)
|
||||||
|
self.founder.refresh_from_db()
|
||||||
|
self.assertIsNone(self.founder.equipped_deck) # still None, not broken
|
||||||
|
|
||||||
|
|
||||||
class RoomViewAllRolesFilledTest(TestCase):
|
class RoomViewAllRolesFilledTest(TestCase):
|
||||||
"""Room view in ROLE_SELECT with all seats assigned shows PICK SIGS button."""
|
"""Room view in ROLE_SELECT with all seats assigned shows PICK SIGS button."""
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
@@ -920,7 +1032,8 @@ def _full_sig_setUp(test_case, role_order=None):
|
|||||||
slot.status = GateSlot.FILLED
|
slot.status = GateSlot.FILLED
|
||||||
slot.save()
|
slot.save()
|
||||||
TableSeat.objects.create(
|
TableSeat.objects.create(
|
||||||
room=room, gamer=gamer, slot_number=i, role=role, role_revealed=True,
|
room=room, gamer=gamer, slot_number=i, role=role,
|
||||||
|
role_revealed=True, deck_variant=earthman,
|
||||||
)
|
)
|
||||||
room.gate_status = Room.OPEN
|
room.gate_status = Room.OPEN
|
||||||
room.table_status = Room.SIG_SELECT
|
room.table_status = Room.SIG_SELECT
|
||||||
@@ -943,7 +1056,34 @@ class SigSelectRenderingTest(TestCase):
|
|||||||
response = self.client.get(self.url)
|
response = self.client.get(self.url)
|
||||||
self.assertContains(response, "id_sig_deck")
|
self.assertContains(response, "id_sig_deck")
|
||||||
|
|
||||||
def test_sig_deck_contains_18_sig_cards(self):
|
def test_sig_deck_contains_16_sig_cards_by_default(self):
|
||||||
|
"""Without Note unlocks the deck shows only 16 court cards (no Nomad/Schizo)."""
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
self.assertEqual(response.content.decode().count('data-card-id='), 16)
|
||||||
|
|
||||||
|
def test_nomad_note_adds_nomad_to_sig_deck(self):
|
||||||
|
Note.objects.create(user=self.gamers[0], slug="nomad", earned_at=timezone.now())
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
self.assertEqual(response.content.decode().count('data-card-id='), 17)
|
||||||
|
|
||||||
|
def test_schizo_note_adds_schizo_to_sig_deck(self):
|
||||||
|
Note.objects.create(user=self.gamers[0], slug="schizo", earned_at=timezone.now())
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
self.assertEqual(response.content.decode().count('data-card-id='), 17)
|
||||||
|
|
||||||
|
def test_super_nomad_note_also_unlocks_nomad(self):
|
||||||
|
Note.objects.create(user=self.gamers[0], slug="super-nomad", earned_at=timezone.now())
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
self.assertEqual(response.content.decode().count('data-card-id='), 17)
|
||||||
|
|
||||||
|
def test_super_schizo_note_also_unlocks_schizo(self):
|
||||||
|
Note.objects.create(user=self.gamers[0], slug="super-schizo", earned_at=timezone.now())
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
self.assertEqual(response.content.decode().count('data-card-id='), 17)
|
||||||
|
|
||||||
|
def test_both_notes_gives_18_sig_cards(self):
|
||||||
|
Note.objects.create(user=self.gamers[0], slug="nomad", earned_at=timezone.now())
|
||||||
|
Note.objects.create(user=self.gamers[0], slug="schizo", earned_at=timezone.now())
|
||||||
response = self.client.get(self.url)
|
response = self.client.get(self.url)
|
||||||
self.assertEqual(response.content.decode().count('data-card-id='), 18)
|
self.assertEqual(response.content.decode().count('data-card-id='), 18)
|
||||||
|
|
||||||
|
|||||||
@@ -276,6 +276,7 @@ def _role_select_context(room, user):
|
|||||||
_my_role = assigned_seats[0].role if assigned_seats else None
|
_my_role = assigned_seats[0].role if assigned_seats else None
|
||||||
ctx = {
|
ctx = {
|
||||||
"card_stack_state": card_stack_state,
|
"card_stack_state": card_stack_state,
|
||||||
|
"equipped_deck_id": user.equipped_deck_id if user.is_authenticated else None,
|
||||||
"starter_roles": starter_roles,
|
"starter_roles": starter_roles,
|
||||||
"assigned_seats": assigned_seats,
|
"assigned_seats": assigned_seats,
|
||||||
"my_tray_role": _my_role,
|
"my_tray_role": _my_role,
|
||||||
@@ -337,9 +338,9 @@ def _role_select_context(room, user):
|
|||||||
ctx["sig_reservations_json"] = json.dumps(reservations)
|
ctx["sig_reservations_json"] = json.dumps(reservations)
|
||||||
|
|
||||||
if user_polarity == 'levity':
|
if user_polarity == 'levity':
|
||||||
ctx["sig_cards"] = levity_sig_cards(room)
|
ctx["sig_cards"] = levity_sig_cards(room, user)
|
||||||
elif user_polarity == 'gravity':
|
elif user_polarity == 'gravity':
|
||||||
ctx["sig_cards"] = gravity_sig_cards(room)
|
ctx["sig_cards"] = gravity_sig_cards(room, user)
|
||||||
else:
|
else:
|
||||||
ctx["sig_cards"] = []
|
ctx["sig_cards"] = []
|
||||||
|
|
||||||
@@ -405,8 +406,13 @@ def drop_token(request, room_id):
|
|||||||
if token.token_type == Token.CARTE:
|
if token.token_type == Token.CARTE:
|
||||||
# CARTE enters the machine without reserving a slot — all slots
|
# CARTE enters the machine without reserving a slot — all slots
|
||||||
# become individually claimable via .drop-token-btn
|
# become individually claimable via .drop-token-btn
|
||||||
|
if token.current_room_id and token.current_room_id != room.id:
|
||||||
|
return HttpResponse(status=409)
|
||||||
token.current_room = room
|
token.current_room = room
|
||||||
token.save()
|
token.save()
|
||||||
|
if request.user.equipped_trinket_id == token.pk:
|
||||||
|
request.user.equipped_trinket = None
|
||||||
|
request.user.save(update_fields=["equipped_trinket"])
|
||||||
request.session["kit_token_id"] = str(token.id)
|
request.session["kit_token_id"] = str(token.id)
|
||||||
_notify_gate_update(room_id)
|
_notify_gate_update(room_id)
|
||||||
return redirect("epic:gatekeeper", room_id=room_id)
|
return redirect("epic:gatekeeper", room_id=room_id)
|
||||||
@@ -566,6 +572,7 @@ def select_role(request, room_id):
|
|||||||
valid_roles = [code for code, _ in TableSeat.ROLE_CHOICES]
|
valid_roles = [code for code, _ in TableSeat.ROLE_CHOICES]
|
||||||
if not role or role not in valid_roles:
|
if not role or role not in valid_roles:
|
||||||
return redirect("epic:room", room_id=room_id)
|
return redirect("epic:room", room_id=room_id)
|
||||||
|
existing = None
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
active_seat = room.table_seats.select_for_update().filter(
|
active_seat = room.table_seats.select_for_update().filter(
|
||||||
role__isnull=True
|
role__isnull=True
|
||||||
@@ -575,7 +582,16 @@ def select_role(request, room_id):
|
|||||||
if room.table_seats.filter(role=role).exists():
|
if room.table_seats.filter(role=role).exists():
|
||||||
return HttpResponse(status=409)
|
return HttpResponse(status=409)
|
||||||
active_seat.role = role
|
active_seat.role = role
|
||||||
|
existing = room.table_seats.filter(
|
||||||
|
gamer=request.user, deck_variant__isnull=False,
|
||||||
|
).exclude(pk=active_seat.pk).first()
|
||||||
|
active_seat.deck_variant = (
|
||||||
|
existing.deck_variant if existing else request.user.equipped_deck
|
||||||
|
)
|
||||||
active_seat.save()
|
active_seat.save()
|
||||||
|
if not existing and request.user.equipped_deck:
|
||||||
|
request.user.equipped_deck = None
|
||||||
|
request.user.save(update_fields=["equipped_deck"])
|
||||||
record(room, GameEvent.ROLE_SELECTED, actor=request.user,
|
record(room, GameEvent.ROLE_SELECTED, actor=request.user,
|
||||||
role=role, slot_number=active_seat.slot_number,
|
role=role, slot_number=active_seat.slot_number,
|
||||||
role_display=dict(TableSeat.ROLE_CHOICES).get(role, role))
|
role_display=dict(TableSeat.ROLE_CHOICES).get(role, role))
|
||||||
@@ -756,15 +772,11 @@ def sig_ready(request, room_id):
|
|||||||
reservation.ready = True
|
reservation.ready = True
|
||||||
reservation.save(update_fields=["ready"])
|
reservation.save(update_fields=["ready"])
|
||||||
card = reservation.card
|
card = reservation.card
|
||||||
if card and card.arcana == TarotCard.MIDDLE:
|
if card:
|
||||||
_pol_prefix = "Leavened" if reservation.polarity == SigReservation.LEVITY else "Graven"
|
_qual = card.levity_qualifier if reservation.polarity == SigReservation.LEVITY else card.gravity_qualifier
|
||||||
_card_display = f"{_pol_prefix} {card.name_title}"
|
_card_display = f"{_qual} {card.name_title}" if _qual else card.name_title
|
||||||
elif card and card.arcana == TarotCard.MAJOR:
|
|
||||||
_base = card.name_title.removeprefix("The ")
|
|
||||||
_pol_suffix = "of Light" if reservation.polarity == SigReservation.LEVITY else "from the Grave"
|
|
||||||
_card_display = f"{_base} {_pol_suffix}"
|
|
||||||
else:
|
else:
|
||||||
_card_display = card.name_title if card else "a card"
|
_card_display = "a card"
|
||||||
record(room, GameEvent.SIG_READY, actor=request.user,
|
record(room, GameEvent.SIG_READY, actor=request.user,
|
||||||
card_name=_card_display,
|
card_name=_card_display,
|
||||||
corner_rank=card.corner_rank if card else "",
|
corner_rank=card.corner_rank if card else "",
|
||||||
|
|||||||
@@ -64,10 +64,20 @@ function initGameKitTooltips() {
|
|||||||
const tokenId = token.dataset.tokenId;
|
const tokenId = token.dataset.tokenId;
|
||||||
const equippedId = gameKit.dataset.equippedId || '';
|
const equippedId = gameKit.dataset.equippedId || '';
|
||||||
const equippedDeckId = gameKit.dataset.equippedDeckId || '';
|
const equippedDeckId = gameKit.dataset.equippedDeckId || '';
|
||||||
|
const inUseDeckIds = new Set((gameKit.dataset.inUseDeckIds || '').split(',').filter(Boolean));
|
||||||
if (deckId) {
|
if (deckId) {
|
||||||
miniPortal.textContent = (equippedDeckId && deckId === equippedDeckId) ? 'Equipped' : 'Not Equipped';
|
if (inUseDeckIds.has(deckId)) {
|
||||||
|
miniPortal.textContent = 'In-Use';
|
||||||
|
} else {
|
||||||
|
miniPortal.textContent = (equippedDeckId && deckId === equippedDeckId) ? 'Equipped' : 'Not Equipped';
|
||||||
|
}
|
||||||
} else if (tokenId) {
|
} else if (tokenId) {
|
||||||
miniPortal.textContent = (equippedId && tokenId === equippedId) ? 'Equipped' : 'Not Equipped';
|
const currentRoomName = token.dataset.currentRoomName || '';
|
||||||
|
if (currentRoomName) {
|
||||||
|
miniPortal.textContent = 'In-Use';
|
||||||
|
} else {
|
||||||
|
miniPortal.textContent = (equippedId && tokenId === equippedId) ? 'Equipped' : 'Not Equipped';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from django.test import TestCase
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from apps.applets.models import Applet, UserApplet
|
from apps.applets.models import Applet, UserApplet
|
||||||
from apps.epic.models import DeckVariant
|
from apps.epic.models import DeckVariant, Room, TableSeat
|
||||||
from apps.lyric.models import Token, User
|
from apps.lyric.models import Token, User
|
||||||
|
|
||||||
|
|
||||||
@@ -61,6 +61,45 @@ class GameboardViewTest(TestCase):
|
|||||||
[_] = self.parsed.cssselect("#id_game_kit #id_kit_dice_set")
|
[_] = self.parsed.cssselect("#id_game_kit #id_kit_dice_set")
|
||||||
|
|
||||||
|
|
||||||
|
class GameboardDeckInUseTest(TestCase):
|
||||||
|
"""Sprint 2: game kit applet renders in-use state for a deck assigned to an active seat."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create(email="gamer@test.io")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
Applet.objects.get_or_create(slug="game-kit", defaults={"name": "Game Kit", "context": "gameboard"})
|
||||||
|
self.earthman = DeckVariant.objects.get(slug="earthman")
|
||||||
|
self.room = Room.objects.create(name="Wildfire", owner=self.user)
|
||||||
|
self.seat = TableSeat.objects.create(
|
||||||
|
room=self.room, gamer=self.user, slot_number=1,
|
||||||
|
deck_variant=self.earthman,
|
||||||
|
)
|
||||||
|
response = self.client.get("/gameboard/")
|
||||||
|
self.parsed = lxml.html.fromstring(response.content)
|
||||||
|
|
||||||
|
def test_in_use_deck_don_is_disabled(self):
|
||||||
|
[don] = self.parsed.cssselect("#id_kit_earthman_deck .btn-equip")
|
||||||
|
self.assertIn("btn-disabled", don.get("class", ""))
|
||||||
|
|
||||||
|
def test_in_use_deck_doff_is_absent(self):
|
||||||
|
active_doff = self.parsed.cssselect(
|
||||||
|
"#id_kit_earthman_deck .btn-unequip:not(.btn-disabled)"
|
||||||
|
)
|
||||||
|
self.assertEqual(len(active_doff), 0)
|
||||||
|
|
||||||
|
def test_in_use_deck_tooltip_shows_game_name(self):
|
||||||
|
[label] = self.parsed.cssselect("#id_kit_earthman_deck .tt-deck-game-name")
|
||||||
|
self.assertIn("Wildfire", label.text_content())
|
||||||
|
|
||||||
|
def test_non_in_use_deck_has_normal_don(self):
|
||||||
|
fiorentine = DeckVariant.objects.get(slug="fiorentine-minchiate")
|
||||||
|
self.user.unlocked_decks.add(fiorentine)
|
||||||
|
response = self.client.get("/gameboard/")
|
||||||
|
parsed = lxml.html.fromstring(response.content)
|
||||||
|
[don] = parsed.cssselect("#id_kit_fiorentine_deck .btn-equip")
|
||||||
|
self.assertNotIn("btn-disabled", don.get("class", ""))
|
||||||
|
|
||||||
|
|
||||||
class ToggleGameAppletsViewTest(TestCase):
|
class ToggleGameAppletsViewTest(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.user = User.objects.create(email="gamer@test.io")
|
self.user = User.objects.create(email="gamer@test.io")
|
||||||
|
|||||||
@@ -4,7 +4,20 @@ from django.shortcuts import get_object_or_404, redirect, render
|
|||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from apps.applets.utils import applet_context, apply_applet_toggle
|
from apps.applets.utils import applet_context, apply_applet_toggle
|
||||||
from apps.epic.models import DeckVariant, Room
|
|
||||||
|
|
||||||
|
def _annotate_deck_in_use(decks, user):
|
||||||
|
"""Attach .in_use_room_name to each deck — the name of the active room using it, or None."""
|
||||||
|
active = {
|
||||||
|
ts.deck_variant_id: ts.room.name
|
||||||
|
for ts in TableSeat.objects.filter(
|
||||||
|
gamer=user, deck_variant__isnull=False,
|
||||||
|
).select_related("room")
|
||||||
|
}
|
||||||
|
for deck in decks:
|
||||||
|
deck.in_use_room_name = active.get(deck.pk)
|
||||||
|
return decks
|
||||||
|
from apps.epic.models import DeckVariant, Room, TableSeat
|
||||||
from apps.epic.utils import rooms_for_user
|
from apps.epic.utils import rooms_for_user
|
||||||
from apps.lyric.models import Token
|
from apps.lyric.models import Token
|
||||||
|
|
||||||
@@ -31,7 +44,7 @@ def gameboard(request):
|
|||||||
"carte": carte,
|
"carte": carte,
|
||||||
"equipped_trinket_id": request.user.equipped_trinket_id,
|
"equipped_trinket_id": request.user.equipped_trinket_id,
|
||||||
"equipped_deck_id": request.user.equipped_deck_id,
|
"equipped_deck_id": request.user.equipped_deck_id,
|
||||||
"deck_variants": list(request.user.unlocked_decks.all()),
|
"deck_variants": _annotate_deck_in_use(list(request.user.unlocked_decks.all()), request.user),
|
||||||
"free_tokens": free_tokens,
|
"free_tokens": free_tokens,
|
||||||
"free_count": len(free_tokens),
|
"free_count": len(free_tokens),
|
||||||
"applets": applet_context(request.user, "gameboard"),
|
"applets": applet_context(request.user, "gameboard"),
|
||||||
@@ -55,7 +68,7 @@ def toggle_game_applets(request):
|
|||||||
"carte": request.user.tokens.filter(token_type=Token.CARTE).first(),
|
"carte": request.user.tokens.filter(token_type=Token.CARTE).first(),
|
||||||
"equipped_trinket_id": request.user.equipped_trinket_id,
|
"equipped_trinket_id": request.user.equipped_trinket_id,
|
||||||
"equipped_deck_id": request.user.equipped_deck_id,
|
"equipped_deck_id": request.user.equipped_deck_id,
|
||||||
"deck_variants": list(request.user.unlocked_decks.all()),
|
"deck_variants": _annotate_deck_in_use(list(request.user.unlocked_decks.all()), request.user),
|
||||||
"free_tokens": free_tokens,
|
"free_tokens": free_tokens,
|
||||||
"free_count": len(free_tokens),
|
"free_count": len(free_tokens),
|
||||||
"my_games": rooms_for_user(request.user),
|
"my_games": rooms_for_user(request.user),
|
||||||
|
|||||||
@@ -213,3 +213,7 @@ def create_wallet_and_tokens(sender, instance, created, **kwargs):
|
|||||||
instance.save(update_fields=['equipped_trinket', 'equipped_deck'])
|
instance.save(update_fields=['equipped_trinket', 'equipped_deck'])
|
||||||
if earthman:
|
if earthman:
|
||||||
instance.unlocked_decks.add(earthman)
|
instance.unlocked_decks.add(earthman)
|
||||||
|
if instance.is_superuser:
|
||||||
|
from apps.drama.models import Note
|
||||||
|
Note.grant_if_new(instance, "super-schizo")
|
||||||
|
Note.grant_if_new(instance, "super-nomad")
|
||||||
|
|||||||
@@ -144,6 +144,27 @@ class SuperuserTokenCreationTest(TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SuperuserNoteGrantTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_superuser(
|
||||||
|
email="admin@test.io", password="secret"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_superuser_gets_super_schizo_note(self):
|
||||||
|
from apps.drama.models import Note
|
||||||
|
self.assertTrue(Note.objects.filter(user=self.user, slug="super-schizo").exists())
|
||||||
|
|
||||||
|
def test_superuser_gets_super_nomad_note(self):
|
||||||
|
from apps.drama.models import Note
|
||||||
|
self.assertTrue(Note.objects.filter(user=self.user, slug="super-nomad").exists())
|
||||||
|
|
||||||
|
def test_regular_user_does_not_get_super_notes(self):
|
||||||
|
from apps.drama.models import Note
|
||||||
|
regular = User.objects.create(email="regular@test.io")
|
||||||
|
self.assertFalse(Note.objects.filter(user=regular, slug="super-schizo").exists())
|
||||||
|
self.assertFalse(Note.objects.filter(user=regular, slug="super-nomad").exists())
|
||||||
|
|
||||||
|
|
||||||
class WalletTooltipTest(TestCase):
|
class WalletTooltipTest(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.user = User.objects.create(email="wallet@test.io")
|
self.user = User.objects.create(email="wallet@test.io")
|
||||||
|
|||||||
@@ -2,8 +2,13 @@
|
|||||||
Management command for manual multi-user sig-select testing.
|
Management command for manual multi-user sig-select testing.
|
||||||
|
|
||||||
Creates (or reuses) a room with all 6 gate slots filled, roles assigned,
|
Creates (or reuses) a room with all 6 gate slots filled, roles assigned,
|
||||||
and table_status=SIG_SELECT. Prints one pre-auth URL per gamer so you can
|
deck contributions wired, and table_status=SIG_SELECT. Prints one pre-auth
|
||||||
paste them into 6 Firefox Multi-Account Container tabs.
|
URL per gamer so you can paste them into 6 Firefox Multi-Account Container tabs.
|
||||||
|
|
||||||
|
Deck contribution by role pair (same segment, levity vs gravity pole):
|
||||||
|
PC & BC → Brands + Crowns (levity / gravity)
|
||||||
|
SC & AC → Blades + Grails (levity / gravity)
|
||||||
|
NC & EC → Trumps (levity / gravity)
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python src/manage.py setup_sig_session
|
python src/manage.py setup_sig_session
|
||||||
@@ -15,7 +20,8 @@ from django.contrib.auth import BACKEND_SESSION_KEY, HASH_SESSION_KEY, SESSION_K
|
|||||||
from django.contrib.sessions.backends.db import SessionStore
|
from django.contrib.sessions.backends.db import SessionStore
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
from apps.epic.models import DeckVariant, GateSlot, Room, TableSeat, TarotCard
|
from apps.drama.models import Note
|
||||||
|
from apps.epic.models import DeckVariant, GateSlot, Room, TableSeat
|
||||||
from apps.lyric.models import User
|
from apps.lyric.models import User
|
||||||
|
|
||||||
|
|
||||||
@@ -31,28 +37,6 @@ GAMERS = [
|
|||||||
ROLES = ["PC", "NC", "EC", "SC", "AC", "BC"]
|
ROLES = ["PC", "NC", "EC", "SC", "AC", "BC"]
|
||||||
|
|
||||||
|
|
||||||
def _ensure_earthman():
|
|
||||||
"""Return (or create) the Earthman DeckVariant with enough sig-deck cards seeded."""
|
|
||||||
earthman, _ = DeckVariant.objects.get_or_create(
|
|
||||||
slug="earthman",
|
|
||||||
defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True},
|
|
||||||
)
|
|
||||||
_NAME = {11: "Maid", 12: "Jack", 13: "Queen", 14: "King"}
|
|
||||||
for suit in ("WANDS", "PENTACLES", "SWORDS", "CUPS"):
|
|
||||||
for number in (11, 12, 13, 14):
|
|
||||||
TarotCard.objects.get_or_create(
|
|
||||||
deck_variant=earthman,
|
|
||||||
slug=f"{_NAME[number].lower()}-of-{suit.lower()}-em",
|
|
||||||
defaults={
|
|
||||||
"arcana": "MINOR",
|
|
||||||
"suit": suit,
|
|
||||||
"number": number,
|
|
||||||
"name": f"{_NAME[number]} of {suit.capitalize()}",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return earthman
|
|
||||||
|
|
||||||
|
|
||||||
def _make_session(user):
|
def _make_session(user):
|
||||||
session = SessionStore()
|
session = SessionStore()
|
||||||
session[SESSION_KEY] = str(user.pk)
|
session[SESSION_KEY] = str(user.pk)
|
||||||
@@ -71,7 +55,7 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
base_url = options["base_url"].rstrip("/")
|
base_url = options["base_url"].rstrip("/")
|
||||||
earthman = _ensure_earthman()
|
earthman = DeckVariant.objects.get(slug="earthman")
|
||||||
|
|
||||||
# ── Users ────────────────────────────────────────────────────────────
|
# ── Users ────────────────────────────────────────────────────────────
|
||||||
users = []
|
users = []
|
||||||
@@ -79,9 +63,13 @@ class Command(BaseCommand):
|
|||||||
user, _ = User.objects.get_or_create(email=email)
|
user, _ = User.objects.get_or_create(email=email)
|
||||||
user.is_staff = True
|
user.is_staff = True
|
||||||
user.is_superuser = True
|
user.is_superuser = True
|
||||||
if not user.equipped_deck:
|
# Deck will be assigned to seat below; ensure it's in unlocked_decks
|
||||||
user.equipped_deck = earthman
|
# but leave equipped_deck=None (seat assignment owns it)
|
||||||
|
user.equipped_deck = None
|
||||||
user.save()
|
user.save()
|
||||||
|
user.unlocked_decks.add(earthman)
|
||||||
|
Note.grant_if_new(user, "super-schizo")
|
||||||
|
Note.grant_if_new(user, "super-nomad")
|
||||||
users.append(user)
|
users.append(user)
|
||||||
|
|
||||||
# ── Room ─────────────────────────────────────────────────────────────
|
# ── Room ─────────────────────────────────────────────────────────────
|
||||||
@@ -104,11 +92,21 @@ class Command(BaseCommand):
|
|||||||
room.gate_status = Room.OPEN
|
room.gate_status = Room.OPEN
|
||||||
room.save()
|
room.save()
|
||||||
|
|
||||||
# ── Table seats + roles ──────────────────────────────────────────────
|
# ── Table seats + roles + deck contributions ─────────────────────────
|
||||||
|
# PC/NC/SC → levity pole; BC/EC/AC → gravity pole.
|
||||||
|
# Each role pair shares a deck segment:
|
||||||
|
# PC & BC → Brands + Crowns
|
||||||
|
# SC & AC → Blades + Grails
|
||||||
|
# NC & EC → Trumps
|
||||||
for i, (user, role) in enumerate(zip(users, ROLES), start=1):
|
for i, (user, role) in enumerate(zip(users, ROLES), start=1):
|
||||||
TableSeat.objects.update_or_create(
|
TableSeat.objects.update_or_create(
|
||||||
room=room, slot_number=i,
|
room=room, slot_number=i,
|
||||||
defaults={"gamer": user, "role": role, "role_revealed": True},
|
defaults={
|
||||||
|
"gamer": user,
|
||||||
|
"role": role,
|
||||||
|
"role_revealed": True,
|
||||||
|
"deck_variant": earthman,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
room.table_status = Room.SIG_SELECT
|
room.table_status = Room.SIG_SELECT
|
||||||
|
|||||||
@@ -431,6 +431,8 @@ class NoteEquipTitleTest(FunctionalTest):
|
|||||||
note_item = self.wait_for(
|
note_item = self.wait_for(
|
||||||
lambda: self.browser.find_element(By.CSS_SELECTOR, ".note-item")
|
lambda: self.browser.find_element(By.CSS_SELECTOR, ".note-item")
|
||||||
)
|
)
|
||||||
|
# Click the note to lock it — makes DON/DOFF opacity:1 and interactable
|
||||||
|
note_item.click()
|
||||||
don_btn = note_item.find_element(By.CSS_SELECTOR, ".btn-equip")
|
don_btn = note_item.find_element(By.CSS_SELECTOR, ".btn-equip")
|
||||||
doff_btn = note_item.find_element(By.CSS_SELECTOR, ".btn-unequip")
|
doff_btn = note_item.find_element(By.CSS_SELECTOR, ".btn-unequip")
|
||||||
|
|
||||||
|
|||||||
216
src/functional_tests/test_deck_contribution.py
Normal file
216
src/functional_tests/test_deck_contribution.py
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
"""
|
||||||
|
Functional tests: deck-to-seat contribution mechanics.
|
||||||
|
|
||||||
|
Sprint 1 (DeckContributionTest):
|
||||||
|
Confirming a role in ROLE SELECT assigns the gamer's equipped deck to the
|
||||||
|
TableSeat. The Game Kit immediately reflects the in-use state: the deck
|
||||||
|
card gains the game name in its tooltip and the micro-status reads "In-Use".
|
||||||
|
|
||||||
|
Sprint 2 (DeckInUseGameKitTest):
|
||||||
|
With a deck already assigned to an active seat the Game Kit DON button is
|
||||||
|
btn-disabled and no DOFF toggle is shown (unlike the normal equipped state
|
||||||
|
where DOFF is visible). Clicking the card shows a tooltip naming the game.
|
||||||
|
A second deck that is NOT assigned to any active seat has normal DON/DOFF
|
||||||
|
behaviour.
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
|
||||||
|
from django.test import tag
|
||||||
|
from selenium.webdriver.common.by import By
|
||||||
|
|
||||||
|
from apps.applets.models import Applet
|
||||||
|
from apps.epic.models import DeckVariant, GateSlot, Room, TableSeat
|
||||||
|
from apps.lyric.models import User
|
||||||
|
from functional_tests.base import FunctionalTest
|
||||||
|
from functional_tests.test_room_role_select import _fill_room_via_orm
|
||||||
|
from functional_tests.test_room_sig_select import _assign_all_roles
|
||||||
|
|
||||||
|
FOUNDER_EMAIL = "founder@test.io"
|
||||||
|
GAMER_EMAIL = "gamer@test.io"
|
||||||
|
|
||||||
|
|
||||||
|
def _equip_earthman(user):
|
||||||
|
earthman = DeckVariant.objects.get(slug="earthman")
|
||||||
|
user.equipped_deck = earthman
|
||||||
|
user.save(update_fields=["equipped_deck"])
|
||||||
|
return earthman
|
||||||
|
|
||||||
|
|
||||||
|
def _room_at_role_select(name="Wildfire"):
|
||||||
|
"""Create a room filled to 6 and at ROLE_SELECT table status."""
|
||||||
|
founder, _ = User.objects.get_or_create(email=FOUNDER_EMAIL)
|
||||||
|
room = Room.objects.create(name=name, owner=founder)
|
||||||
|
emails = [FOUNDER_EMAIL, GAMER_EMAIL,
|
||||||
|
"b@test.io", "c@test.io", "d@test.io", "e@test.io"]
|
||||||
|
_fill_room_via_orm(room, emails)
|
||||||
|
for slot in room.gate_slots.all():
|
||||||
|
if slot.gamer:
|
||||||
|
_equip_earthman(slot.gamer)
|
||||||
|
room.table_status = Room.ROLE_SELECT
|
||||||
|
room.save()
|
||||||
|
return room, founder
|
||||||
|
|
||||||
|
|
||||||
|
# ── Sprint 1 ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class DeckContributionTest(FunctionalTest):
|
||||||
|
"""Sprint 1: role confirmation → TableSeat.deck_variant set; Game Kit shows In-Use."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.browser.set_window_size(800, 1200)
|
||||||
|
for slug, name, ctx in [
|
||||||
|
("game-kit", "Game Kit", "gameboard"),
|
||||||
|
("gk-decks", "Card Decks","game-kit"),
|
||||||
|
]:
|
||||||
|
Applet.objects.get_or_create(slug=slug, defaults={"name": name, "context": ctx})
|
||||||
|
|
||||||
|
def test_confirming_role_assigns_equipped_deck_to_seat(self):
|
||||||
|
"""After the gamer confirms a role, Game Kit shows the Earthman deck as 'In-Use'
|
||||||
|
with the game name in the deck tooltip, and the micro-status changes from
|
||||||
|
'Equipped' to 'In-Use'.
|
||||||
|
"""
|
||||||
|
room, founder = _room_at_role_select()
|
||||||
|
gamer = User.objects.get(email=GAMER_EMAIL)
|
||||||
|
|
||||||
|
# Gamer logs in and navigates to the role-select room
|
||||||
|
session_key = self.create_pre_authenticated_session(GAMER_EMAIL)
|
||||||
|
self.browser.get(self.live_server_url)
|
||||||
|
self.browser.add_cookie({"name": "sessionid", "value": session_key})
|
||||||
|
self.browser.get(self.live_server_url + f"/gameboard/room/{room.pk}/")
|
||||||
|
|
||||||
|
# Gamer confirms a role (NC — slot 2 is theirs)
|
||||||
|
role_btn = self.wait_for(
|
||||||
|
lambda: self.browser.find_element(By.CSS_SELECTOR, ".role-card[data-role='NC'] .btn-confirm")
|
||||||
|
)
|
||||||
|
role_btn.click()
|
||||||
|
|
||||||
|
# Deck is now assigned to the seat in the DB
|
||||||
|
self.wait_for(lambda: self.assertTrue(
|
||||||
|
TableSeat.objects.filter(
|
||||||
|
gamer=gamer,
|
||||||
|
room=room,
|
||||||
|
deck_variant=gamer.equipped_deck,
|
||||||
|
).exists(),
|
||||||
|
"TableSeat.deck_variant was not set after role confirmation",
|
||||||
|
))
|
||||||
|
|
||||||
|
# Navigate to Game Kit → Card Decks to verify UI state
|
||||||
|
self.browser.get(self.live_server_url + "/gameboard/")
|
||||||
|
decks_btn = self.wait_for(
|
||||||
|
lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_kit_card_deck")
|
||||||
|
)
|
||||||
|
decks_btn.click()
|
||||||
|
|
||||||
|
earthman_card = self.wait_for(
|
||||||
|
lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_kit_earthman_deck")
|
||||||
|
)
|
||||||
|
earthman_card.click() # open tooltip
|
||||||
|
|
||||||
|
# Tooltip shows the game name
|
||||||
|
tooltip = self.wait_for(
|
||||||
|
lambda: self.browser.find_element(By.CSS_SELECTOR, ".tt-deck-game-name")
|
||||||
|
)
|
||||||
|
self.assertIn(room.name.upper(), tooltip.text.upper())
|
||||||
|
|
||||||
|
# Micro-status reads "In-Use", not "Equipped"
|
||||||
|
micro_status = self.wait_for(
|
||||||
|
lambda: self.browser.find_element(
|
||||||
|
By.CSS_SELECTOR, "#id_kit_earthman_deck .deck-micro-status"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertIn("IN-USE", micro_status.text.upper())
|
||||||
|
self.assertNotIn("EQUIPPED", micro_status.text.upper())
|
||||||
|
|
||||||
|
|
||||||
|
# ── Sprint 2 ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class DeckInUseGameKitTest(FunctionalTest):
|
||||||
|
"""Sprint 2: DON disabled + no DOFF toggle for in-use deck; second deck unaffected."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.browser.set_window_size(800, 1200)
|
||||||
|
for slug, name, ctx in [
|
||||||
|
("game-kit", "Game Kit", "gameboard"),
|
||||||
|
("gk-decks", "Card Decks","game-kit"),
|
||||||
|
]:
|
||||||
|
Applet.objects.get_or_create(slug=slug, defaults={"name": name, "context": ctx})
|
||||||
|
|
||||||
|
def _setup_in_use_deck(self):
|
||||||
|
"""Create a seated gamer whose Earthman deck is already assigned to a seat."""
|
||||||
|
room, founder = _room_at_role_select()
|
||||||
|
gamer = User.objects.get(email=GAMER_EMAIL)
|
||||||
|
earthman = _equip_earthman(gamer)
|
||||||
|
# Assign deck directly (Sprint 1 must be green first)
|
||||||
|
seat = TableSeat.objects.create(gamer=gamer, room=room, role="NC", deck_variant=earthman)
|
||||||
|
return gamer, earthman, room, seat
|
||||||
|
|
||||||
|
def test_don_is_disabled_and_doff_absent_for_in_use_deck(self):
|
||||||
|
"""DON button carries btn-disabled; DOFF is not rendered at all (not just disabled)."""
|
||||||
|
gamer, earthman, room, seat = self._setup_in_use_deck()
|
||||||
|
session_key = self.create_pre_authenticated_session(GAMER_EMAIL)
|
||||||
|
self.browser.get(self.live_server_url)
|
||||||
|
self.browser.add_cookie({"name": "sessionid", "value": session_key})
|
||||||
|
self.browser.get(self.live_server_url + "/gameboard/")
|
||||||
|
self.wait_for(
|
||||||
|
lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_kit_card_deck")
|
||||||
|
).click()
|
||||||
|
self.wait_for(
|
||||||
|
lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_kit_earthman_deck")
|
||||||
|
).click()
|
||||||
|
|
||||||
|
# DON is disabled
|
||||||
|
don_btn = self.wait_for(
|
||||||
|
lambda: self.browser.find_element(
|
||||||
|
By.CSS_SELECTOR, f"#id_kit_earthman_deck .btn-equip"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertIn("btn-disabled", don_btn.get_attribute("class"))
|
||||||
|
|
||||||
|
# DOFF button is not present (not just disabled — entirely absent)
|
||||||
|
doff_btns = self.browser.find_elements(
|
||||||
|
By.CSS_SELECTOR, f"#id_kit_earthman_deck .btn-unequip:not(.btn-disabled)"
|
||||||
|
)
|
||||||
|
self.assertEqual(len(doff_btns), 0, "DOFF button should not be shown for an in-use deck")
|
||||||
|
|
||||||
|
def test_tooltip_names_the_game_for_in_use_deck(self):
|
||||||
|
"""Opening an in-use deck's tooltip shows the room name it is contributing to."""
|
||||||
|
gamer, earthman, room, seat = self._setup_in_use_deck()
|
||||||
|
session_key = self.create_pre_authenticated_session(GAMER_EMAIL)
|
||||||
|
self.browser.get(self.live_server_url)
|
||||||
|
self.browser.add_cookie({"name": "sessionid", "value": session_key})
|
||||||
|
self.browser.get(self.live_server_url + "/gameboard/")
|
||||||
|
self.wait_for(
|
||||||
|
lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_kit_card_deck")
|
||||||
|
).click()
|
||||||
|
self.wait_for(
|
||||||
|
lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_kit_earthman_deck")
|
||||||
|
).click()
|
||||||
|
game_label = self.wait_for(
|
||||||
|
lambda: self.browser.find_element(By.CSS_SELECTOR, ".tt-deck-game-name")
|
||||||
|
)
|
||||||
|
self.assertIn(room.name.upper(), game_label.text.upper())
|
||||||
|
|
||||||
|
def test_non_contributing_deck_has_normal_don_doff(self):
|
||||||
|
"""A deck not assigned to any active seat shows the normal DON/DOFF apparatus."""
|
||||||
|
gamer, earthman, room, seat = self._setup_in_use_deck()
|
||||||
|
# Unlock Fiorentine for the gamer so it appears in Game Kit
|
||||||
|
fiorentine = DeckVariant.objects.get(slug="fiorentine-minchiate")
|
||||||
|
gamer.unlocked_decks.add(fiorentine)
|
||||||
|
session_key = self.create_pre_authenticated_session(GAMER_EMAIL)
|
||||||
|
self.browser.get(self.live_server_url)
|
||||||
|
self.browser.add_cookie({"name": "sessionid", "value": session_key})
|
||||||
|
self.browser.get(self.live_server_url + "/gameboard/")
|
||||||
|
self.wait_for(
|
||||||
|
lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_kit_card_deck")
|
||||||
|
).click()
|
||||||
|
self.wait_for(
|
||||||
|
lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_kit_fiorentine_deck")
|
||||||
|
).click()
|
||||||
|
don_btn = self.wait_for(
|
||||||
|
lambda: self.browser.find_element(
|
||||||
|
By.CSS_SELECTOR, "#id_kit_fiorentine_deck .btn-equip"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertNotIn("btn-disabled", don_btn.get_attribute("class"))
|
||||||
354
src/functional_tests/test_game_invite.py
Normal file
354
src/functional_tests/test_game_invite.py
Normal file
@@ -0,0 +1,354 @@
|
|||||||
|
"""
|
||||||
|
Functional tests: game-room invite flow via BillPost.
|
||||||
|
|
||||||
|
BillPost is a new billboard.BillPost model (kind=INVITE for this sprint;
|
||||||
|
more kinds to follow in later feature sprints). It is the first of a series
|
||||||
|
of Post-types that will grow through coming sprints. The existing
|
||||||
|
dashboard.Post model will be renamed (to Message or similar) when the WS
|
||||||
|
messaging sprint lands; this new system's naming takes precedence.
|
||||||
|
|
||||||
|
Sprint 3 (GameInviteNotificationTest) — @two-browser:
|
||||||
|
Founder sends an email invite from the room page. invite_gamer() creates a
|
||||||
|
BillPost(kind=INVITE) for the invitee. Invitee sees "INVITE: <room>" as a
|
||||||
|
link in the My Posts applet on the billboard page.
|
||||||
|
|
||||||
|
Sprint 4 (GameInviteBillPostTest):
|
||||||
|
Clicking the invite post navigates to /billboard/post/<pk>/. The page
|
||||||
|
renders a _billpost_invite.html partial with room info. OK and BYE
|
||||||
|
(.btn-confirm / .btn-abandon) appear next to the title.
|
||||||
|
BYE marks the BillPost dismissed; the post is no longer listed in My Posts.
|
||||||
|
OK shows a Log-Out-style confirm guard ("Join game? OK / NVM") if the
|
||||||
|
invitee has a valid (non-contributing) deck equipped.
|
||||||
|
|
||||||
|
Sprint 5 (GameInviteDeckValidationTest):
|
||||||
|
If the invitee has no deck equipped, or their equipped deck is already
|
||||||
|
assigned to an active seat, the OK button is immediately btn-disabled
|
||||||
|
(no guard shown) and a tooltip explains the situation.
|
||||||
|
When a valid deck is equipped and the invitee confirms, they are redirected
|
||||||
|
to the room and their deck is assigned to a seat.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
from django.test import tag
|
||||||
|
from selenium import webdriver
|
||||||
|
from selenium.webdriver.common.by import By
|
||||||
|
|
||||||
|
from apps.applets.models import Applet
|
||||||
|
from apps.billboard.models import BillPost
|
||||||
|
from apps.epic.models import DeckVariant, GateSlot, Room, RoomInvite, TableSeat
|
||||||
|
from apps.lyric.models import User
|
||||||
|
from functional_tests.base import FunctionalTest
|
||||||
|
from functional_tests.test_room_role_select import _fill_room_via_orm
|
||||||
|
|
||||||
|
FOUNDER_EMAIL = "founder@test.io"
|
||||||
|
INVITEE_EMAIL = "invitee@test.io"
|
||||||
|
|
||||||
|
|
||||||
|
def _applets():
|
||||||
|
for slug, name, ctx in [
|
||||||
|
("my-posts", "My Posts", "billboard"),
|
||||||
|
("billboard-my-scrolls", "My Scrolls","billboard"),
|
||||||
|
]:
|
||||||
|
Applet.objects.get_or_create(slug=slug, defaults={"name": name, "context": ctx})
|
||||||
|
|
||||||
|
|
||||||
|
def _make_open_room(name="Wildfire"):
|
||||||
|
founder, _ = User.objects.get_or_create(email=FOUNDER_EMAIL)
|
||||||
|
room = Room.objects.create(name=name, owner=founder)
|
||||||
|
_fill_room_via_orm(room, [FOUNDER_EMAIL,
|
||||||
|
"b@test.io", "c@test.io", "d@test.io", "e@test.io", "f@test.io"])
|
||||||
|
return room, founder
|
||||||
|
|
||||||
|
|
||||||
|
def _make_second_browser():
|
||||||
|
opts = webdriver.FirefoxOptions()
|
||||||
|
if os.environ.get("HEADLESS"):
|
||||||
|
opts.add_argument("--headless")
|
||||||
|
b = webdriver.Firefox(options=opts)
|
||||||
|
b.set_window_size(800, 1200)
|
||||||
|
return b
|
||||||
|
|
||||||
|
|
||||||
|
# ── Sprint 3 ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@tag("two-browser")
|
||||||
|
class GameInviteNotificationTest(FunctionalTest):
|
||||||
|
"""Sprint 3: founder sends invite → INVITE: BillPost appears in invitee's My Posts."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.browser.set_window_size(800, 1200)
|
||||||
|
self.second_browser = _make_second_browser()
|
||||||
|
_applets()
|
||||||
|
User.objects.get_or_create(email=INVITEE_EMAIL)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.second_browser.quit()
|
||||||
|
super().tearDown()
|
||||||
|
|
||||||
|
def test_invite_creates_bill_post_in_my_posts(self):
|
||||||
|
"""Founder sends email invite; invitee sees 'INVITE: Wildfire' link in My Posts."""
|
||||||
|
room, founder = _make_open_room()
|
||||||
|
|
||||||
|
# Founder: log in, navigate to room, send invite
|
||||||
|
founder_session = self.create_pre_authenticated_session(FOUNDER_EMAIL)
|
||||||
|
self.browser.get(self.live_server_url)
|
||||||
|
self.browser.add_cookie({"name": "sessionid", "value": founder_session})
|
||||||
|
self.browser.get(self.live_server_url + f"/gameboard/room/{room.pk}/")
|
||||||
|
|
||||||
|
invite_input = self.wait_for(
|
||||||
|
lambda: self.browser.find_element(By.CSS_SELECTOR, "input[name='invitee_email']")
|
||||||
|
)
|
||||||
|
invite_input.send_keys(INVITEE_EMAIL)
|
||||||
|
self.browser.find_element(By.CSS_SELECTOR, "#id_invite_btn").click()
|
||||||
|
|
||||||
|
# A BillPost(kind=INVITE) is created for the invitee
|
||||||
|
self.wait_for(lambda: self.assertTrue(
|
||||||
|
BillPost.objects.filter(
|
||||||
|
kind=BillPost.INVITE,
|
||||||
|
recipient__email=INVITEE_EMAIL,
|
||||||
|
room=room,
|
||||||
|
dismissed=False,
|
||||||
|
).exists(),
|
||||||
|
"BillPost(kind=INVITE) was not created for the invitee",
|
||||||
|
))
|
||||||
|
|
||||||
|
# Invitee: log in to billboard, see "INVITE: Wildfire" in My Posts applet
|
||||||
|
invitee = User.objects.get(email=INVITEE_EMAIL)
|
||||||
|
invitee_session = self.create_pre_authenticated_session(INVITEE_EMAIL)
|
||||||
|
self.second_browser.get(self.live_server_url)
|
||||||
|
self.second_browser.add_cookie({"name": "sessionid", "value": invitee_session})
|
||||||
|
self.second_browser.get(self.live_server_url + "/billboard/")
|
||||||
|
|
||||||
|
my_posts = self.wait_for(
|
||||||
|
lambda: self.second_browser.find_element(By.CSS_SELECTOR, "#id_applet_my_posts")
|
||||||
|
)
|
||||||
|
invite_link = self.wait_for(
|
||||||
|
lambda: my_posts.find_element(By.PARTIAL_LINK_TEXT, "INVITE:")
|
||||||
|
)
|
||||||
|
self.assertIn("WILDFIRE", invite_link.text.upper())
|
||||||
|
|
||||||
|
# Link goes to /billboard/post/<pk>/
|
||||||
|
post = BillPost.objects.get(kind=BillPost.INVITE, recipient__email=INVITEE_EMAIL)
|
||||||
|
self.assertIn(f"/billboard/post/{post.pk}/", invite_link.get_attribute("href"))
|
||||||
|
|
||||||
|
|
||||||
|
# ── Sprint 4 ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class GameInviteBillPostTest(FunctionalTest):
|
||||||
|
"""Sprint 4: BillPost invite page — _billpost_invite.html partial, OK/BYE, BYE dismisses."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.browser.set_window_size(800, 1200)
|
||||||
|
_applets()
|
||||||
|
|
||||||
|
def _make_invite_post(self, equip_deck=False):
|
||||||
|
room, founder = _make_open_room()
|
||||||
|
invitee, _ = User.objects.get_or_create(email=INVITEE_EMAIL)
|
||||||
|
invite = RoomInvite.objects.create(
|
||||||
|
room=room, inviter=founder, invitee_email=INVITEE_EMAIL,
|
||||||
|
status=RoomInvite.PENDING,
|
||||||
|
)
|
||||||
|
post = BillPost.objects.create(
|
||||||
|
kind=BillPost.INVITE, recipient=invitee, room=room, invite=invite,
|
||||||
|
)
|
||||||
|
if equip_deck:
|
||||||
|
earthman = DeckVariant.objects.get(slug="earthman")
|
||||||
|
invitee.equipped_deck = earthman
|
||||||
|
invitee.unlocked_decks.add(earthman)
|
||||||
|
invitee.save(update_fields=["equipped_deck"])
|
||||||
|
return invitee, post, room
|
||||||
|
|
||||||
|
def test_billpost_page_shows_invite_partial(self):
|
||||||
|
"""The BillPost page at /billboard/post/<pk>/ renders the invite partial with room info."""
|
||||||
|
invitee, post, room = self._make_invite_post()
|
||||||
|
session_key = self.create_pre_authenticated_session(INVITEE_EMAIL)
|
||||||
|
self.browser.get(self.live_server_url)
|
||||||
|
self.browser.add_cookie({"name": "sessionid", "value": session_key})
|
||||||
|
self.browser.get(self.live_server_url + f"/billboard/post/{post.pk}/")
|
||||||
|
|
||||||
|
# Page title / heading shows "INVITE: Wildfire"
|
||||||
|
heading = self.wait_for(
|
||||||
|
lambda: self.browser.find_element(By.CSS_SELECTOR, ".billpost-title")
|
||||||
|
)
|
||||||
|
self.assertIn("INVITE", heading.text.upper())
|
||||||
|
self.assertIn(room.name.upper(), heading.text.upper())
|
||||||
|
|
||||||
|
# Invite partial is present
|
||||||
|
self.wait_for(
|
||||||
|
lambda: self.browser.find_element(By.CSS_SELECTOR, ".billpost-invite")
|
||||||
|
)
|
||||||
|
|
||||||
|
# OK and BYE buttons are present
|
||||||
|
self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".btn-confirm"))
|
||||||
|
self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".btn-abandon"))
|
||||||
|
|
||||||
|
def test_bye_dismisses_invite_and_removes_from_my_posts(self):
|
||||||
|
"""Clicking BYE marks the BillPost dismissed and it no longer appears in My Posts."""
|
||||||
|
invitee, post, room = self._make_invite_post()
|
||||||
|
session_key = self.create_pre_authenticated_session(INVITEE_EMAIL)
|
||||||
|
self.browser.get(self.live_server_url)
|
||||||
|
self.browser.add_cookie({"name": "sessionid", "value": session_key})
|
||||||
|
self.browser.get(self.live_server_url + f"/billboard/post/{post.pk}/")
|
||||||
|
|
||||||
|
bye_btn = self.wait_for(
|
||||||
|
lambda: self.browser.find_element(By.CSS_SELECTOR, ".btn-abandon")
|
||||||
|
)
|
||||||
|
bye_btn.click()
|
||||||
|
|
||||||
|
# BillPost is now dismissed in DB
|
||||||
|
post.refresh_from_db()
|
||||||
|
self.assertTrue(post.dismissed)
|
||||||
|
|
||||||
|
# Redirected to billboard; post no longer in My Posts
|
||||||
|
self.wait_for(lambda: self.assertIn("/billboard/", self.browser.current_url))
|
||||||
|
my_posts = self.wait_for(
|
||||||
|
lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_applet_my_posts")
|
||||||
|
)
|
||||||
|
invite_links = my_posts.find_elements(By.PARTIAL_LINK_TEXT, "INVITE:")
|
||||||
|
self.assertEqual(len(invite_links), 0, "Dismissed invite should not appear in My Posts")
|
||||||
|
|
||||||
|
def test_ok_with_valid_deck_shows_confirm_guard(self):
|
||||||
|
"""With a valid deck equipped, OK shows a Log-Out-style confirm guard."""
|
||||||
|
invitee, post, room = self._make_invite_post(equip_deck=True)
|
||||||
|
session_key = self.create_pre_authenticated_session(INVITEE_EMAIL)
|
||||||
|
self.browser.get(self.live_server_url)
|
||||||
|
self.browser.add_cookie({"name": "sessionid", "value": session_key})
|
||||||
|
self.browser.get(self.live_server_url + f"/billboard/post/{post.pk}/")
|
||||||
|
|
||||||
|
ok_btn = self.wait_for(
|
||||||
|
lambda: self.browser.find_element(By.CSS_SELECTOR, ".btn-confirm")
|
||||||
|
)
|
||||||
|
self.assertNotIn("btn-disabled", ok_btn.get_attribute("class"))
|
||||||
|
ok_btn.click()
|
||||||
|
|
||||||
|
# Confirm guard appears ("Join game?" with OK / NVM)
|
||||||
|
guard = self.wait_for(
|
||||||
|
lambda: self.browser.find_element(By.CSS_SELECTOR, ".join-guard")
|
||||||
|
)
|
||||||
|
self.assertIn("JOIN", guard.text.upper())
|
||||||
|
self.wait_for(lambda: guard.find_element(By.CSS_SELECTOR, ".btn-confirm"))
|
||||||
|
self.wait_for(lambda: guard.find_element(By.CSS_SELECTOR, ".btn-nvm"))
|
||||||
|
|
||||||
|
|
||||||
|
# ── Sprint 5 ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class GameInviteDeckValidationTest(FunctionalTest):
|
||||||
|
"""Sprint 5: OK deck validation — disabled without valid deck; on join, deck locked."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.browser.set_window_size(800, 1200)
|
||||||
|
_applets()
|
||||||
|
for slug, name, ctx in [
|
||||||
|
("game-kit", "Game Kit", "gameboard"),
|
||||||
|
("gk-decks", "Card Decks","game-kit"),
|
||||||
|
]:
|
||||||
|
Applet.objects.get_or_create(slug=slug, defaults={"name": name, "context": ctx})
|
||||||
|
|
||||||
|
def _make_invite_post(self, equip_deck=False, deck_in_use=False):
|
||||||
|
room, founder = _make_open_room()
|
||||||
|
invitee, _ = User.objects.get_or_create(email=INVITEE_EMAIL)
|
||||||
|
invite = RoomInvite.objects.create(
|
||||||
|
room=room, inviter=founder, invitee_email=INVITEE_EMAIL,
|
||||||
|
status=RoomInvite.PENDING,
|
||||||
|
)
|
||||||
|
post = BillPost.objects.create(
|
||||||
|
kind=BillPost.INVITE, recipient=invitee, room=room, invite=invite,
|
||||||
|
)
|
||||||
|
earthman = DeckVariant.objects.get(slug="earthman")
|
||||||
|
if equip_deck or deck_in_use:
|
||||||
|
invitee.equipped_deck = earthman
|
||||||
|
invitee.unlocked_decks.add(earthman)
|
||||||
|
invitee.save(update_fields=["equipped_deck"])
|
||||||
|
if deck_in_use:
|
||||||
|
# Simulate the deck already being assigned to another active seat
|
||||||
|
other_room = Room.objects.create(name="Other Game", owner=invitee)
|
||||||
|
TableSeat.objects.create(
|
||||||
|
gamer=invitee, room=other_room, role="PC", deck_variant=earthman,
|
||||||
|
)
|
||||||
|
return invitee, post, room, earthman
|
||||||
|
|
||||||
|
def test_ok_immediately_disabled_without_equipped_deck(self):
|
||||||
|
"""OK is btn-disabled on load when invitee has no deck equipped."""
|
||||||
|
invitee, post, room, _ = self._make_invite_post(equip_deck=False)
|
||||||
|
session_key = self.create_pre_authenticated_session(INVITEE_EMAIL)
|
||||||
|
self.browser.get(self.live_server_url)
|
||||||
|
self.browser.add_cookie({"name": "sessionid", "value": session_key})
|
||||||
|
self.browser.get(self.live_server_url + f"/billboard/post/{post.pk}/")
|
||||||
|
|
||||||
|
ok_btn = self.wait_for(
|
||||||
|
lambda: self.browser.find_element(By.CSS_SELECTOR, ".btn-confirm")
|
||||||
|
)
|
||||||
|
self.assertIn("btn-disabled", ok_btn.get_attribute("class"),
|
||||||
|
"OK should be disabled when invitee has no deck equipped")
|
||||||
|
|
||||||
|
# Tooltip explains the situation
|
||||||
|
ok_btn.click()
|
||||||
|
tooltip = self.wait_for(
|
||||||
|
lambda: self.browser.find_element(By.CSS_SELECTOR, ".invite-no-deck-tooltip")
|
||||||
|
)
|
||||||
|
self.assertTrue(tooltip.is_displayed())
|
||||||
|
|
||||||
|
def test_ok_immediately_disabled_when_deck_in_use(self):
|
||||||
|
"""OK is btn-disabled when invitee's equipped deck is already assigned to another game."""
|
||||||
|
invitee, post, room, _ = self._make_invite_post(deck_in_use=True)
|
||||||
|
session_key = self.create_pre_authenticated_session(INVITEE_EMAIL)
|
||||||
|
self.browser.get(self.live_server_url)
|
||||||
|
self.browser.add_cookie({"name": "sessionid", "value": session_key})
|
||||||
|
self.browser.get(self.live_server_url + f"/billboard/post/{post.pk}/")
|
||||||
|
|
||||||
|
ok_btn = self.wait_for(
|
||||||
|
lambda: self.browser.find_element(By.CSS_SELECTOR, ".btn-confirm")
|
||||||
|
)
|
||||||
|
self.assertIn("btn-disabled", ok_btn.get_attribute("class"),
|
||||||
|
"OK should be disabled when invitee's deck is already in use")
|
||||||
|
|
||||||
|
def test_confirming_join_assigns_deck_to_seat_and_locks_game_kit(self):
|
||||||
|
"""After confirming join, invitee's deck is assigned to their new seat and
|
||||||
|
the Game Kit DON button for that deck becomes btn-disabled."""
|
||||||
|
invitee, post, room, earthman = self._make_invite_post(equip_deck=True)
|
||||||
|
session_key = self.create_pre_authenticated_session(INVITEE_EMAIL)
|
||||||
|
self.browser.get(self.live_server_url)
|
||||||
|
self.browser.add_cookie({"name": "sessionid", "value": session_key})
|
||||||
|
self.browser.get(self.live_server_url + f"/billboard/post/{post.pk}/")
|
||||||
|
|
||||||
|
# OK → guard → confirm
|
||||||
|
ok_btn = self.wait_for(
|
||||||
|
lambda: self.browser.find_element(By.CSS_SELECTOR, ".btn-confirm")
|
||||||
|
)
|
||||||
|
ok_btn.click()
|
||||||
|
guard_ok = self.wait_for(
|
||||||
|
lambda: self.browser.find_element(By.CSS_SELECTOR, ".join-guard .btn-confirm")
|
||||||
|
)
|
||||||
|
guard_ok.click()
|
||||||
|
|
||||||
|
# Redirected into the room
|
||||||
|
self.wait_for(
|
||||||
|
lambda: self.assertIn(f"/gameboard/room/{room.pk}/", self.browser.current_url)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Invitee's seat has their deck assigned
|
||||||
|
self.wait_for(lambda: self.assertTrue(
|
||||||
|
TableSeat.objects.filter(
|
||||||
|
gamer=invitee, room=room, deck_variant=earthman,
|
||||||
|
).exists(),
|
||||||
|
"TableSeat.deck_variant was not set on join",
|
||||||
|
))
|
||||||
|
|
||||||
|
# Game Kit reflects in-use state
|
||||||
|
self.browser.get(self.live_server_url + "/gameboard/game-kit/")
|
||||||
|
self.wait_for(
|
||||||
|
lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_kit_card_deck")
|
||||||
|
).click()
|
||||||
|
self.wait_for(
|
||||||
|
lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_kit_earthman_deck")
|
||||||
|
).click()
|
||||||
|
don_btn = self.wait_for(
|
||||||
|
lambda: self.browser.find_element(
|
||||||
|
By.CSS_SELECTOR, "#id_kit_earthman_deck .btn-equip"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertIn("btn-disabled", don_btn.get_attribute("class"))
|
||||||
204
src/static/tests/NotePageSpec.js
Normal file
204
src/static/tests/NotePageSpec.js
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
function flushPromises() {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("NotePage", () => {
|
||||||
|
let testDiv, item1, item2, don1, doff1, don2, doff2;
|
||||||
|
|
||||||
|
function makeItem(slug, isEquipped) {
|
||||||
|
const li = document.createElement("li");
|
||||||
|
li.className = "note-item";
|
||||||
|
li.dataset.slug = slug;
|
||||||
|
li.dataset.donUrl = `/billboard/note/${slug}/don`;
|
||||||
|
li.dataset.doffUrl = `/billboard/note/${slug}/doff`;
|
||||||
|
li.innerHTML = `
|
||||||
|
<div class="note-don-doff">
|
||||||
|
<button class="btn btn-equip note-don-btn${isEquipped ? " btn-disabled" : ""}">${isEquipped ? "×" : "DON"}</button>
|
||||||
|
<button class="btn btn-unequip note-doff-btn${isEquipped ? "" : " btn-disabled"}">${isEquipped ? "DOFF" : "×"}</button>
|
||||||
|
</div>
|
||||||
|
<div class="note-item__body"><p class="note-item__title">${slug}</p></div>
|
||||||
|
`;
|
||||||
|
return li;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
NotePage._testReset();
|
||||||
|
testDiv = document.createElement("div");
|
||||||
|
testDiv.innerHTML = `<span id="id_greeting_prefix">Welcome,</span><span id="id_greeting_name">Earthman</span>`;
|
||||||
|
item1 = makeItem("super-schizo", false);
|
||||||
|
item2 = makeItem("super-nomad", false);
|
||||||
|
testDiv.appendChild(item1);
|
||||||
|
testDiv.appendChild(item2);
|
||||||
|
document.body.appendChild(testDiv);
|
||||||
|
|
||||||
|
window.fetch = jasmine.createSpy("fetch").and.returnValue(
|
||||||
|
Promise.resolve({ ok: true, json: () => Promise.resolve({ title: "Schizoid Man", greeting: "21st Century" }) })
|
||||||
|
);
|
||||||
|
|
||||||
|
don1 = item1.querySelector(".note-don-btn");
|
||||||
|
doff1 = item1.querySelector(".note-doff-btn");
|
||||||
|
don2 = item2.querySelector(".note-don-btn");
|
||||||
|
doff2 = item2.querySelector(".note-doff-btn");
|
||||||
|
|
||||||
|
NotePage._init();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
testDiv.remove();
|
||||||
|
document.body.classList.remove("notes-locked");
|
||||||
|
delete window.fetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Lock / unlock ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("click-lock behaviour", () => {
|
||||||
|
it("clicking a note adds note-item--locked", () => {
|
||||||
|
item1.click();
|
||||||
|
expect(item1.classList.contains("note-item--locked")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clicking a note adds notes-locked to body", () => {
|
||||||
|
item1.click();
|
||||||
|
expect(document.body.classList.contains("notes-locked")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clicking the same note again removes lock", () => {
|
||||||
|
item1.click();
|
||||||
|
item1.click();
|
||||||
|
expect(item1.classList.contains("note-item--locked")).toBe(false);
|
||||||
|
expect(document.body.classList.contains("notes-locked")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clicking a different note moves lock to that note", () => {
|
||||||
|
item1.click();
|
||||||
|
item2.click();
|
||||||
|
expect(item1.classList.contains("note-item--locked")).toBe(false);
|
||||||
|
expect(item2.classList.contains("note-item--locked")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("body click clears all locks", () => {
|
||||||
|
item1.click();
|
||||||
|
document.body.click();
|
||||||
|
expect(item1.classList.contains("note-item--locked")).toBe(false);
|
||||||
|
expect(document.body.classList.contains("notes-locked")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── DON/DOFF state ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("DON button", () => {
|
||||||
|
it("clicking DON sends POST to don URL", async () => {
|
||||||
|
don1.click();
|
||||||
|
await flushPromises();
|
||||||
|
expect(window.fetch).toHaveBeenCalledWith(
|
||||||
|
"/billboard/note/super-schizo/don",
|
||||||
|
jasmine.objectContaining({ method: "POST" })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("after DON, note gains note-item--donned", async () => {
|
||||||
|
don1.click();
|
||||||
|
await flushPromises();
|
||||||
|
expect(item1.classList.contains("note-item--donned")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("after DON, lock is cleared", async () => {
|
||||||
|
item1.click(); // lock first
|
||||||
|
don1.click();
|
||||||
|
await flushPromises();
|
||||||
|
expect(item1.classList.contains("note-item--locked")).toBe(false);
|
||||||
|
expect(document.body.classList.contains("notes-locked")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("after DON, greeting prefix and name are updated", async () => {
|
||||||
|
don1.click();
|
||||||
|
await flushPromises();
|
||||||
|
expect(document.getElementById("id_greeting_prefix").innerHTML).toContain("21st Century");
|
||||||
|
expect(document.getElementById("id_greeting_name").textContent).toBe("Schizoid Man");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("DONning item1 auto-removes donned state from previously DONned item2", async () => {
|
||||||
|
item2.classList.add("note-item--donned");
|
||||||
|
don2.classList.add("btn-disabled"); don2.textContent = "×";
|
||||||
|
doff2.classList.remove("btn-disabled"); doff2.textContent = "DOFF";
|
||||||
|
NotePage._donnedItem = item2;
|
||||||
|
|
||||||
|
don1.click();
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(item2.classList.contains("note-item--donned")).toBe(false);
|
||||||
|
expect(don2.classList.contains("btn-disabled")).toBe(false);
|
||||||
|
expect(don2.textContent).toBe("DON");
|
||||||
|
expect(doff2.classList.contains("btn-disabled")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("DONning one does not POST a doff for the previous", async () => {
|
||||||
|
item2.classList.add("note-item--donned");
|
||||||
|
NotePage._donnedItem = item2;
|
||||||
|
don1.click();
|
||||||
|
await flushPromises();
|
||||||
|
// Only one fetch call (the DON), no DOFF call for item2
|
||||||
|
expect(window.fetch.calls.count()).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("DOFF button", () => {
|
||||||
|
it("clicking DOFF sends POST to doff URL", async () => {
|
||||||
|
window.fetch = jasmine.createSpy("fetch").and.returnValue(
|
||||||
|
Promise.resolve({ ok: true, json: () => Promise.resolve({ greeting: "Welcome,", title: "Earthman" }) })
|
||||||
|
);
|
||||||
|
item1.classList.add("note-item--donned");
|
||||||
|
doff1.classList.remove("btn-disabled"); doff1.textContent = "DOFF";
|
||||||
|
don1.classList.add("btn-disabled"); don1.textContent = "×";
|
||||||
|
NotePage._donnedItem = item1;
|
||||||
|
|
||||||
|
doff1.click();
|
||||||
|
await flushPromises();
|
||||||
|
expect(window.fetch).toHaveBeenCalledWith(
|
||||||
|
"/billboard/note/super-schizo/doff",
|
||||||
|
jasmine.objectContaining({ method: "POST" })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("after DOFF, note loses note-item--donned", async () => {
|
||||||
|
window.fetch = jasmine.createSpy("fetch").and.returnValue(
|
||||||
|
Promise.resolve({ ok: true, json: () => Promise.resolve({ greeting: "Welcome,", title: "Earthman" }) })
|
||||||
|
);
|
||||||
|
item1.classList.add("note-item--donned");
|
||||||
|
doff1.classList.remove("btn-disabled"); doff1.textContent = "DOFF";
|
||||||
|
NotePage._donnedItem = item1;
|
||||||
|
|
||||||
|
doff1.click();
|
||||||
|
await flushPromises();
|
||||||
|
expect(item1.classList.contains("note-item--donned")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("after DOFF, greeting reverts to Welcome, / Earthman", async () => {
|
||||||
|
window.fetch = jasmine.createSpy("fetch").and.returnValue(
|
||||||
|
Promise.resolve({ ok: true, json: () => Promise.resolve({ greeting: "Welcome,", title: "Earthman" }) })
|
||||||
|
);
|
||||||
|
document.getElementById("id_greeting_prefix").textContent = "21st Century";
|
||||||
|
document.getElementById("id_greeting_name").textContent = "Schizoid Man";
|
||||||
|
item1.classList.add("note-item--donned");
|
||||||
|
doff1.classList.remove("btn-disabled"); doff1.textContent = "DOFF";
|
||||||
|
NotePage._donnedItem = item1;
|
||||||
|
|
||||||
|
doff1.click();
|
||||||
|
await flushPromises();
|
||||||
|
expect(document.getElementById("id_greeting_prefix").textContent).toBe("Welcome,");
|
||||||
|
expect(document.getElementById("id_greeting_name").textContent).toBe("Earthman");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Initial donned state ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("initial load with an already-donned note", () => {
|
||||||
|
it("note whose DON is btn-disabled gets note-item--donned on init", () => {
|
||||||
|
const equippedItem = makeItem("stargazer", true);
|
||||||
|
testDiv.appendChild(equippedItem);
|
||||||
|
NotePage._testReset();
|
||||||
|
NotePage._init();
|
||||||
|
expect(equippedItem.classList.contains("note-item--donned")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -21,6 +21,8 @@ describe("RoleSelect", () => {
|
|||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
RoleSelect.closeFan();
|
RoleSelect.closeFan();
|
||||||
testDiv.remove();
|
testDiv.remove();
|
||||||
|
const warning = document.querySelector(".role-no-deck-warning");
|
||||||
|
if (warning) warning.remove();
|
||||||
delete window.showGuard;
|
delete window.showGuard;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -693,4 +695,87 @@ describe("RoleSelect", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ //
|
||||||
|
// No-deck guard //
|
||||||
|
// data-equipped-deck="" → openFan() blocked; warning shown over stack //
|
||||||
|
// ------------------------------------------------------------------ //
|
||||||
|
|
||||||
|
describe("openFan() — no deck equipped (data-equipped-deck='')", () => {
|
||||||
|
let stack;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
stack = document.createElement("div");
|
||||||
|
stack.className = "card-stack";
|
||||||
|
stack.dataset.state = "eligible";
|
||||||
|
stack.dataset.starterRoles = "";
|
||||||
|
stack.dataset.userSlots = "1";
|
||||||
|
stack.dataset.activeSlot = "1";
|
||||||
|
stack.dataset.equippedDeck = ""; // explicitly empty = no deck
|
||||||
|
testDiv.appendChild(stack);
|
||||||
|
|
||||||
|
RoleSelect.openFan(); // intercepted — fan must NOT open
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not open the fan backdrop", () => {
|
||||||
|
expect(document.querySelector(".role-select-backdrop")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows .role-no-deck-warning in the DOM", () => {
|
||||||
|
expect(document.querySelector(".role-no-deck-warning")).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("warning contains the deck prompt text", () => {
|
||||||
|
const w = document.querySelector(".role-no-deck-warning");
|
||||||
|
expect(w.textContent).toContain("Equip card deck before Role select");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("warning has a .btn-caution FYI link to gameboard", () => {
|
||||||
|
const btn = document.querySelector(".role-no-deck-warning .guard-actions .btn-caution");
|
||||||
|
expect(btn).not.toBeNull();
|
||||||
|
expect(btn.tagName).toBe("A");
|
||||||
|
expect(btn.href).toContain("/gameboard/");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("warning has a .btn-cancel NVM button", () => {
|
||||||
|
expect(document.querySelector(".role-no-deck-warning .guard-actions .btn-cancel")).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("NVM click removes the warning", () => {
|
||||||
|
document.querySelector(".role-no-deck-warning .guard-actions .btn-cancel").click();
|
||||||
|
expect(document.querySelector(".role-no-deck-warning")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("second openFan() call does not duplicate the warning", () => {
|
||||||
|
RoleSelect.openFan();
|
||||||
|
expect(document.querySelectorAll(".role-no-deck-warning").length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not POST to select_role", () => {
|
||||||
|
expect(window.fetch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("openFan() — deck is equipped (data-equipped-deck non-empty)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
const stack = document.createElement("div");
|
||||||
|
stack.className = "card-stack";
|
||||||
|
stack.dataset.state = "eligible";
|
||||||
|
stack.dataset.starterRoles = "";
|
||||||
|
stack.dataset.userSlots = "1";
|
||||||
|
stack.dataset.activeSlot = "1";
|
||||||
|
stack.dataset.equippedDeck = "42"; // non-empty = deck present
|
||||||
|
testDiv.appendChild(stack);
|
||||||
|
|
||||||
|
RoleSelect.openFan();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens the fan backdrop normally", () => {
|
||||||
|
expect(document.querySelector(".role-select-backdrop")).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show .role-no-deck-warning", () => {
|
||||||
|
expect(document.querySelector(".role-no-deck-warning")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -58,7 +58,9 @@ describe("SigSelect", () => {
|
|||||||
data-correspondence=""
|
data-correspondence=""
|
||||||
data-keywords-upright="action,impulsiveness,ambition"
|
data-keywords-upright="action,impulsiveness,ambition"
|
||||||
data-keywords-reversed="no direction,disregard for consequences"
|
data-keywords-reversed="no direction,disregard for consequences"
|
||||||
data-cautions="${cardCautions.replace(/"/g, '"')}">
|
data-cautions="${cardCautions.replace(/"/g, '"')}"
|
||||||
|
data-levity-qualifier="Elevated"
|
||||||
|
data-gravity-qualifier="Graven">
|
||||||
<div class="fan-card-corner fan-card-corner--tl">
|
<div class="fan-card-corner fan-card-corner--tl">
|
||||||
<span class="fan-corner-rank">K</span>
|
<span class="fan-corner-rank">K</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -540,25 +542,25 @@ describe("SigSelect", () => {
|
|||||||
|
|
||||||
// ── Polarity theming — stage qualifier text ────────────────────────────── //
|
// ── Polarity theming — stage qualifier text ────────────────────────────── //
|
||||||
//
|
//
|
||||||
// On mouseenter, updateStage() injects "Leavened" or "Graven" into the
|
// On mouseenter, updateStage() injects "Elevated" or "Graven" into the
|
||||||
// sig-qualifier-above (non-major) or sig-qualifier-below (major arcana) slot.
|
// sig-qualifier-above (non-major) or sig-qualifier-below (major arcana) slot.
|
||||||
// Correspondence field is never populated in sig-select context.
|
// Correspondence field is never populated in sig-select context.
|
||||||
|
|
||||||
describe("polarity theming — stage qualifier", () => {
|
describe("polarity theming — stage qualifier", () => {
|
||||||
it("levity non-major card puts 'Leavened' in qualifier-above, qualifier-below empty", () => {
|
it("levity non-major card puts 'Elevated' in qualifier-above, qualifier-below empty", () => {
|
||||||
makeFixture({ polarity: 'levity', userRole: 'PC' });
|
makeFixture({ polarity: 'levity', userRole: 'PC' });
|
||||||
// data-arcana defaults to "Minor Arcana" in fixture → non-major
|
// data-arcana defaults to "Minor Arcana" in fixture → non-major
|
||||||
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||||
expect(testDiv.querySelector(".sig-qualifier-above").textContent).toBe("Leavened");
|
expect(testDiv.querySelector(".sig-qualifier-above").textContent).toBe("Elevated");
|
||||||
expect(testDiv.querySelector(".sig-qualifier-below").textContent).toBe("");
|
expect(testDiv.querySelector(".sig-qualifier-below").textContent).toBe("");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("levity major arcana card puts 'Leavened' in qualifier-below, qualifier-above empty", () => {
|
it("levity major arcana card puts 'Elevated' in qualifier-below, qualifier-above empty", () => {
|
||||||
makeFixture({ polarity: 'levity', userRole: 'PC' });
|
makeFixture({ polarity: 'levity', userRole: 'PC' });
|
||||||
card.dataset.arcana = "Major Arcana";
|
card.dataset.arcana = "Major Arcana";
|
||||||
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||||
expect(testDiv.querySelector(".sig-qualifier-above").textContent).toBe("");
|
expect(testDiv.querySelector(".sig-qualifier-above").textContent).toBe("");
|
||||||
expect(testDiv.querySelector(".sig-qualifier-below").textContent).toBe("Leavened");
|
expect(testDiv.querySelector(".sig-qualifier-below").textContent).toBe("Elevated");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("major arcana title gets a trailing comma (qualifier reads as subtitle)", () => {
|
it("major arcana title gets a trailing comma (qualifier reads as subtitle)", () => {
|
||||||
@@ -597,7 +599,7 @@ describe("SigSelect", () => {
|
|||||||
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||||
// Now major — above should be empty, below filled
|
// Now major — above should be empty, below filled
|
||||||
expect(testDiv.querySelector(".sig-qualifier-above").textContent).toBe("");
|
expect(testDiv.querySelector(".sig-qualifier-above").textContent).toBe("");
|
||||||
expect(testDiv.querySelector(".sig-qualifier-below").textContent).toBe("Leavened");
|
expect(testDiv.querySelector(".sig-qualifier-below").textContent).toBe("Elevated");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("correspondence field is never populated", () => {
|
it("correspondence field is never populated", () => {
|
||||||
|
|||||||
@@ -24,9 +24,11 @@
|
|||||||
<script src="SigSelectSpec.js"></script>
|
<script src="SigSelectSpec.js"></script>
|
||||||
<script src="NatusWheelSpec.js"></script>
|
<script src="NatusWheelSpec.js"></script>
|
||||||
<script src="NoteSpec.js"></script>
|
<script src="NoteSpec.js"></script>
|
||||||
|
<script src="NotePageSpec.js"></script>
|
||||||
<!-- src files -->
|
<!-- src files -->
|
||||||
<script src="/static/apps/dashboard/dashboard.js"></script>
|
<script src="/static/apps/dashboard/dashboard.js"></script>
|
||||||
<script src="/static/apps/dashboard/note.js"></script>
|
<script src="/static/apps/dashboard/note.js"></script>
|
||||||
|
<script src="/static/apps/billboard/note-page.js"></script>
|
||||||
<script src="/static/apps/epic/role-select.js"></script>
|
<script src="/static/apps/epic/role-select.js"></script>
|
||||||
<script src="/static/apps/epic/tray.js"></script>
|
<script src="/static/apps/epic/tray.js"></script>
|
||||||
<script src="/static/apps/epic/sig-select.js"></script>
|
<script src="/static/apps/epic/sig-select.js"></script>
|
||||||
|
|||||||
@@ -557,13 +557,22 @@ body {
|
|||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ordinal superscript: 21st, 2nd, 3rd etc. — matches .tt-ord but globally available.
|
||||||
|
.ord {
|
||||||
|
font-size: 0.6em;
|
||||||
|
vertical-align: 0.25em;
|
||||||
|
line-height: 0;
|
||||||
|
margin-left: -0.1em;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
#id_guard_portal {
|
#id_guard_portal {
|
||||||
display: none;
|
display: none;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 10000;
|
z-index: 10000;
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
background-color: rgba(var(--tooltip-bg), 0.5);
|
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);
|
||||||
box-shadow: 0 0.25rem 1rem rgba(0, 0, 0, 0.4);
|
box-shadow: 0 0.25rem 1rem rgba(0, 0, 0, 0.4);
|
||||||
@@ -586,4 +595,9 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card-ref {
|
||||||
|
color: rgba(var(--terUser), 1) !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
}
|
||||||
|
|||||||
@@ -70,6 +70,8 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
|
||||||
.btn { margin: 0; }
|
.btn { margin: 0; }
|
||||||
}
|
}
|
||||||
@@ -85,19 +87,33 @@
|
|||||||
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);
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
cursor: help;
|
cursor: pointer;
|
||||||
transition: border-color 0.15s, box-shadow 0.15s;
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
|
|
||||||
&:hover,
|
// Hover glow — only when no note is click-locked on the page
|
||||||
&:active,
|
body:not(.notes-locked) &:hover {
|
||||||
|
border-color: rgba(var(--terUser), 1);
|
||||||
|
box-shadow: 0 0 10px rgba(var(--ninUser), 0.35);
|
||||||
|
.note-item__title { color: rgba(var(--terUser), 1); }
|
||||||
|
.note-don-doff { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Palette modal open (existing)
|
||||||
&.note-item--active {
|
&.note-item--active {
|
||||||
border-color: rgba(var(--terUser), 1);
|
border-color: rgba(var(--terUser), 1);
|
||||||
opacity: 1;
|
|
||||||
box-shadow: 0 0 10px rgba(var(--ninUser), 0.35);
|
box-shadow: 0 0 10px rgba(var(--ninUser), 0.35);
|
||||||
|
|
||||||
.note-item__title { color: rgba(var(--terUser), 1); }
|
.note-item__title { color: rgba(var(--terUser), 1); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Click-locked: glow + DON/DOFF always visible
|
||||||
|
&.note-item--locked,
|
||||||
|
&.note-item--donned {
|
||||||
|
border-color: rgba(var(--terUser), 1);
|
||||||
|
box-shadow: 0 0 10px rgba(var(--ninUser), 0.35);
|
||||||
|
.note-item__title { color: rgba(var(--terUser), 1); }
|
||||||
|
.note-don-doff { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
.note-item__body {
|
.note-item__body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -164,6 +180,17 @@
|
|||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
|
|
||||||
&:hover { opacity: 1; }
|
&:hover { opacity: 1; }
|
||||||
|
|
||||||
|
&--label {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: rgba(var(--secUser), 0.6);
|
||||||
|
opacity: 1;
|
||||||
|
cursor: default;
|
||||||
|
border-style: solid;
|
||||||
|
border-color: rgba(var(--secUser), 0.6);
|
||||||
|
&:hover { opacity: 1; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Confirmed palette swatch — right-side thumbnail, same gradient as .note-swatch-body.
|
// Confirmed palette swatch — right-side thumbnail, same gradient as .note-swatch-body.
|
||||||
|
|||||||
@@ -738,6 +738,37 @@ html:has(.gate-backdrop) .position-strip .gate-slot button { pointer-events: aut
|
|||||||
$card-w: 90px;
|
$card-w: 90px;
|
||||||
$card-h: 60px;
|
$card-h: 60px;
|
||||||
|
|
||||||
|
// ─── No-deck warning overlay ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
.role-no-deck-warning {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 10000;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
max-width: 11rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background-color: rgba(var(--tooltip-bg), 0.75);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
border: 0.1rem solid rgba(var(--secUser), 0.4);
|
||||||
|
box-shadow: 0 0.25rem 1rem rgba(0, 0, 0, 0.4);
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: rgba(var(--secUser), 0.9);
|
||||||
|
text-align: center;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guard-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Role select modal ─────────────────────────────────────────────────────
|
// ─── Role select modal ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
.role-select-backdrop {
|
.role-select-backdrop {
|
||||||
|
|||||||
204
src/static_src/tests/NotePageSpec.js
Normal file
204
src/static_src/tests/NotePageSpec.js
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
function flushPromises() {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("NotePage", () => {
|
||||||
|
let testDiv, item1, item2, don1, doff1, don2, doff2;
|
||||||
|
|
||||||
|
function makeItem(slug, isEquipped) {
|
||||||
|
const li = document.createElement("li");
|
||||||
|
li.className = "note-item";
|
||||||
|
li.dataset.slug = slug;
|
||||||
|
li.dataset.donUrl = `/billboard/note/${slug}/don`;
|
||||||
|
li.dataset.doffUrl = `/billboard/note/${slug}/doff`;
|
||||||
|
li.innerHTML = `
|
||||||
|
<div class="note-don-doff">
|
||||||
|
<button class="btn btn-equip note-don-btn${isEquipped ? " btn-disabled" : ""}">${isEquipped ? "×" : "DON"}</button>
|
||||||
|
<button class="btn btn-unequip note-doff-btn${isEquipped ? "" : " btn-disabled"}">${isEquipped ? "DOFF" : "×"}</button>
|
||||||
|
</div>
|
||||||
|
<div class="note-item__body"><p class="note-item__title">${slug}</p></div>
|
||||||
|
`;
|
||||||
|
return li;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
NotePage._testReset();
|
||||||
|
testDiv = document.createElement("div");
|
||||||
|
testDiv.innerHTML = `<span id="id_greeting_prefix">Welcome,</span><span id="id_greeting_name">Earthman</span>`;
|
||||||
|
item1 = makeItem("super-schizo", false);
|
||||||
|
item2 = makeItem("super-nomad", false);
|
||||||
|
testDiv.appendChild(item1);
|
||||||
|
testDiv.appendChild(item2);
|
||||||
|
document.body.appendChild(testDiv);
|
||||||
|
|
||||||
|
window.fetch = jasmine.createSpy("fetch").and.returnValue(
|
||||||
|
Promise.resolve({ ok: true, json: () => Promise.resolve({ title: "Schizoid Man", greeting: "21st Century" }) })
|
||||||
|
);
|
||||||
|
|
||||||
|
don1 = item1.querySelector(".note-don-btn");
|
||||||
|
doff1 = item1.querySelector(".note-doff-btn");
|
||||||
|
don2 = item2.querySelector(".note-don-btn");
|
||||||
|
doff2 = item2.querySelector(".note-doff-btn");
|
||||||
|
|
||||||
|
NotePage._init();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
testDiv.remove();
|
||||||
|
document.body.classList.remove("notes-locked");
|
||||||
|
delete window.fetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Lock / unlock ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("click-lock behaviour", () => {
|
||||||
|
it("clicking a note adds note-item--locked", () => {
|
||||||
|
item1.click();
|
||||||
|
expect(item1.classList.contains("note-item--locked")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clicking a note adds notes-locked to body", () => {
|
||||||
|
item1.click();
|
||||||
|
expect(document.body.classList.contains("notes-locked")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clicking the same note again removes lock", () => {
|
||||||
|
item1.click();
|
||||||
|
item1.click();
|
||||||
|
expect(item1.classList.contains("note-item--locked")).toBe(false);
|
||||||
|
expect(document.body.classList.contains("notes-locked")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clicking a different note moves lock to that note", () => {
|
||||||
|
item1.click();
|
||||||
|
item2.click();
|
||||||
|
expect(item1.classList.contains("note-item--locked")).toBe(false);
|
||||||
|
expect(item2.classList.contains("note-item--locked")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("body click clears all locks", () => {
|
||||||
|
item1.click();
|
||||||
|
document.body.click();
|
||||||
|
expect(item1.classList.contains("note-item--locked")).toBe(false);
|
||||||
|
expect(document.body.classList.contains("notes-locked")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── DON/DOFF state ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("DON button", () => {
|
||||||
|
it("clicking DON sends POST to don URL", async () => {
|
||||||
|
don1.click();
|
||||||
|
await flushPromises();
|
||||||
|
expect(window.fetch).toHaveBeenCalledWith(
|
||||||
|
"/billboard/note/super-schizo/don",
|
||||||
|
jasmine.objectContaining({ method: "POST" })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("after DON, note gains note-item--donned", async () => {
|
||||||
|
don1.click();
|
||||||
|
await flushPromises();
|
||||||
|
expect(item1.classList.contains("note-item--donned")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("after DON, lock is cleared", async () => {
|
||||||
|
item1.click(); // lock first
|
||||||
|
don1.click();
|
||||||
|
await flushPromises();
|
||||||
|
expect(item1.classList.contains("note-item--locked")).toBe(false);
|
||||||
|
expect(document.body.classList.contains("notes-locked")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("after DON, greeting prefix and name are updated", async () => {
|
||||||
|
don1.click();
|
||||||
|
await flushPromises();
|
||||||
|
expect(document.getElementById("id_greeting_prefix").innerHTML).toContain("21st Century");
|
||||||
|
expect(document.getElementById("id_greeting_name").textContent).toBe("Schizoid Man");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("DONning item1 auto-removes donned state from previously DONned item2", async () => {
|
||||||
|
item2.classList.add("note-item--donned");
|
||||||
|
don2.classList.add("btn-disabled"); don2.textContent = "×";
|
||||||
|
doff2.classList.remove("btn-disabled"); doff2.textContent = "DOFF";
|
||||||
|
NotePage._donnedItem = item2;
|
||||||
|
|
||||||
|
don1.click();
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(item2.classList.contains("note-item--donned")).toBe(false);
|
||||||
|
expect(don2.classList.contains("btn-disabled")).toBe(false);
|
||||||
|
expect(don2.textContent).toBe("DON");
|
||||||
|
expect(doff2.classList.contains("btn-disabled")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("DONning one does not POST a doff for the previous", async () => {
|
||||||
|
item2.classList.add("note-item--donned");
|
||||||
|
NotePage._donnedItem = item2;
|
||||||
|
don1.click();
|
||||||
|
await flushPromises();
|
||||||
|
// Only one fetch call (the DON), no DOFF call for item2
|
||||||
|
expect(window.fetch.calls.count()).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("DOFF button", () => {
|
||||||
|
it("clicking DOFF sends POST to doff URL", async () => {
|
||||||
|
window.fetch = jasmine.createSpy("fetch").and.returnValue(
|
||||||
|
Promise.resolve({ ok: true, json: () => Promise.resolve({ greeting: "Welcome,", title: "Earthman" }) })
|
||||||
|
);
|
||||||
|
item1.classList.add("note-item--donned");
|
||||||
|
doff1.classList.remove("btn-disabled"); doff1.textContent = "DOFF";
|
||||||
|
don1.classList.add("btn-disabled"); don1.textContent = "×";
|
||||||
|
NotePage._donnedItem = item1;
|
||||||
|
|
||||||
|
doff1.click();
|
||||||
|
await flushPromises();
|
||||||
|
expect(window.fetch).toHaveBeenCalledWith(
|
||||||
|
"/billboard/note/super-schizo/doff",
|
||||||
|
jasmine.objectContaining({ method: "POST" })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("after DOFF, note loses note-item--donned", async () => {
|
||||||
|
window.fetch = jasmine.createSpy("fetch").and.returnValue(
|
||||||
|
Promise.resolve({ ok: true, json: () => Promise.resolve({ greeting: "Welcome,", title: "Earthman" }) })
|
||||||
|
);
|
||||||
|
item1.classList.add("note-item--donned");
|
||||||
|
doff1.classList.remove("btn-disabled"); doff1.textContent = "DOFF";
|
||||||
|
NotePage._donnedItem = item1;
|
||||||
|
|
||||||
|
doff1.click();
|
||||||
|
await flushPromises();
|
||||||
|
expect(item1.classList.contains("note-item--donned")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("after DOFF, greeting reverts to Welcome, / Earthman", async () => {
|
||||||
|
window.fetch = jasmine.createSpy("fetch").and.returnValue(
|
||||||
|
Promise.resolve({ ok: true, json: () => Promise.resolve({ greeting: "Welcome,", title: "Earthman" }) })
|
||||||
|
);
|
||||||
|
document.getElementById("id_greeting_prefix").textContent = "21st Century";
|
||||||
|
document.getElementById("id_greeting_name").textContent = "Schizoid Man";
|
||||||
|
item1.classList.add("note-item--donned");
|
||||||
|
doff1.classList.remove("btn-disabled"); doff1.textContent = "DOFF";
|
||||||
|
NotePage._donnedItem = item1;
|
||||||
|
|
||||||
|
doff1.click();
|
||||||
|
await flushPromises();
|
||||||
|
expect(document.getElementById("id_greeting_prefix").textContent).toBe("Welcome,");
|
||||||
|
expect(document.getElementById("id_greeting_name").textContent).toBe("Earthman");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Initial donned state ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("initial load with an already-donned note", () => {
|
||||||
|
it("note whose DON is btn-disabled gets note-item--donned on init", () => {
|
||||||
|
const equippedItem = makeItem("stargazer", true);
|
||||||
|
testDiv.appendChild(equippedItem);
|
||||||
|
NotePage._testReset();
|
||||||
|
NotePage._init();
|
||||||
|
expect(equippedItem.classList.contains("note-item--donned")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -21,6 +21,8 @@ describe("RoleSelect", () => {
|
|||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
RoleSelect.closeFan();
|
RoleSelect.closeFan();
|
||||||
testDiv.remove();
|
testDiv.remove();
|
||||||
|
const warning = document.querySelector(".role-no-deck-warning");
|
||||||
|
if (warning) warning.remove();
|
||||||
delete window.showGuard;
|
delete window.showGuard;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -693,4 +695,87 @@ describe("RoleSelect", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ //
|
||||||
|
// No-deck guard //
|
||||||
|
// data-equipped-deck="" → openFan() blocked; warning shown over stack //
|
||||||
|
// ------------------------------------------------------------------ //
|
||||||
|
|
||||||
|
describe("openFan() — no deck equipped (data-equipped-deck='')", () => {
|
||||||
|
let stack;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
stack = document.createElement("div");
|
||||||
|
stack.className = "card-stack";
|
||||||
|
stack.dataset.state = "eligible";
|
||||||
|
stack.dataset.starterRoles = "";
|
||||||
|
stack.dataset.userSlots = "1";
|
||||||
|
stack.dataset.activeSlot = "1";
|
||||||
|
stack.dataset.equippedDeck = ""; // explicitly empty = no deck
|
||||||
|
testDiv.appendChild(stack);
|
||||||
|
|
||||||
|
RoleSelect.openFan(); // intercepted — fan must NOT open
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not open the fan backdrop", () => {
|
||||||
|
expect(document.querySelector(".role-select-backdrop")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows .role-no-deck-warning in the DOM", () => {
|
||||||
|
expect(document.querySelector(".role-no-deck-warning")).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("warning contains the deck prompt text", () => {
|
||||||
|
const w = document.querySelector(".role-no-deck-warning");
|
||||||
|
expect(w.textContent).toContain("Equip card deck before Role select");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("warning has a .btn-caution FYI link to gameboard", () => {
|
||||||
|
const btn = document.querySelector(".role-no-deck-warning .guard-actions .btn-caution");
|
||||||
|
expect(btn).not.toBeNull();
|
||||||
|
expect(btn.tagName).toBe("A");
|
||||||
|
expect(btn.href).toContain("/gameboard/");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("warning has a .btn-cancel NVM button", () => {
|
||||||
|
expect(document.querySelector(".role-no-deck-warning .guard-actions .btn-cancel")).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("NVM click removes the warning", () => {
|
||||||
|
document.querySelector(".role-no-deck-warning .guard-actions .btn-cancel").click();
|
||||||
|
expect(document.querySelector(".role-no-deck-warning")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("second openFan() call does not duplicate the warning", () => {
|
||||||
|
RoleSelect.openFan();
|
||||||
|
expect(document.querySelectorAll(".role-no-deck-warning").length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not POST to select_role", () => {
|
||||||
|
expect(window.fetch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("openFan() — deck is equipped (data-equipped-deck non-empty)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
const stack = document.createElement("div");
|
||||||
|
stack.className = "card-stack";
|
||||||
|
stack.dataset.state = "eligible";
|
||||||
|
stack.dataset.starterRoles = "";
|
||||||
|
stack.dataset.userSlots = "1";
|
||||||
|
stack.dataset.activeSlot = "1";
|
||||||
|
stack.dataset.equippedDeck = "42"; // non-empty = deck present
|
||||||
|
testDiv.appendChild(stack);
|
||||||
|
|
||||||
|
RoleSelect.openFan();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens the fan backdrop normally", () => {
|
||||||
|
expect(document.querySelector(".role-select-backdrop")).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show .role-no-deck-warning", () => {
|
||||||
|
expect(document.querySelector(".role-no-deck-warning")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -58,7 +58,9 @@ describe("SigSelect", () => {
|
|||||||
data-correspondence=""
|
data-correspondence=""
|
||||||
data-keywords-upright="action,impulsiveness,ambition"
|
data-keywords-upright="action,impulsiveness,ambition"
|
||||||
data-keywords-reversed="no direction,disregard for consequences"
|
data-keywords-reversed="no direction,disregard for consequences"
|
||||||
data-cautions="${cardCautions.replace(/"/g, '"')}">
|
data-cautions="${cardCautions.replace(/"/g, '"')}"
|
||||||
|
data-levity-qualifier="Elevated"
|
||||||
|
data-gravity-qualifier="Graven">
|
||||||
<div class="fan-card-corner fan-card-corner--tl">
|
<div class="fan-card-corner fan-card-corner--tl">
|
||||||
<span class="fan-corner-rank">K</span>
|
<span class="fan-corner-rank">K</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -540,25 +542,25 @@ describe("SigSelect", () => {
|
|||||||
|
|
||||||
// ── Polarity theming — stage qualifier text ────────────────────────────── //
|
// ── Polarity theming — stage qualifier text ────────────────────────────── //
|
||||||
//
|
//
|
||||||
// On mouseenter, updateStage() injects "Leavened" or "Graven" into the
|
// On mouseenter, updateStage() injects "Elevated" or "Graven" into the
|
||||||
// sig-qualifier-above (non-major) or sig-qualifier-below (major arcana) slot.
|
// sig-qualifier-above (non-major) or sig-qualifier-below (major arcana) slot.
|
||||||
// Correspondence field is never populated in sig-select context.
|
// Correspondence field is never populated in sig-select context.
|
||||||
|
|
||||||
describe("polarity theming — stage qualifier", () => {
|
describe("polarity theming — stage qualifier", () => {
|
||||||
it("levity non-major card puts 'Leavened' in qualifier-above, qualifier-below empty", () => {
|
it("levity non-major card puts 'Elevated' in qualifier-above, qualifier-below empty", () => {
|
||||||
makeFixture({ polarity: 'levity', userRole: 'PC' });
|
makeFixture({ polarity: 'levity', userRole: 'PC' });
|
||||||
// data-arcana defaults to "Minor Arcana" in fixture → non-major
|
// data-arcana defaults to "Minor Arcana" in fixture → non-major
|
||||||
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||||
expect(testDiv.querySelector(".sig-qualifier-above").textContent).toBe("Leavened");
|
expect(testDiv.querySelector(".sig-qualifier-above").textContent).toBe("Elevated");
|
||||||
expect(testDiv.querySelector(".sig-qualifier-below").textContent).toBe("");
|
expect(testDiv.querySelector(".sig-qualifier-below").textContent).toBe("");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("levity major arcana card puts 'Leavened' in qualifier-below, qualifier-above empty", () => {
|
it("levity major arcana card puts 'Elevated' in qualifier-below, qualifier-above empty", () => {
|
||||||
makeFixture({ polarity: 'levity', userRole: 'PC' });
|
makeFixture({ polarity: 'levity', userRole: 'PC' });
|
||||||
card.dataset.arcana = "Major Arcana";
|
card.dataset.arcana = "Major Arcana";
|
||||||
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||||
expect(testDiv.querySelector(".sig-qualifier-above").textContent).toBe("");
|
expect(testDiv.querySelector(".sig-qualifier-above").textContent).toBe("");
|
||||||
expect(testDiv.querySelector(".sig-qualifier-below").textContent).toBe("Leavened");
|
expect(testDiv.querySelector(".sig-qualifier-below").textContent).toBe("Elevated");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("major arcana title gets a trailing comma (qualifier reads as subtitle)", () => {
|
it("major arcana title gets a trailing comma (qualifier reads as subtitle)", () => {
|
||||||
@@ -597,7 +599,7 @@ describe("SigSelect", () => {
|
|||||||
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||||
// Now major — above should be empty, below filled
|
// Now major — above should be empty, below filled
|
||||||
expect(testDiv.querySelector(".sig-qualifier-above").textContent).toBe("");
|
expect(testDiv.querySelector(".sig-qualifier-above").textContent).toBe("");
|
||||||
expect(testDiv.querySelector(".sig-qualifier-below").textContent).toBe("Leavened");
|
expect(testDiv.querySelector(".sig-qualifier-below").textContent).toBe("Elevated");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("correspondence field is never populated", () => {
|
it("correspondence field is never populated", () => {
|
||||||
|
|||||||
@@ -24,9 +24,11 @@
|
|||||||
<script src="SigSelectSpec.js"></script>
|
<script src="SigSelectSpec.js"></script>
|
||||||
<script src="NatusWheelSpec.js"></script>
|
<script src="NatusWheelSpec.js"></script>
|
||||||
<script src="NoteSpec.js"></script>
|
<script src="NoteSpec.js"></script>
|
||||||
|
<script src="NotePageSpec.js"></script>
|
||||||
<!-- src files -->
|
<!-- src files -->
|
||||||
<script src="/static/apps/dashboard/dashboard.js"></script>
|
<script src="/static/apps/dashboard/dashboard.js"></script>
|
||||||
<script src="/static/apps/dashboard/note.js"></script>
|
<script src="/static/apps/dashboard/note.js"></script>
|
||||||
|
<script src="/static/apps/billboard/note-page.js"></script>
|
||||||
<script src="/static/apps/epic/role-select.js"></script>
|
<script src="/static/apps/epic/role-select.js"></script>
|
||||||
<script src="/static/apps/epic/tray.js"></script>
|
<script src="/static/apps/epic/tray.js"></script>
|
||||||
<script src="/static/apps/epic/sig-select.js"></script>
|
<script src="/static/apps/epic/sig-select.js"></script>
|
||||||
|
|||||||
@@ -25,11 +25,11 @@
|
|||||||
|
|
||||||
<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>
|
||||||
<p class="note-item__description">{{ item.description }}</p>
|
<p class="note-item__description">{{ item.description|safe }}</p>
|
||||||
<div class="note-recognitions">
|
<div class="note-recognitions">
|
||||||
<div class="note-recognitions__header">Recognitions</div>
|
<div class="note-recognitions__header">Recognitions</div>
|
||||||
<ul class="note-recognitions__list">
|
<ul class="note-recognitions__list">
|
||||||
<li><span class="note-recognitions__dim">Title:</span> <strong>{{ item.title }}</strong></li>
|
<li><span class="note-recognitions__dim">Title:</span> <strong>{{ item.recognition_title }}</strong></li>
|
||||||
{% if item.obj.palette %}
|
{% if item.obj.palette %}
|
||||||
<li class="note-recognitions__palette-line"><span class="note-recognitions__dim">Palette:</span> <strong>{{ item.palette_label }}</strong></li>
|
<li class="note-recognitions__palette-line"><span class="note-recognitions__dim">Palette:</span> <strong>{{ item.palette_label }}</strong></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -39,6 +39,8 @@
|
|||||||
|
|
||||||
{% if item.obj.palette %}
|
{% if item.obj.palette %}
|
||||||
<div class="note-item__palette {{ item.obj.palette }}"></div>
|
<div class="note-item__palette {{ item.obj.palette }}"></div>
|
||||||
|
{% elif item.swatch_label %}
|
||||||
|
<div class="note-item__image-box note-item__image-box--label">{{ item.swatch_label }}</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="note-item__image-box">?</div>
|
<div class="note-item__image-box">?</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
|
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
|
||||||
>
|
>
|
||||||
<h2><a href="{% url 'game_kit' %}">Game Kit</a></h2>
|
<h2><a href="{% url 'game_kit' %}">Game Kit</a></h2>
|
||||||
<div id="id_game_kit" data-equipped-id="{{ equipped_trinket_id|default:'' }}" data-equipped-deck-id="{{ equipped_deck_id|default:'' }}">
|
<div id="id_game_kit" data-equipped-id="{{ equipped_trinket_id|default:'' }}" data-equipped-deck-id="{{ equipped_deck_id|default:'' }}" data-in-use-deck-ids="{% for d in deck_variants %}{% if d.in_use_room_name %}{{ d.pk }},{% endif %}{% endfor %}">
|
||||||
{% if pass_token %}
|
{% if pass_token %}
|
||||||
<div id="id_kit_pass" class="token" data-token-id="{{ pass_token.pk }}">
|
<div id="id_kit_pass" class="token" data-token-id="{{ pass_token.pk }}">
|
||||||
<i class="fa-solid fa-clipboard"></i>
|
<i class="fa-solid fa-clipboard"></i>
|
||||||
@@ -21,11 +21,11 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if carte %}
|
{% if carte %}
|
||||||
<div id="id_kit_carte_blanche" class="token" data-token-id="{{ carte.pk }}">
|
<div id="id_kit_carte_blanche" class="token" data-token-id="{{ carte.pk }}" data-current-room-name="{{ carte.current_room.name|default:'' }}">
|
||||||
<i class="fa-solid fa-money-check"></i>
|
<i class="fa-solid fa-money-check"></i>
|
||||||
<div class="tt">
|
<div class="tt">
|
||||||
<div class="tt-equip-btns">
|
<div class="tt-equip-btns">
|
||||||
{% if carte.pk == equipped_trinket_id %}<button class="btn btn-equip btn-disabled" data-token-id="{{ carte.pk }}">×</button><button class="btn btn-unequip" data-token-id="{{ carte.pk }}">DOFF</button>{% else %}<button class="btn btn-equip" data-token-id="{{ carte.pk }}">DON</button><button class="btn btn-unequip btn-disabled" data-token-id="{{ carte.pk }}">×</button>{% endif %}
|
{% if carte.current_room %}<button class="btn btn-equip btn-disabled" data-token-id="{{ carte.pk }}">×</button><button class="btn btn-unequip btn-disabled" data-token-id="{{ carte.pk }}">×</button>{% elif carte.pk == equipped_trinket_id %}<button class="btn btn-equip btn-disabled" data-token-id="{{ carte.pk }}">×</button><button class="btn btn-unequip" data-token-id="{{ carte.pk }}">DOFF</button>{% else %}<button class="btn btn-equip" data-token-id="{{ carte.pk }}">DON</button><button class="btn btn-unequip btn-disabled" data-token-id="{{ carte.pk }}">×</button>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<h4 class="tt-title">{{ carte.tooltip_name }}</h4>
|
<h4 class="tt-title">{{ carte.tooltip_name }}</h4>
|
||||||
<p class="tt-description">{{ carte.tooltip_description }}</p>
|
<p class="tt-description">{{ carte.tooltip_description }}</p>
|
||||||
@@ -33,6 +33,7 @@
|
|||||||
<p class="tt-shoptalk"><em>{{ carte.tooltip_shoptalk }}</em></p>
|
<p class="tt-shoptalk"><em>{{ carte.tooltip_shoptalk }}</em></p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<p class="tt-expiry">{{ carte.tooltip_expiry }}</p>
|
<p class="tt-expiry">{{ carte.tooltip_expiry }}</p>
|
||||||
|
{% if carte.current_room %}<p class="tt-token-room-name">In game: {{ carte.current_room.name }}</p>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -72,10 +73,13 @@
|
|||||||
<i class="fa-regular fa-id-badge"></i>
|
<i class="fa-regular fa-id-badge"></i>
|
||||||
<div class="tt">
|
<div class="tt">
|
||||||
<div class="tt-equip-btns">
|
<div class="tt-equip-btns">
|
||||||
{% if deck.pk == equipped_deck_id %}<button class="btn btn-equip btn-disabled" data-deck-id="{{ deck.pk }}">×</button><button class="btn btn-unequip" data-deck-id="{{ deck.pk }}">DOFF</button>{% else %}<button class="btn btn-equip" data-deck-id="{{ deck.pk }}">DON</button><button class="btn btn-unequip btn-disabled" data-deck-id="{{ deck.pk }}">×</button>{% endif %}
|
{% if deck.in_use_room_name %}<button class="btn btn-equip btn-disabled" data-deck-id="{{ deck.pk }}">×</button><button class="btn btn-unequip btn-disabled" data-deck-id="{{ deck.pk }}">×</button>{% elif deck.pk == equipped_deck_id %}<button class="btn btn-equip btn-disabled" data-deck-id="{{ deck.pk }}">×</button><button class="btn btn-unequip" data-deck-id="{{ deck.pk }}">DOFF</button>{% else %}<button class="btn btn-equip" data-deck-id="{{ deck.pk }}">DON</button><button class="btn btn-unequip btn-disabled" data-deck-id="{{ deck.pk }}">×</button>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<h4 class="tt-title">{{ deck.name }}</h4>
|
<h4 class="tt-title">{{ deck.name }}{% if deck.is_default %} <span class="token-count">(Default)</span>{% endif %}</h4>
|
||||||
<p class="tt-description">{{ deck.card_count }} cards</p>
|
<p class="tt-description">{{ deck.card_count }}-card Tarot deck</p>
|
||||||
|
{% if deck.description %}<p class="tt-shoptalk"><em>{{ deck.description }}</em></p>{% endif %}
|
||||||
|
<p class="tt-shoptalk">Stock version <span class="tt-subcounter">(0 substitutions)</span></p>
|
||||||
|
{% if deck.in_use_room_name %}<p class="tt-deck-game-name">In game: {{ deck.in_use_room_name }}</p>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
|
|||||||
@@ -32,13 +32,11 @@
|
|||||||
<div class="sig-stage-card" style="--sig-card-w: 4rem">
|
<div class="sig-stage-card" style="--sig-card-w: 4rem">
|
||||||
{% if my_tray_sig %}
|
{% if my_tray_sig %}
|
||||||
<div class="fan-card-face">
|
<div class="fan-card-face">
|
||||||
{% if my_tray_sig.arcana == "MIDDLE" %}
|
{% if my_tray_sig.arcana == "MAJOR" %}
|
||||||
<p class="sig-qualifier-above">{% if user_polarity == "levity" %}Leavened{% else %}Graven{% endif %}</p>
|
|
||||||
<p class="fan-card-name">{{ my_tray_sig.name_title }}</p>
|
<p class="fan-card-name">{{ my_tray_sig.name_title }}</p>
|
||||||
{% elif my_tray_sig.arcana == "MAJOR" %}
|
<p class="sig-qualifier-below">{% if user_polarity == "levity" %}{{ my_tray_sig.levity_qualifier }}{% else %}{{ my_tray_sig.gravity_qualifier }}{% endif %}</p>
|
||||||
<p class="fan-card-name">{{ my_tray_sig.name_title }}</p>
|
|
||||||
<p class="sig-qualifier-below">{% if user_polarity == "levity" %}Leavened{% else %}Graven{% endif %}</p>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
|
<p class="sig-qualifier-above">{% if user_polarity == "levity" %}{{ my_tray_sig.levity_qualifier }}{% else %}{{ my_tray_sig.gravity_qualifier }}{% endif %}</p>
|
||||||
<p class="fan-card-name">{{ my_tray_sig.name_title }}</p>
|
<p class="fan-card-name">{{ my_tray_sig.name_title }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -72,7 +72,9 @@ Context: sig_cards, user_polarity, user_seat, sig_reserve_url, sig_reservations_
|
|||||||
data-correspondence="{{ card.correspondence|default:'' }}"
|
data-correspondence="{{ card.correspondence|default:'' }}"
|
||||||
data-keywords-upright="{{ card.keywords_upright|join:',' }}"
|
data-keywords-upright="{{ card.keywords_upright|join:',' }}"
|
||||||
data-keywords-reversed="{{ card.keywords_reversed|join:',' }}"
|
data-keywords-reversed="{{ card.keywords_reversed|join:',' }}"
|
||||||
data-cautions="{{ card.cautions_json }}">
|
data-cautions="{{ card.cautions_json }}"
|
||||||
|
data-levity-qualifier="{{ card.levity_qualifier }}"
|
||||||
|
data-gravity-qualifier="{{ card.gravity_qualifier }}">
|
||||||
<div class="fan-card-corner fan-card-corner--tl">
|
<div class="fan-card-corner fan-card-corner--tl">
|
||||||
<span class="fan-corner-rank">{{ card.corner_rank }}</span>
|
<span class="fan-corner-rank">{{ card.corner_rank }}</span>
|
||||||
{% if card.suit_icon %}<i class="fa-solid {{ card.suit_icon }}"></i>{% endif %}
|
{% if card.suit_icon %}<i class="fa-solid {{ card.suit_icon }}"></i>{% endif %}
|
||||||
|
|||||||
@@ -27,7 +27,8 @@
|
|||||||
<div class="card-stack" data-state="{{ card_stack_state }}"
|
<div class="card-stack" data-state="{{ card_stack_state }}"
|
||||||
data-starter-roles="{{ starter_roles|join:',' }}"
|
data-starter-roles="{{ starter_roles|join:',' }}"
|
||||||
data-user-slots="{{ user_slots|join:',' }}"
|
data-user-slots="{{ user_slots|join:',' }}"
|
||||||
data-active-slot="{{ active_slot }}">
|
data-active-slot="{{ active_slot }}"
|
||||||
|
data-equipped-deck="{{ equipped_deck_id|default:'' }}">
|
||||||
{% if card_stack_state == "ineligible" %}
|
{% if card_stack_state == "ineligible" %}
|
||||||
<i class="fa-solid fa-ban"></i>
|
<i class="fa-solid fa-ban"></i>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -1,20 +1,23 @@
|
|||||||
{% if equipped_deck %}
|
|
||||||
<div class="kit-bag-section">
|
<div class="kit-bag-section">
|
||||||
<span class="kit-bag-label">Deck</span>
|
<span class="kit-bag-label">Deck</span>
|
||||||
<div class="kit-bag-row">
|
<div class="kit-bag-row">
|
||||||
|
{% if equipped_deck %}
|
||||||
<div class="kit-bag-deck" data-deck-id="{{ equipped_deck.pk }}">
|
<div class="kit-bag-deck" data-deck-id="{{ equipped_deck.pk }}">
|
||||||
<i class="fa-regular fa-id-badge"></i>
|
<i class="fa-regular fa-id-badge"></i>
|
||||||
<div class="tt">
|
<div class="tt">
|
||||||
<h4 class="tt-title">{{ equipped_deck.name }}{% if equipped_deck.is_default %} <span class="token-count">(Default)</span>{% endif %}</h4>
|
<h4 class="tt-title">{{ equipped_deck.name }}{% if equipped_deck.is_default %} <span class="token-count">(Default)</span>{% endif %}</h4>
|
||||||
<p class="tt-description">{{ equipped_deck.card_count }}-card Tarot deck</p>
|
<p class="tt-description">{{ equipped_deck.card_count }}-card Tarot deck</p>
|
||||||
<p class="tt-shoptalk"><em>placeholder comment</em></p>
|
{% if equipped_deck.description %}<p class="tt-shoptalk"><em>{{ equipped_deck.description }}</em></p>{% endif %}
|
||||||
<p class="tt-effect">active</p>
|
<p class="tt-shoptalk">Stock version <span class="tt-subcounter">(0 substitutions)</span></p>
|
||||||
<p class="tt-expiry">Stock version</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="kit-bag-placeholder">
|
||||||
|
<i class="fa-regular fa-id-badge"></i>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="kit-bag-section">
|
<div class="kit-bag-section">
|
||||||
<span class="kit-bag-label">Dice</span>
|
<span class="kit-bag-label">Dice</span>
|
||||||
|
|||||||
@@ -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><span id="id_greeting_name">{% if user.active_title %}{{ user.active_title.slug|capfirst }}{% else %}Earthman{% endif %}</span></h1>
|
<h1><span id="id_greeting_prefix">{% if user.active_title %}{{ user.active_title.display_greeting|safe }}{% else %}Welcome,{% endif %}</span><br><span id="id_greeting_name">{% if user.active_title %}{{ user.active_title.display_title }}{% else %}Earthman{% endif %}</span></h1>
|
||||||
</a>
|
</a>
|
||||||
{% if user.email %}
|
{% if user.email %}
|
||||||
<div class="navbar-user">
|
<div class="navbar-user">
|
||||||
|
|||||||
Reference in New Issue
Block a user