807 lines
30 KiB
Python
807 lines
30 KiB
Python
import unittest
|
|
from functools import lru_cache
|
|
|
|
import django
|
|
|
|
import os
|
|
|
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "timetracker.settings")
|
|
django.setup()
|
|
|
|
from django.template import TemplateDoesNotExist
|
|
from django.utils.safestring import SafeText
|
|
|
|
from common import components
|
|
from games.models import Platform, Game, Purchase, Session
|
|
|
|
|
|
class RenderCachedImplTest(unittest.TestCase):
|
|
"""Test _render_cached_impl renders templates correctly."""
|
|
|
|
def test_basic_render(self):
|
|
result = components._render_cached_impl(
|
|
"cotton/icon/play.html",
|
|
'{"slot": "", "title": "Play"}',
|
|
)
|
|
self.assertIn("<svg", result)
|
|
self.assertIn("</svg>", result)
|
|
|
|
def test_slot_marked_safe(self):
|
|
result = components._render_cached_impl(
|
|
"cotton/icon/play.html",
|
|
'{"slot": "<b>bold</b>", "title": "Play"}',
|
|
)
|
|
self.assertIsInstance(result, SafeText)
|
|
|
|
def test_different_templates_different_output(self):
|
|
r1 = components._render_cached_impl(
|
|
"cotton/icon/play.html", '{"slot": "", "title": "Play"}',
|
|
)
|
|
r2 = components._render_cached_impl(
|
|
"cotton/icon/delete.html", '{"slot": "", "title": "Delete"}',
|
|
)
|
|
self.assertNotEqual(r1, r2)
|
|
|
|
def test_nonexistent_template_raises(self):
|
|
with self.assertRaises(TemplateDoesNotExist):
|
|
components._render_cached_impl(
|
|
"cotton/nonexistent.html", '{"slot": "", "title": "X"}',
|
|
)
|
|
|
|
def test_context_keys_are_sorted(self):
|
|
"""Verify sort_keys=True in Component produces consistent JSON."""
|
|
from common.components import Component
|
|
r1 = Component(
|
|
template="cotton/icon/play.html",
|
|
attributes=[("title", "Play"), ("b", "2")],
|
|
)
|
|
r2 = Component(
|
|
template="cotton/icon/play.html",
|
|
attributes=[("b", "2"), ("title", "Play")],
|
|
)
|
|
self.assertEqual(r1, r2)
|
|
|
|
|
|
class RenderCachedLRUTest(unittest.TestCase):
|
|
"""Test LRU cache behavior of _render_cached when enabled."""
|
|
|
|
def setUp(self):
|
|
components.enable_cache()
|
|
components._render_cached.cache_clear()
|
|
|
|
def tearDown(self):
|
|
components._render_cached = components._render_cached_impl
|
|
|
|
def test_cache_hits_and_misses(self):
|
|
# Call through _render_cached (the cached wrapper), not _render_cached_impl
|
|
components._render_cached(
|
|
"cotton/icon/play.html", '{"slot": "", "title": "Play"}',
|
|
)
|
|
info = components._render_cached.cache_info()
|
|
self.assertEqual(info.hits, 0)
|
|
self.assertEqual(info.misses, 1)
|
|
|
|
components._render_cached(
|
|
"cotton/icon/play.html", '{"slot": "", "title": "Play"}',
|
|
)
|
|
info = components._render_cached.cache_info()
|
|
self.assertEqual(info.hits, 1)
|
|
self.assertEqual(info.misses, 1)
|
|
|
|
def test_cache_clear(self):
|
|
components._render_cached_impl(
|
|
"cotton/icon/play.html", '{"slot": "", "title": "Play"}',
|
|
)
|
|
components._render_cached.cache_clear()
|
|
info = components._render_cached.cache_info()
|
|
self.assertEqual(info.currsize, 0)
|
|
self.assertEqual(info.hits, 0)
|
|
|
|
def test_cache_parameters(self):
|
|
info = components._render_cached.cache_info()
|
|
self.assertEqual(components._render_cached.cache_parameters()["maxsize"], 4096)
|
|
|
|
def test_different_contexts_different_entries(self):
|
|
# Call through _render_cached (the cached wrapper), not _render_cached_impl
|
|
components._render_cached(
|
|
"cotton/button.html",
|
|
'{"size": "base", "color": "blue", "icon": false, "class": "hover:cursor-pointer", "slot": ""}',
|
|
)
|
|
components._render_cached(
|
|
"cotton/button.html",
|
|
'{"size": "base", "color": "red", "icon": false, "class": "hover:cursor-pointer", "slot": ""}',
|
|
)
|
|
info = components._render_cached.cache_info()
|
|
self.assertEqual(info.currsize, 2)
|
|
|
|
def test_cache_size_limited(self):
|
|
"""After exceeding maxsize, oldest entries are evicted."""
|
|
for i in range(5000):
|
|
components._render_cached_impl(
|
|
f"cotton/icon/play.html",
|
|
f'{{"slot": "", "title": "{i}"}}',
|
|
)
|
|
info = components._render_cached.cache_info()
|
|
self.assertLessEqual(info.currsize, 4096)
|
|
|
|
|
|
class ComponentIntegrationTest(unittest.TestCase):
|
|
"""Test Component() works correctly with caching transparent."""
|
|
|
|
def setUp(self):
|
|
components.enable_cache()
|
|
components._render_cached.cache_clear()
|
|
|
|
def tearDown(self):
|
|
components._render_cached = components._render_cached_impl
|
|
|
|
def test_template_component(self):
|
|
result = components.Component(
|
|
template="cotton/icon/play.html", attributes=[],
|
|
)
|
|
self.assertIn("<svg", result)
|
|
self.assertIn("</svg>", result)
|
|
|
|
def test_tag_name_component(self):
|
|
result = components.Component(
|
|
tag_name="div",
|
|
attributes=[("class", "test")],
|
|
children="hello",
|
|
)
|
|
self.assertEqual(result, '<div class="test">hello</div>')
|
|
|
|
def test_repeated_calls_identical(self):
|
|
r1 = components.Component(
|
|
template="cotton/icon/play.html", attributes=[],
|
|
)
|
|
r2 = components.Component(
|
|
template="cotton/icon/play.html", attributes=[],
|
|
)
|
|
self.assertEqual(r1, r2)
|
|
|
|
def test_different_components_different(self):
|
|
r1 = components.Component(
|
|
template="cotton/button.html", attributes=[("hx_get", "/url1")],
|
|
)
|
|
r2 = components.Component(
|
|
template="cotton/button.html", attributes=[("hx_get", "/url2")],
|
|
)
|
|
self.assertNotEqual(r1, r2)
|
|
|
|
|
|
class RandomidDeterministicTest(unittest.TestCase):
|
|
"""Test that randomid() produces deterministic, reproducible IDs."""
|
|
|
|
def test_same_content_same_id(self):
|
|
r1 = components.randomid(content="foo")
|
|
r2 = components.randomid(content="foo")
|
|
self.assertEqual(r1, r2)
|
|
|
|
def test_different_content_different_id(self):
|
|
r1 = components.randomid(content="foo")
|
|
r2 = components.randomid(content="bar")
|
|
self.assertNotEqual(r1, r2)
|
|
|
|
def test_seed_prepended(self):
|
|
result = components.randomid(seed="a", content="x")
|
|
self.assertTrue(result.startswith("a"))
|
|
|
|
def test_seed_respects_length(self):
|
|
result = components.randomid(seed="ab", content="x", length=10)
|
|
self.assertEqual(len(result), 10)
|
|
|
|
def test_empty_input_returns_empty(self):
|
|
self.assertEqual(components.randomid(), "")
|
|
|
|
def test_output_is_lowercase_alphanum(self):
|
|
result = components.randomid(content="test")
|
|
self.assertTrue(all(c in "abcdefghijklmnopqrstuvwxyz0123456789" for c in result))
|
|
|
|
def test_output_length_is_correct(self):
|
|
for length in [5, 10, 15, 20]:
|
|
result = components.randomid(content="test", length=length)
|
|
self.assertEqual(len(result), length)
|
|
|
|
def test_hash_reproducible_across_calls(self):
|
|
results = [components.randomid(content="reproducible_test") for _ in range(100)]
|
|
self.assertEqual(len(set(results)), 1)
|
|
|
|
|
|
class RandomidVsOldBehaviorTest(unittest.TestCase):
|
|
"""Prove the new hash-based approach is deterministic while the old random approach was not."""
|
|
|
|
def _old_random_id(self, seed="", length=10):
|
|
from random import choices
|
|
from string import ascii_lowercase
|
|
return seed + "".join(choices(ascii_lowercase, k=length))
|
|
|
|
def test_old_random_produces_different_ids(self):
|
|
results = [self._old_random_id() for _ in range(50)]
|
|
self.assertEqual(len(set(results)), 50)
|
|
|
|
def test_new_hash_produces_same_id(self):
|
|
results = [components.randomid(content="determinism_test") for _ in range(50)]
|
|
self.assertEqual(len(set(results)), 1)
|
|
|
|
def test_new_hash_deterministic_per_content(self):
|
|
results = [components.randomid(content=c) for c in ["aaa", "bbb", "ccc"]]
|
|
self.assertEqual(len(set(results)), 3)
|
|
|
|
|
|
class PopoverDeterministicTest(unittest.TestCase):
|
|
"""Test that Popover() produces deterministic HTML output."""
|
|
|
|
def setUp(self):
|
|
components.enable_cache()
|
|
components._render_cached.cache_clear()
|
|
|
|
def tearDown(self):
|
|
components._render_cached = components._render_cached_impl
|
|
|
|
def test_same_popover_same_id(self):
|
|
r1 = components.Popover("hello", wrapped_content="hello")
|
|
r2 = components.Popover("hello", wrapped_content="hello")
|
|
self.assertEqual(r1, r2)
|
|
|
|
def test_different_content_different_id(self):
|
|
r1 = components.Popover("content_a", wrapped_content="content_a")
|
|
r2 = components.Popover("content_b", wrapped_content="content_b")
|
|
self.assertNotEqual(r1, r2)
|
|
|
|
def test_wrapped_classes_affect_id(self):
|
|
r1 = components.Popover("c", wrapped_content="c", wrapped_classes="class_x")
|
|
r2 = components.Popover("c", wrapped_content="c", wrapped_classes="class_y")
|
|
self.assertNotEqual(r1, r2)
|
|
|
|
def test_wrapped_content_affects_id(self):
|
|
r1 = components.Popover("popover", wrapped_content="wrapped_a")
|
|
r2 = components.Popover("popover", wrapped_content="wrapped_b")
|
|
self.assertNotEqual(r1, r2)
|
|
|
|
def test_popover_content_affects_id(self):
|
|
r1 = components.Popover("popover_a", wrapped_content="wrapped")
|
|
r2 = components.Popover("popover_b", wrapped_content="wrapped")
|
|
self.assertNotEqual(r1, r2)
|
|
|
|
def test_full_html_deterministic(self):
|
|
r1 = components.Popover("hello world", wrapped_content="hello world")
|
|
r2 = components.Popover("hello world", wrapped_content="hello world")
|
|
self.assertEqual(r1.encode(), r2.encode())
|
|
|
|
|
|
class PopoverCacheIntegrationTest(unittest.TestCase):
|
|
"""Test that Popover() output works correctly with LRU caching."""
|
|
|
|
def setUp(self):
|
|
components.enable_cache()
|
|
components._render_cached.cache_clear()
|
|
|
|
def tearDown(self):
|
|
components._render_cached = components._render_cached_impl
|
|
|
|
def _get_popover_context(self, popover_content, wrapped_content="", wrapped_classes=""):
|
|
"""Build the context JSON that _render_cached would receive for a Popover."""
|
|
import json
|
|
content = f"{wrapped_content}:{popover_content}:{wrapped_classes}"
|
|
id = components.randomid(content=content)
|
|
context = {
|
|
"id": id,
|
|
"wrapped_content": wrapped_content,
|
|
"popover_content": popover_content,
|
|
"wrapped_classes": wrapped_classes,
|
|
"slot": "",
|
|
}
|
|
return json.dumps(context, sort_keys=True)
|
|
|
|
def test_popover_first_call_no_cache_hit(self):
|
|
components.Popover("test_content", wrapped_content="test_content")
|
|
info = components._render_cached.cache_info()
|
|
self.assertEqual(info.hits, 0)
|
|
|
|
def test_popover_second_call_cache_hit(self):
|
|
components.Popover("test_content", wrapped_content="test_content")
|
|
info = components._render_cached.cache_info()
|
|
self.assertEqual(info.hits, 0)
|
|
components.Popover("test_content", wrapped_content="test_content")
|
|
info = components._render_cached.cache_info()
|
|
self.assertEqual(info.hits, 1)
|
|
|
|
def test_popover_different_content_different_entry(self):
|
|
components.Popover("content_a", wrapped_content="content_a")
|
|
components.Popover("content_b", wrapped_content="content_b")
|
|
info = components._render_cached.cache_info()
|
|
self.assertEqual(info.currsize, 2)
|
|
|
|
def test_popover_repeated_call_increments_hits(self):
|
|
for _ in range(5):
|
|
components.Popover("repeated_test", wrapped_content="repeated_test")
|
|
info = components._render_cached.cache_info()
|
|
self.assertEqual(info.hits, 4)
|
|
|
|
|
|
class TemplatetagRandomidTest(unittest.TestCase):
|
|
"""Test games/templatetags/randomid.py produces deterministic IDs."""
|
|
|
|
def test_same_seed_same_id(self):
|
|
from games.templatetags import randomid
|
|
r1 = randomid.randomid(seed="foo")
|
|
r2 = randomid.randomid(seed="foo")
|
|
self.assertEqual(r1, r2)
|
|
|
|
def test_different_seed_different_id(self):
|
|
from games.templatetags import randomid
|
|
r1 = randomid.randomid(seed="foo")
|
|
r2 = randomid.randomid(seed="bar")
|
|
self.assertNotEqual(r1, r2)
|
|
|
|
def test_output_length_ten(self):
|
|
from games.templatetags import randomid
|
|
for seed in ["a", "hello", "test1234"]:
|
|
result = randomid.randomid(seed=seed)
|
|
self.assertEqual(len(result), 10)
|
|
|
|
def test_empty_seed_returns_hash(self):
|
|
from games.templatetags import randomid
|
|
result = randomid.randomid()
|
|
self.assertEqual(len(result), 10)
|
|
self.assertTrue(all(c in "abcdef0123456789" for c in result))
|
|
|
|
|
|
class ComponentReturnTypeTest(unittest.TestCase):
|
|
"""Test that component functions return SafeText and render correctly."""
|
|
|
|
def setUp(self):
|
|
components.enable_cache()
|
|
components._render_cached.cache_clear()
|
|
|
|
def tearDown(self):
|
|
components._render_cached = components._render_cached_impl
|
|
|
|
def test_div_returns_safe_text(self):
|
|
result = components.Div([("class", "x")], "hello")
|
|
self.assertIsInstance(result, SafeText)
|
|
|
|
def test_div_deterministic(self):
|
|
r1 = components.Div([("class", "x")], "hello")
|
|
r2 = components.Div([("class", "x")], "hello")
|
|
self.assertEqual(r1, r2)
|
|
self.assertIn('<div class="x">hello</div>', r1)
|
|
|
|
def test_div_no_args(self):
|
|
result = components.Div(children="test")
|
|
self.assertIsInstance(result, SafeText)
|
|
self.assertIn('<div>test</div>', result)
|
|
|
|
def test_a_returns_safe_text(self):
|
|
result = components.A([], "link")
|
|
self.assertIsInstance(result, SafeText)
|
|
|
|
def test_a_literal_href(self):
|
|
result = components.A([], "x", href="/literal/path")
|
|
self.assertIn('href="/literal/path"', result)
|
|
|
|
def test_a_url_name_reversed(self):
|
|
from unittest.mock import patch
|
|
with patch("common.components.reverse", return_value="/resolved/url"):
|
|
result = components.A([], "link", url_name="some_name")
|
|
self.assertIn('href="/resolved/url"', result)
|
|
|
|
def test_a_no_url_or_href(self):
|
|
result = components.A([], "link")
|
|
self.assertIn('<a>link</a>', result)
|
|
self.assertNotIn('href=', result)
|
|
|
|
def test_a_both_url_name_and_href_raises(self):
|
|
with self.assertRaises(ValueError):
|
|
components.A(href="/path", url_name="some_name")
|
|
|
|
def test_button_returns_safe_text(self):
|
|
result = components.Button([], "click")
|
|
self.assertIsInstance(result, SafeText)
|
|
self.assertIn("<button", result)
|
|
|
|
def test_button_default_colors(self):
|
|
result = components.Button([], "click")
|
|
self.assertIn("text-white bg-brand", result)
|
|
|
|
def test_name_with_icon_no_link(self):
|
|
result = components.NameWithIcon(name="Game", platform="Steam", linkify=False)
|
|
self.assertIsInstance(result, SafeText)
|
|
self.assertIn("Game", result)
|
|
self.assertNotIn("<a ", result)
|
|
|
|
def test_name_with_icon_no_trailing_comma(self):
|
|
result = components.NameWithIcon(name="Test", platform="Steam", linkify=False)
|
|
self.assertIsInstance(result, SafeText)
|
|
self.assertNotIsInstance(result, tuple)
|
|
|
|
|
|
class ComponentEdgeCasesTest(unittest.TestCase):
|
|
"""Test Component() edge cases and error handling."""
|
|
|
|
def test_no_template_or_tag_name_raises(self):
|
|
with self.assertRaises(ValueError) as ctx:
|
|
components.Component(children="hello")
|
|
self.assertIn("template or tag_name", str(ctx.exception))
|
|
|
|
def test_single_string_children_wrapped(self):
|
|
result = components.Component(tag_name="span", children="hello")
|
|
self.assertIn("hello", result)
|
|
|
|
def test_multiple_children_joined_with_newlines(self):
|
|
result = components.Component(
|
|
tag_name="div", children=["<span>a</span>", "<span>b</span>"]
|
|
)
|
|
self.assertIn("<span>a</span>", result)
|
|
self.assertIn("<span>b</span>", result)
|
|
self.assertIn("<div>", result)
|
|
self.assertIn("</div>", result)
|
|
|
|
def test_attributes_serialized_correctly(self):
|
|
result = components.Component(
|
|
tag_name="div", attributes=[("class", "foo"), ("id", "bar")]
|
|
)
|
|
self.assertIn('class="foo"', result)
|
|
self.assertIn('id="bar"', result)
|
|
|
|
def test_empty_attributes_no_extra_space(self):
|
|
result = components.Component(tag_name="span", children="x")
|
|
self.assertEqual(result, "<span>x</span>")
|
|
self.assertNotIn(" <span", result)
|
|
|
|
def test_non_string_children_not_supported(self):
|
|
"""Component only accepts str for children, not integers."""
|
|
result = components.Component(tag_name="span", children=str(42))
|
|
self.assertIn("42", result)
|
|
|
|
def test_returns_safetext(self):
|
|
result = components.Component(tag_name="div", children="test")
|
|
self.assertIsInstance(result, SafeText)
|
|
|
|
|
|
class IconTest(unittest.TestCase):
|
|
"""Test Icon() component function."""
|
|
|
|
def setUp(self):
|
|
components.enable_cache()
|
|
components._render_cached.cache_clear()
|
|
|
|
def tearDown(self):
|
|
components._render_cached = components._render_cached_impl
|
|
|
|
def test_valid_icon_renders_svg(self):
|
|
result = components.Icon("play")
|
|
self.assertIsInstance(result, SafeText)
|
|
self.assertIn("<svg", result)
|
|
self.assertIn("</svg>", result)
|
|
|
|
def test_unavailable_icon_falls_back(self):
|
|
result = components.Icon("zzz_nonexistent_platform")
|
|
self.assertIsInstance(result, SafeText)
|
|
self.assertIn("<svg", result)
|
|
|
|
def test_icon_passes_attributes_to_template(self):
|
|
result = components.Icon("play", attributes=[("title", "Play")])
|
|
self.assertIsInstance(result, SafeText)
|
|
|
|
def test_returns_safetext(self):
|
|
result = components.Icon("delete")
|
|
self.assertIsInstance(result, SafeText)
|
|
|
|
|
|
class FormInputTest(unittest.TestCase):
|
|
"""Test Form(), Input(), and Div() functions."""
|
|
|
|
def test_form_default_method_get(self):
|
|
result = components.Form()
|
|
self.assertIn('method="get"', result)
|
|
self.assertIn('<form', result)
|
|
|
|
def test_form_post_method(self):
|
|
result = components.Form(method="post")
|
|
self.assertIn('method="post"', result)
|
|
self.assertIn('<form', result)
|
|
|
|
def test_form_action(self):
|
|
result = components.Form(action="/submit/")
|
|
self.assertIn('action="/submit/"', result)
|
|
|
|
def test_form_children_rendered(self):
|
|
child = components.Input(type="text", attributes=[("name", "email")])
|
|
result = components.Form(children=[child])
|
|
self.assertIn('<input', result)
|
|
self.assertIn('type="text"', result)
|
|
self.assertIn('name="email"', result)
|
|
|
|
def test_input_default_type_text(self):
|
|
result = components.Input()
|
|
self.assertIn('<input', result)
|
|
self.assertIn('type="text"', result)
|
|
|
|
def test_input_custom_type(self):
|
|
result = components.Input(type="submit")
|
|
self.assertIn('type="submit"', result)
|
|
|
|
def test_input_attributes_merged_with_type(self):
|
|
result = components.Input(type="email", attributes=[("id", "email"), ("class", "form-input")])
|
|
self.assertIn('type="email"', result)
|
|
self.assertIn('id="email"', result)
|
|
self.assertIn('class="form-input"', result)
|
|
|
|
|
|
class PopoverTruncatedTest(unittest.TestCase):
|
|
"""Test PopoverTruncated() component function."""
|
|
|
|
def setUp(self):
|
|
components.enable_cache()
|
|
components._render_cached.cache_clear()
|
|
|
|
def tearDown(self):
|
|
components._render_cached = components._render_cached_impl
|
|
|
|
def test_short_string_no_popover(self):
|
|
result = components.PopoverTruncated("hi")
|
|
self.assertEqual(result, "hi")
|
|
|
|
def test_long_string_wrapped_in_popover(self):
|
|
long_text = "a" * 100
|
|
result = components.PopoverTruncated(long_text)
|
|
# Should NOT equal the truncated form directly
|
|
truncated = components.truncate(long_text, 30)
|
|
self.assertNotEqual(result, truncated)
|
|
# Should contain popover markers
|
|
self.assertIn("data-popover-target", result)
|
|
|
|
def test_custom_ellipsis_used(self):
|
|
long_text = "a" * 50
|
|
result = components.PopoverTruncated(long_text, ellipsis=">>")
|
|
# Django template escapes >> to >> in the wrapped_content
|
|
self.assertIn(">>", result)
|
|
|
|
def test_popover_if_not_truncated_flag(self):
|
|
short_text = "hi"
|
|
result = components.PopoverTruncated(
|
|
short_text, popover_content="full content", popover_if_not_truncated=True
|
|
)
|
|
# Should be wrapped in popover even though short
|
|
self.assertNotEqual(result, "hi")
|
|
self.assertIn("data-popover-target", result)
|
|
|
|
def test_popover_content_override(self):
|
|
result = components.PopoverTruncated("short", popover_content="custom popover")
|
|
# With popover_if_not_truncated=False (default), short text returns as-is
|
|
self.assertEqual(result, "short")
|
|
|
|
def test_popover_content_override_with_flag(self):
|
|
result = components.PopoverTruncated(
|
|
"short", popover_content="custom popover", popover_if_not_truncated=True
|
|
)
|
|
self.assertIn("custom popover", result)
|
|
|
|
def test_endpart_visible_in_output(self):
|
|
long_text = "a" * 50
|
|
result = components.PopoverTruncated(long_text, endpart="...")
|
|
self.assertIn("...", result)
|
|
|
|
def test_returns_safetext(self):
|
|
result = components.PopoverTruncated("a" * 100)
|
|
self.assertIsInstance(result, SafeText)
|
|
|
|
def test_default_length(self):
|
|
text = "a" * 31
|
|
result = components.PopoverTruncated(text)
|
|
# 31 chars exceeds default length of 30, so should be truncated
|
|
self.assertIn("data-popover-target", result)
|
|
|
|
def test_length_zero(self):
|
|
result = components.PopoverTruncated("hello", length=0)
|
|
# Even empty length triggers popover for any content
|
|
self.assertIn("data-popover-target", result)
|
|
|
|
|
|
class EnableCacheTest(unittest.TestCase):
|
|
"""Test enable_cache() function."""
|
|
|
|
def test_wraps_with_lru_cache(self):
|
|
components.enable_cache()
|
|
# Should have cache_info method
|
|
self.assertTrue(hasattr(components._render_cached, "cache_info"))
|
|
|
|
def test_cache_has_correct_maxsize(self):
|
|
components.enable_cache()
|
|
params = components._render_cached.cache_parameters()
|
|
self.assertEqual(params["maxsize"], 4096)
|
|
|
|
|
|
class ModelDependentComponentsTest(unittest.TestCase):
|
|
"""Test components that depend on Django models."""
|
|
|
|
@staticmethod
|
|
def _create_platform(name="Steam", icon="steam"):
|
|
return Platform.objects.create(name=name, icon=icon)
|
|
|
|
@staticmethod
|
|
def _create_game(platform, name="Test Game"):
|
|
return Game.objects.create(name=name, platform=platform)
|
|
|
|
@staticmethod
|
|
def _create_purchase(games, platform=None, price=19.99):
|
|
purchase = Purchase.objects.create(
|
|
platform=platform or (games[0].platform if games else None),
|
|
date_purchased="2025-01-01",
|
|
price=price,
|
|
price_currency="USD",
|
|
converted_price=price,
|
|
converted_currency="USD",
|
|
)
|
|
purchase.games.set(games)
|
|
return purchase
|
|
|
|
@classmethod
|
|
def tearDownClass(cls):
|
|
super().tearDownClass()
|
|
Game.objects.all().delete()
|
|
Purchase.objects.all().delete()
|
|
Session.objects.all().delete()
|
|
Platform.objects.all().delete()
|
|
|
|
def test_name_with_icon_linkify_with_game(self):
|
|
platform = self._create_platform(name="Steam", icon="steam")
|
|
game = self._create_game(platform)
|
|
result = components.NameWithIcon(game_id=game.pk, linkify=True)
|
|
self.assertIsInstance(result, SafeText)
|
|
self.assertIn("<a ", result)
|
|
self.assertIn("Test Game", result)
|
|
self.assertIn("/tracker/game/", result)
|
|
|
|
def test_name_with_icon_no_linkify(self):
|
|
platform = self._create_platform(name="GOG", icon="gog")
|
|
game = self._create_game(platform)
|
|
result = components.NameWithIcon(name="Test Game", game_id=game.pk, linkify=False)
|
|
self.assertIsInstance(result, SafeText)
|
|
self.assertNotIn("<a ", result)
|
|
self.assertIn("Test Game", result)
|
|
|
|
def test_name_with_icon_emulated_flag(self):
|
|
platform = self._create_platform(icon="steam")
|
|
game = self._create_game(platform)
|
|
session = Session.objects.create(
|
|
game=game,
|
|
timestamp_start="2025-01-01 00:00:00+00:00",
|
|
emulated=True,
|
|
)
|
|
result = components.NameWithIcon(session_id=session.pk, linkify=True)
|
|
self.assertIsInstance(result, SafeText)
|
|
self.assertIn("<a ", result)
|
|
|
|
def test_name_with_icon_no_platform(self):
|
|
result = components.NameWithIcon(name="Standalone", platform="", linkify=False)
|
|
self.assertIsInstance(result, SafeText)
|
|
self.assertIn("Standalone", result)
|
|
|
|
def test_name_with_icon_session_fetches_game(self):
|
|
platform = self._create_platform(icon="egs")
|
|
game = self._create_game(platform, name="Epic Game")
|
|
session = Session.objects.create(
|
|
game=game,
|
|
timestamp_start="2025-01-01 00:00:00+00:00",
|
|
)
|
|
result = components.NameWithIcon(session_id=session.pk, linkify=True)
|
|
self.assertIsInstance(result, SafeText)
|
|
self.assertIn("Epic Game", result)
|
|
|
|
def test_purchase_price_renders_currency(self):
|
|
platform = self._create_platform()
|
|
game = self._create_game(platform)
|
|
purchase = self._create_purchase([game], price=29.99)
|
|
result = components.PurchasePrice(purchase)
|
|
self.assertIsInstance(result, SafeText)
|
|
# floatformat rounds to 1 decimal: 29.99 -> 30.0
|
|
self.assertIn("30.0", result)
|
|
self.assertIn("USD", result)
|
|
self.assertIn("data-popover-target", result)
|
|
|
|
def test_linked_purchase_single_game(self):
|
|
platform = self._create_platform(icon="steam")
|
|
game = self._create_game(platform, name="Single Game")
|
|
purchase = self._create_purchase([game], price=14.99)
|
|
result = components.LinkedPurchase(purchase)
|
|
self.assertIsInstance(result, SafeText)
|
|
self.assertIn("Single Game", result)
|
|
self.assertIn("<a ", result)
|
|
self.assertIn("/tracker/purchase/", result)
|
|
|
|
def test_linked_purchase_multiple_games(self):
|
|
platform = self._create_platform(icon="steam")
|
|
game1 = self._create_game(platform, name="Game One")
|
|
game2 = self._create_game(platform, name="Game Two")
|
|
purchase = self._create_purchase([game1, game2], price=24.99)
|
|
result = components.LinkedPurchase(purchase)
|
|
self.assertIsInstance(result, SafeText)
|
|
self.assertIn("2 games", result)
|
|
self.assertIn("<a ", result)
|
|
self.assertIn("/tracker/purchase/", result)
|
|
|
|
def test_linked_purchase_with_name(self):
|
|
platform = self._create_platform(icon="steam")
|
|
game1 = self._create_game(platform, name="Game A")
|
|
game2 = self._create_game(platform, name="Game B")
|
|
purchase = self._create_purchase(
|
|
[game1, game2], price=24.99,
|
|
)
|
|
purchase.name = "Bundle"
|
|
purchase.save()
|
|
result = components.LinkedPurchase(purchase)
|
|
self.assertIsInstance(result, SafeText)
|
|
self.assertIn("Bundle", result)
|
|
|
|
def test_linked_purchase_renders_game_names_in_popover(self):
|
|
platform = self._create_platform(icon="steam")
|
|
game1 = self._create_game(platform, name="Alpha")
|
|
game2 = self._create_game(platform, name="Beta")
|
|
purchase = self._create_purchase([game1, game2], price=19.99)
|
|
result = components.LinkedPurchase(purchase)
|
|
self.assertIsInstance(result, SafeText)
|
|
self.assertIn("Alpha", result)
|
|
self.assertIn("Beta", result)
|
|
|
|
|
|
class PurchaseTruncatedTest(unittest.TestCase):
|
|
"""Test PopoverTruncated with endpart edge cases."""
|
|
|
|
def setUp(self):
|
|
components.enable_cache()
|
|
components._render_cached.cache_clear()
|
|
|
|
def tearDown(self):
|
|
components._render_cached = components._render_cached_impl
|
|
|
|
def test_endpart_shorter_than_length(self):
|
|
text = "a" * 50
|
|
result = components.PopoverTruncated(text, length=10, endpart="x")
|
|
# endpart=x takes 1 char, so content gets truncated at 9 chars
|
|
self.assertIn("data-popover-target", result)
|
|
self.assertIn("x", result)
|
|
|
|
def test_no_truncation_no_ellipsis(self):
|
|
result = components.PopoverTruncated("short text")
|
|
self.assertEqual(result, "short text")
|
|
|
|
def test_custom_length(self):
|
|
text = "hello world"
|
|
result = components.PopoverTruncated(text, length=6)
|
|
self.assertIn("data-popover-target", result)
|
|
|
|
|
|
class NameWithIconPlatformTest(unittest.TestCase):
|
|
"""Test NameWithIcon platform icon rendering."""
|
|
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
super().setUpClass()
|
|
cls.platform = Platform.objects.create(name="Nintendo", icon="nintendo")
|
|
cls.game = Game.objects.create(name="Zelda", platform=cls.platform)
|
|
|
|
@classmethod
|
|
def tearDownClass(cls):
|
|
super().tearDownClass()
|
|
Game.objects.all().delete()
|
|
Platform.objects.all().delete()
|
|
|
|
def test_name_with_icon_shows_platform_icon(self):
|
|
# NameWithIcon looks up platform from DB when linkify=True with game_id
|
|
result = components.NameWithIcon(
|
|
name="Zelda", game_id=self.game.pk, linkify=True
|
|
)
|
|
self.assertIsInstance(result, SafeText)
|
|
self.assertIn("Zelda", result)
|
|
|
|
def test_name_with_icon_no_game_id_no_platform(self):
|
|
result = components.NameWithIcon(name="Unknown Game", linkify=False)
|
|
self.assertIsInstance(result, SafeText)
|
|
self.assertIn("Unknown Game", result)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|