tooltips app TDD spike + kit bag refactor to .tt
- New apps.tooltips: TooltipContent model, {% tooltip data %} inclusion
tag, _tooltip.html partial with .tt/.tt-title/.tt-description etc.
class contract; 34 tests green
- Kit bag panel (_kit_bag_panel.html): .token-tooltip → .tt + child
class renames (tt-title, tt-description, tt-shoptalk, tt-expiry)
- game-kit.js attachTooltip: .token-tooltip → .tt selector
- SCSS: .tt added alongside .token-tooltip for display:none default +
hover rules in _wallet-tokens.scss and _game-kit.scss
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
0
src/apps/tooltips/__init__.py
Normal file
0
src/apps/tooltips/__init__.py
Normal file
10
src/apps/tooltips/admin.py
Normal file
10
src/apps/tooltips/admin.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import TooltipContent
|
||||
|
||||
|
||||
@admin.register(TooltipContent)
|
||||
class TooltipContentAdmin(admin.ModelAdmin):
|
||||
list_display = ('slug', 'title', 'type_label', 'symbol')
|
||||
search_fields = ('slug', 'title', 'type_label')
|
||||
list_filter = ('type_label',)
|
||||
6
src/apps/tooltips/apps.py
Normal file
6
src/apps/tooltips/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class TooltipsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.tooltips'
|
||||
33
src/apps/tooltips/migrations/0001_initial.py
Normal file
33
src/apps/tooltips/migrations/0001_initial.py
Normal file
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 6.0 on 2026-04-16 00:38
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='TooltipContent',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('slug', models.SlugField(unique=True)),
|
||||
('title', models.CharField(max_length=200)),
|
||||
('type_label', models.CharField(blank=True, max_length=100)),
|
||||
('symbol', models.CharField(blank=True, max_length=10)),
|
||||
('degree_str', models.CharField(blank=True, max_length=40)),
|
||||
('description', models.TextField(blank=True)),
|
||||
('shoptalk', models.TextField(blank=True)),
|
||||
('expiry', models.CharField(blank=True, max_length=200)),
|
||||
('effect', models.TextField(blank=True)),
|
||||
('extras', models.JSONField(default=dict)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['slug'],
|
||||
},
|
||||
),
|
||||
]
|
||||
0
src/apps/tooltips/migrations/__init__.py
Normal file
0
src/apps/tooltips/migrations/__init__.py
Normal file
38
src/apps/tooltips/models.py
Normal file
38
src/apps/tooltips/models.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from django.db import models
|
||||
|
||||
|
||||
class TooltipContent(models.Model):
|
||||
"""
|
||||
Static tooltip content for game objects — planets, signs, houses, cards, etc.
|
||||
|
||||
Required: slug (unique lookup key), title (display name).
|
||||
All other fields are optional; omitted fields render nothing in the template.
|
||||
|
||||
extras (JSONField) stores structured data that varies by tooltip type:
|
||||
dignities — dict {role: body/sign string, …}
|
||||
aspects — list [{symbol, type, body, orb}, …]
|
||||
keywords_up / keywords_rev — list of strings (card upright/reversed keywords)
|
||||
cautions — list [{title, type_label, shoptalk, effect}, …]
|
||||
nav — dict {prv: slug, nxt: slug} for PRV/NXT navigation
|
||||
"""
|
||||
|
||||
slug = models.SlugField(unique=True)
|
||||
title = models.CharField(max_length=200)
|
||||
|
||||
# Optional display fields
|
||||
type_label = models.CharField(max_length=100, blank=True)
|
||||
symbol = models.CharField(max_length=10, blank=True)
|
||||
degree_str = models.CharField(max_length=40, blank=True)
|
||||
description = models.TextField(blank=True)
|
||||
shoptalk = models.TextField(blank=True)
|
||||
expiry = models.CharField(max_length=200, blank=True)
|
||||
effect = models.TextField(blank=True)
|
||||
|
||||
# Structured data for tabular / list / nav sections
|
||||
extras = models.JSONField(default=dict)
|
||||
|
||||
class Meta:
|
||||
ordering = ['slug']
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
0
src/apps/tooltips/templatetags/__init__.py
Normal file
0
src/apps/tooltips/templatetags/__init__.py
Normal file
31
src/apps/tooltips/templatetags/tooltip_tags.py
Normal file
31
src/apps/tooltips/templatetags/tooltip_tags.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from django import template
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.inclusion_tag('apps/tooltips/_tooltip.html')
|
||||
def tooltip(data):
|
||||
"""Render the unified tooltip partial from a data dict.
|
||||
|
||||
Minimum required key: 'title'.
|
||||
All other keys are optional; missing keys render nothing.
|
||||
extras (dict) may contain: dignities, aspects, keywords_up,
|
||||
keywords_rev, cautions, nav.
|
||||
"""
|
||||
extras = data.get('extras', {}) or {}
|
||||
return {
|
||||
'title': data.get('title', ''),
|
||||
'type_label': data.get('type_label', ''),
|
||||
'symbol': data.get('symbol', ''),
|
||||
'degree_str': data.get('degree_str', ''),
|
||||
'description': data.get('description', ''),
|
||||
'shoptalk': data.get('shoptalk', ''),
|
||||
'expiry': data.get('expiry', ''),
|
||||
'effect': data.get('effect', ''),
|
||||
'dignities': extras.get('dignities'),
|
||||
'aspects': extras.get('aspects'),
|
||||
'keywords_up': extras.get('keywords_up'),
|
||||
'keywords_rev':extras.get('keywords_rev'),
|
||||
'cautions': extras.get('cautions'),
|
||||
'nav': extras.get('nav'),
|
||||
}
|
||||
0
src/apps/tooltips/tests/__init__.py
Normal file
0
src/apps/tooltips/tests/__init__.py
Normal file
0
src/apps/tooltips/tests/integrated/__init__.py
Normal file
0
src/apps/tooltips/tests/integrated/__init__.py
Normal file
127
src/apps/tooltips/tests/integrated/test_models.py
Normal file
127
src/apps/tooltips/tests/integrated/test_models.py
Normal file
@@ -0,0 +1,127 @@
|
||||
from django.test import TestCase
|
||||
from django.db import IntegrityError
|
||||
|
||||
from apps.tooltips.models import TooltipContent
|
||||
|
||||
|
||||
class TooltipContentModelTest(TestCase):
|
||||
|
||||
# ── Required fields ───────────────────────────────────────────────────────
|
||||
|
||||
def test_can_create_with_slug_and_title_only(self):
|
||||
tt = TooltipContent.objects.create(slug='sun', title='Sun')
|
||||
self.assertEqual(TooltipContent.objects.count(), 1)
|
||||
self.assertEqual(tt.slug, 'sun')
|
||||
self.assertEqual(tt.title, 'Sun')
|
||||
|
||||
def test_slug_is_unique(self):
|
||||
TooltipContent.objects.create(slug='sun', title='Sun')
|
||||
with self.assertRaises(IntegrityError):
|
||||
TooltipContent.objects.create(slug='sun', title='Another Sun')
|
||||
|
||||
def test_str_returns_title(self):
|
||||
tt = TooltipContent(slug='moon', title='Moon')
|
||||
self.assertEqual(str(tt), 'Moon')
|
||||
|
||||
# ── Optional text fields ──────────────────────────────────────────────────
|
||||
|
||||
def test_optional_text_fields_default_blank(self):
|
||||
tt = TooltipContent.objects.create(slug='mars', title='Mars')
|
||||
for field in ('type_label', 'symbol', 'degree_str',
|
||||
'description', 'shoptalk', 'expiry', 'effect'):
|
||||
self.assertEqual(getattr(tt, field), '', msg=f'{field} should default blank')
|
||||
|
||||
def test_can_set_all_text_fields(self):
|
||||
tt = TooltipContent.objects.create(
|
||||
slug='jupiter',
|
||||
title='Jupiter',
|
||||
type_label='Planet',
|
||||
symbol='♃',
|
||||
degree_str='14° 22′ Scorpio',
|
||||
description='Planet of expansion and fortune.',
|
||||
shoptalk='Ruler of Sagittarius.',
|
||||
expiry='',
|
||||
effect='Amplifies whichever house it occupies.',
|
||||
)
|
||||
self.assertEqual(tt.type_label, 'Planet')
|
||||
self.assertEqual(tt.symbol, '♃')
|
||||
self.assertEqual(tt.degree_str, '14° 22′ Scorpio')
|
||||
|
||||
# ── extras JSONField ──────────────────────────────────────────────────────
|
||||
|
||||
def test_extras_defaults_to_empty_dict(self):
|
||||
tt = TooltipContent.objects.create(slug='venus', title='Venus')
|
||||
self.assertEqual(tt.extras, {})
|
||||
|
||||
def test_extras_stores_dignities(self):
|
||||
tt = TooltipContent.objects.create(
|
||||
slug='saturn',
|
||||
title='Saturn',
|
||||
extras={
|
||||
'dignities': {
|
||||
'Domicile': 'Capricorn / Aquarius',
|
||||
'Exalted': 'Libra',
|
||||
'Exile': 'Cancer / Leo',
|
||||
'Fallen': 'Aries',
|
||||
},
|
||||
},
|
||||
)
|
||||
tt.refresh_from_db()
|
||||
self.assertEqual(tt.extras['dignities']['Exalted'], 'Libra')
|
||||
|
||||
def test_extras_stores_aspects(self):
|
||||
tt = TooltipContent.objects.create(
|
||||
slug='mercury',
|
||||
title='Mercury',
|
||||
extras={
|
||||
'aspects': [
|
||||
{'symbol': '△', 'type': 'Trine', 'body': 'Venus', 'orb': '2° 14′'},
|
||||
{'symbol': '□', 'type': 'Square', 'body': 'Mars', 'orb': '0° 42′'},
|
||||
],
|
||||
},
|
||||
)
|
||||
tt.refresh_from_db()
|
||||
self.assertEqual(len(tt.extras['aspects']), 2)
|
||||
self.assertEqual(tt.extras['aspects'][0]['type'], 'Trine')
|
||||
|
||||
def test_extras_stores_keywords(self):
|
||||
tt = TooltipContent.objects.create(
|
||||
slug='the-schizo',
|
||||
title='The Schizo, Leavened',
|
||||
extras={
|
||||
'keywords_up': ['willpower', 'skill', 'resourcefulness'],
|
||||
'keywords_rev': ['hubris', 'overreach'],
|
||||
},
|
||||
)
|
||||
tt.refresh_from_db()
|
||||
self.assertIn('willpower', tt.extras['keywords_up'])
|
||||
|
||||
def test_extras_stores_cautions(self):
|
||||
tt = TooltipContent.objects.create(
|
||||
slug='the-schizo-cautions',
|
||||
title='The Schizo, Leavened',
|
||||
extras={
|
||||
'cautions': [
|
||||
{
|
||||
'title': 'Caution!',
|
||||
'type_label': 'Rival Interaction',
|
||||
'shoptalk': '[Shoptalk forthcoming]',
|
||||
'effect': 'This card will reverse into I. The Pervert...',
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
tt.refresh_from_db()
|
||||
self.assertEqual(tt.extras['cautions'][0]['type_label'], 'Rival Interaction')
|
||||
|
||||
def test_extras_stores_nav(self):
|
||||
"""PRV/NXT nav dict for paired or sequenced tooltips."""
|
||||
tt = TooltipContent.objects.create(
|
||||
slug='house-01',
|
||||
title='House of Self',
|
||||
extras={
|
||||
'nav': {'prv': 'house-12', 'nxt': 'house-02'},
|
||||
},
|
||||
)
|
||||
tt.refresh_from_db()
|
||||
self.assertEqual(tt.extras['nav']['nxt'], 'house-02')
|
||||
172
src/apps/tooltips/tests/integrated/test_templatetags.py
Normal file
172
src/apps/tooltips/tests/integrated/test_templatetags.py
Normal file
@@ -0,0 +1,172 @@
|
||||
from django.template import Context, Template
|
||||
from django.test import TestCase
|
||||
|
||||
|
||||
def render_tooltip(data):
|
||||
"""Helper: render the {% tooltip %} tag with the given data dict."""
|
||||
tpl = Template("{% load tooltip_tags %}{% tooltip data %}")
|
||||
return tpl.render(Context({'data': data}))
|
||||
|
||||
|
||||
class TooltipTagRequiredFieldsTest(TestCase):
|
||||
|
||||
def test_renders_title(self):
|
||||
html = render_tooltip({'title': 'Sun'})
|
||||
self.assertIn('Sun', html)
|
||||
self.assertIn('class="tt-title"', html)
|
||||
|
||||
def test_wrapper_has_tt_class(self):
|
||||
html = render_tooltip({'title': 'Moon'})
|
||||
self.assertIn('class="tt"', html)
|
||||
|
||||
|
||||
class TooltipTagOptionalTextFieldsTest(TestCase):
|
||||
|
||||
def test_renders_type_label_when_present(self):
|
||||
html = render_tooltip({'title': 'Sun', 'type_label': 'Planet'})
|
||||
self.assertIn('Planet', html)
|
||||
self.assertIn('tt-type', html)
|
||||
|
||||
def test_omits_type_label_when_absent(self):
|
||||
html = render_tooltip({'title': 'Sun'})
|
||||
self.assertNotIn('tt-type', html)
|
||||
|
||||
def test_renders_symbol_when_present(self):
|
||||
html = render_tooltip({'title': 'Sun', 'symbol': '☉'})
|
||||
self.assertIn('☉', html)
|
||||
self.assertIn('tt-symbol', html)
|
||||
|
||||
def test_omits_symbol_when_absent(self):
|
||||
html = render_tooltip({'title': 'Sun'})
|
||||
self.assertNotIn('tt-symbol', html)
|
||||
|
||||
def test_renders_degree_str_when_present(self):
|
||||
html = render_tooltip({'title': 'Sun', 'degree_str': '14° 22′ Scorpio'})
|
||||
self.assertIn('14° 22′ Scorpio', html)
|
||||
self.assertIn('tt-degree', html)
|
||||
|
||||
def test_omits_degree_str_when_absent(self):
|
||||
html = render_tooltip({'title': 'Sun'})
|
||||
self.assertNotIn('tt-degree', html)
|
||||
|
||||
def test_renders_description_when_present(self):
|
||||
html = render_tooltip({'title': 'Sun', 'description': 'Planet of vitality.'})
|
||||
self.assertIn('Planet of vitality.', html)
|
||||
self.assertIn('tt-description', html)
|
||||
|
||||
def test_renders_shoptalk_in_em_when_present(self):
|
||||
html = render_tooltip({'title': 'Sun', 'shoptalk': 'Ruler of Leo.'})
|
||||
self.assertIn('Ruler of Leo.', html)
|
||||
self.assertIn('tt-shoptalk', html)
|
||||
self.assertIn('<em>', html)
|
||||
|
||||
def test_renders_expiry_when_present(self):
|
||||
html = render_tooltip({'title': 'Pass', 'expiry': 'no expiry'})
|
||||
self.assertIn('no expiry', html)
|
||||
self.assertIn('tt-expiry', html)
|
||||
|
||||
def test_renders_effect_when_present(self):
|
||||
html = render_tooltip({'title': 'Pass', 'effect': 'Admit All Entry'})
|
||||
self.assertIn('Admit All Entry', html)
|
||||
self.assertIn('tt-effect', html)
|
||||
|
||||
|
||||
class TooltipTagExtrasTest(TestCase):
|
||||
|
||||
def test_renders_dignities_table_when_present(self):
|
||||
data = {
|
||||
'title': 'Saturn',
|
||||
'extras': {
|
||||
'dignities': {
|
||||
'Domicile': 'Capricorn',
|
||||
'Exalted': 'Libra',
|
||||
},
|
||||
},
|
||||
}
|
||||
html = render_tooltip(data)
|
||||
self.assertIn('tt-table--dignities', html)
|
||||
self.assertIn('Capricorn', html)
|
||||
self.assertIn('Exalted', html)
|
||||
|
||||
def test_omits_dignities_when_absent(self):
|
||||
html = render_tooltip({'title': 'Sun', 'extras': {}})
|
||||
self.assertNotIn('tt-table--dignities', html)
|
||||
|
||||
def test_renders_aspects_list_when_present(self):
|
||||
data = {
|
||||
'title': 'Mercury',
|
||||
'extras': {
|
||||
'aspects': [
|
||||
{'symbol': '△', 'type': 'Trine', 'body': 'Venus', 'orb': '2° 14′'},
|
||||
],
|
||||
},
|
||||
}
|
||||
html = render_tooltip(data)
|
||||
self.assertIn('tt-aspects', html)
|
||||
self.assertIn('Trine', html)
|
||||
self.assertIn('Venus', html)
|
||||
|
||||
def test_omits_aspects_when_absent(self):
|
||||
html = render_tooltip({'title': 'Sun', 'extras': {}})
|
||||
self.assertNotIn('tt-aspects', html)
|
||||
|
||||
def test_renders_keyword_lists_when_present(self):
|
||||
data = {
|
||||
'title': 'The Schizo',
|
||||
'extras': {
|
||||
'keywords_up': ['willpower', 'skill'],
|
||||
'keywords_rev': ['hubris'],
|
||||
},
|
||||
}
|
||||
html = render_tooltip(data)
|
||||
self.assertIn('tt-keywords', html)
|
||||
self.assertIn('willpower', html)
|
||||
self.assertIn('hubris', html)
|
||||
|
||||
def test_omits_keywords_when_absent(self):
|
||||
html = render_tooltip({'title': 'Sun', 'extras': {}})
|
||||
self.assertNotIn('tt-keywords', html)
|
||||
|
||||
def test_renders_fyi_cautions_section_when_present(self):
|
||||
data = {
|
||||
'title': 'The Schizo',
|
||||
'extras': {
|
||||
'cautions': [
|
||||
{
|
||||
'title': 'Caution!',
|
||||
'type_label': 'Rival Interaction',
|
||||
'shoptalk': '[Shoptalk forthcoming]',
|
||||
'effect': 'This card will reverse...',
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
html = render_tooltip(data)
|
||||
self.assertIn('tt-fyi', html)
|
||||
self.assertIn('tt-fyi--cautions', html)
|
||||
self.assertIn('Rival Interaction', html)
|
||||
|
||||
def test_omits_cautions_section_when_absent(self):
|
||||
html = render_tooltip({'title': 'Sun', 'extras': {}})
|
||||
self.assertNotIn('tt-fyi--cautions', html)
|
||||
|
||||
def test_renders_nav_when_present(self):
|
||||
data = {
|
||||
'title': 'House of Self',
|
||||
'extras': {
|
||||
'nav': {'prv': 'house-12', 'nxt': 'house-02'},
|
||||
},
|
||||
}
|
||||
html = render_tooltip(data)
|
||||
self.assertIn('tt-nav', html)
|
||||
self.assertIn('house-02', html)
|
||||
self.assertIn('house-12', html)
|
||||
|
||||
def test_omits_nav_when_absent(self):
|
||||
html = render_tooltip({'title': 'Sun', 'extras': {}})
|
||||
self.assertNotIn('tt-nav', html)
|
||||
|
||||
def test_no_extras_key_does_not_error(self):
|
||||
"""Tag should handle data with no 'extras' key gracefully."""
|
||||
html = render_tooltip({'title': 'Sun'})
|
||||
self.assertIn('Sun', html)
|
||||
Reference in New Issue
Block a user