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 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("", result) def test_slot_marked_safe(self): result = components._render_cached_impl( "cotton/icon/play.html", '{"slot": "bold", "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("", result) def test_tag_name_component(self): result = components.Component( tag_name="div", attributes=[("class", "test")], children="hello", ) self.assertEqual(result, '
hello
') 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)) if __name__ == "__main__": unittest.main()