Files
python-tdd/src/apps/dashboard/tests/integrated/test_views.py
Disco DeDisco 5aaff6240b
Some checks failed
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline failed
palette tooltip: contextual description per lock state; earned date below Unlocked in green — TDD
- _palettes_for_user: shoptalk→description; "available by default" / "explore to unlock" / "recognized via {Title}"; unlocked_date key carries earned_at ISO for Note-unlocked
- template: data-shoptalk→data-description; data-unlocked-date now holds ISO datetime (was literal "Default")
- dashboard.js: lockText drops "— Default"; dateLine renders "Apr 22, 2026 · 3:05 AM" after Unlocked line
- _palette-picker.scss: tt-shoptalk→tt-description in display:block list; tt-date added
- _tooltips.scss: .tt-date mirrors .tt-expiry w. --priGn colour

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 18:59:10 -04:00

654 lines
27 KiB
Python

import json
import lxml.html
from unittest.mock import patch, MagicMock
from django.contrib.messages import get_messages
from django.test import override_settings, TestCase
from django.urls import reverse
from django.utils import html, timezone
from apps.applets.models import Applet, UserApplet
from apps.dashboard.forms import (
DUPLICATE_LINE_ERROR,
EMPTY_LINE_ERROR,
)
from apps.dashboard.models import Line, Post
from apps.drama.models import Note
from apps.lyric.models import User
class HomePageTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="disco@test.io")
self.client.force_login(self.user)
def test_uses_home_template(self):
response = self.client.get('/')
self.assertTemplateUsed(response, 'apps/dashboard/home.html')
@override_settings(COMPRESS_ENABLED=False)
class NewPostTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="disco@test.io")
self.client.force_login(self.user)
Applet.objects.get_or_create(
slug="new-post",
defaults={"name": "New Post", "context": "billboard", "grid_cols": 9, "grid_rows": 3},
)
def test_can_save_a_POST_request(self):
self.client.post("/dashboard/new_post", data={"text": "A new post line"})
self.assertEqual(Line.objects.count(), 1)
new_line = Line.objects.get()
self.assertEqual(new_line.text, "A new post line")
def test_redirects_after_POST(self):
response = self.client.post("/dashboard/new_post", data={"text": "A new post line"})
new_post = Post.objects.get()
self.assertRedirects(response, f"/dashboard/post/{new_post.id}/")
# Post invalid input helper
def post_invalid_input(self):
return self.client.post("/dashboard/new_post", data={"text": ""})
def test_for_invalid_input_nothing_saved_to_db(self):
self.post_invalid_input()
self.assertEqual(Line.objects.count(), 0)
def test_for_invalid_input_renders_billboard_template(self):
response = self.post_invalid_input()
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, "apps/billboard/billboard.html")
def test_for_invalid_input_shows_error_on_page(self):
response = self.post_invalid_input()
self.assertContains(response, html.escape(EMPTY_LINE_ERROR))
@override_settings(COMPRESS_ENABLED=False)
class PostViewTest(TestCase):
def test_uses_post_template(self):
mypost = Post.objects.create()
response = self.client.get(f"/dashboard/post/{mypost.id}/")
self.assertTemplateUsed(response, "apps/dashboard/post.html")
def test_renders_input_form(self):
mypost = Post.objects.create()
url = f"/dashboard/post/{mypost.id}/"
response = self.client.get(url)
parsed = lxml.html.fromstring(response.content)
forms = parsed.cssselect("form[method=POST]")
self.assertIn(url, [form.get("action") for form in forms])
[form] = [form for form in forms if form.get("action") == url]
inputs = form.cssselect("input")
self.assertIn("text", [input.get("name") for input in inputs])
def test_displays_only_lines_for_that_post(self):
# Given/Arrange
correct_post = Post.objects.create()
Line.objects.create(text="itemey 1", post=correct_post)
Line.objects.create(text="itemey 2", post=correct_post)
other_post = Post.objects.create()
Line.objects.create(text="other post line", post=other_post)
# When/Act
response = self.client.get(f"/dashboard/post/{correct_post.id}/")
# Then/Assert
self.assertContains(response, "itemey 1")
self.assertContains(response, "itemey 2")
self.assertNotContains(response, "other post line")
def test_can_save_a_POST_request_to_an_existing_post(self):
other_post = Post.objects.create()
correct_post = Post.objects.create()
self.client.post(
f"/dashboard/post/{correct_post.id}/",
data={"text": "A new line for an existing post"},
)
self.assertEqual(Line.objects.count(), 1)
new_line = Line.objects.get()
self.assertEqual(new_line.text, "A new line for an existing post")
self.assertEqual(new_line.post, correct_post)
def test_POST_redirects_to_post_view(self):
other_post = Post.objects.create()
correct_post = Post.objects.create()
response = self.client.post(
f"/dashboard/post/{correct_post.id}/",
data={"text": "A new line for an existing post"},
)
self.assertRedirects(response, f"/dashboard/post/{correct_post.id}/")
# Post invalid input helper
def post_invalid_input(self):
mypost = Post.objects.create()
return self.client.post(f"/dashboard/post/{mypost.id}/", data={"text": ""})
def test_for_invalid_input_nothing_saved_to_db(self):
self.post_invalid_input()
self.assertEqual(Line.objects.count(), 0)
def test_for_invalid_input_renders_post_template(self):
response = self.post_invalid_input()
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, "apps/dashboard/post.html")
def test_for_invalid_input_shows_error_on_page(self):
response = self.post_invalid_input()
self.assertContains(response, html.escape(EMPTY_LINE_ERROR))
def test_for_invalid_input_sets_is_invalid_class(self):
response = self.post_invalid_input()
parsed = lxml.html.fromstring(response.content)
[input] = parsed.cssselect("input[name=text]")
self.assertIn("is-invalid", set(input.classes))
def test_duplicate_line_validation_errors_end_up_on_post_page(self):
post1 = Post.objects.create()
Line.objects.create(post=post1, text="lorem ipsum")
response = self.client.post(
f"/dashboard/post/{post1.id}/",
data={"text": "lorem ipsum"},
)
expected_error = html.escape(DUPLICATE_LINE_ERROR)
self.assertContains(response, expected_error)
self.assertTemplateUsed(response, "apps/dashboard/post.html")
self.assertEqual(Line.objects.all().count(), 1)
class MyPostsTest(TestCase):
def test_my_posts_url_renders_my_posts_template(self):
user = User.objects.create(email="a@b.cde")
self.client.force_login(user)
response = self.client.get(f"/dashboard/users/{user.id}/")
self.assertTemplateUsed(response, "apps/dashboard/my_posts.html")
def test_passes_correct_owner_to_template(self):
User.objects.create(email="wrongowner@example.com")
correct_user = User.objects.create(email="a@b.cde")
self.client.force_login(correct_user)
response = self.client.get(f"/dashboard/users/{correct_user.id}/")
self.assertEqual(response.context["owner"], correct_user)
def test_post_owner_is_saved_if_user_is_authenticated(self):
user = User.objects.create(email="a@b.cde")
self.client.force_login(user)
self.client.post("/dashboard/new_post", data={"text": "new line"})
new_post = Post.objects.get()
self.assertEqual(new_post.owner, user)
def test_my_posts_redirects_if_not_logged_in(self):
user = User.objects.create(email="a@b.cde")
response = self.client.get(f"/dashboard/users/{user.id}/")
self.assertRedirects(response, "/")
def test_my_posts_returns_403_for_wrong_user(self):
user1 = User.objects.create(email="a@b.cde")
user2 = User.objects.create(email="wrongowner@example.com")
self.client.force_login(user2)
response = self.client.get(f"/dashboard/users/{user1.id}/")
self.assertEqual(response.status_code, 403)
class SharePostTest(TestCase):
def test_post_to_share_post_url_redirects_to_post(self):
our_post = Post.objects.create()
alice = User.objects.create(email="alice@example.com")
response = self.client.post(
f"/dashboard/post/{our_post.id}/share_post",
data={"recipient": "alice@example.com"},
)
self.assertRedirects(response, f"/dashboard/post/{our_post.id}/")
def test_post_with_email_adds_user_to_shared_with(self):
our_post = Post.objects.create()
alice = User.objects.create(email="alice@example.com")
self.client.post(
f"/dashboard/post/{our_post.id}/share_post",
data={"recipient": "alice@example.com"},
)
self.assertIn(alice, our_post.shared_with.all())
def test_post_with_nonexistent_email_redirects_to_post(self):
our_post = Post.objects.create()
response = self.client.post(
f"/dashboard/post/{our_post.id}/share_post",
data={"recipient": "nobody@example.com"},
)
self.assertRedirects(
response,
f"/dashboard/post/{our_post.id}/",
fetch_redirect_response=False,
)
def test_share_post_does_not_add_owner_as_recipient(self):
owner = User.objects.create(email="owner@example.com")
our_post = Post.objects.create(owner=owner)
self.client.force_login(owner)
self.client.post(reverse("share_post", args=[our_post.id]),
data={"recipient": "owner@example.com"})
self.assertNotIn(owner, our_post.shared_with.all())
@override_settings(MESSAGE_STORAGE='django.contrib.messages.storage.session.SessionStorage')
def test_share_post_shows_privacy_safe_message(self):
our_post = Post.objects.create()
response = self.client.post(
f"/dashboard/post/{our_post.id}/share_post",
data={"recipient": "nobody@example.com"},
follow=True,
)
messages = list(get_messages(response.wsgi_request))
self.assertEqual(
str(messages[0]),
"An invite has been sent if that address is registered.",
)
class ViewAuthPostTest(TestCase):
def setUp(self):
self.owner = User.objects.create(email="disco@example.com")
self.our_post = Post.objects.create(owner=self.owner)
def test_anonymous_user_is_redirected(self):
response = self.client.get(reverse("view_post", args=[self.our_post.id]))
self.assertRedirects(response, "/", fetch_redirect_response=False)
def test_non_owner_non_shared_user_gets_403(self):
stranger = User.objects.create(email="stranger@example.com")
self.client.force_login(stranger)
response = self.client.get(reverse("view_post", args=[self.our_post.id]))
self.assertEqual(response.status_code, 403)
def test_shared_with_user_can_access_post(self):
guest = User.objects.create(email="guest@example.com")
self.our_post.shared_with.add(guest)
self.client.force_login(guest)
response = self.client.get(reverse("view_post", args=[self.our_post.id]))
self.assertEqual(response.status_code, 200)
@override_settings(COMPRESS_ENABLED=False)
class SetPaletteTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="a@b.cde")
self.client.force_login(self.user)
self.url = reverse("home")
Applet.objects.get_or_create(slug="palette", defaults={"name": "Palette"})
def test_anonymous_user_is_redirected_home(self):
response = self.client.post("/dashboard/set_palette")
self.assertRedirects(response, "/", fetch_redirect_response=False)
def test_set_palette_updates_user_palette(self):
User.objects.filter(pk=self.user.pk).update(palette="palette-sheol")
self.client.post("/dashboard/set_palette", data={"palette": "palette-default"})
self.user.refresh_from_db()
self.assertEqual(self.user.palette, "palette-default")
def test_locked_palette_is_rejected(self):
response = self.client.post("/dashboard/set_palette", data={"palette": "palette-bardo"})
self.user.refresh_from_db()
self.assertEqual(self.user.palette, "palette-default")
self.assertRedirects(response, "/", fetch_redirect_response=False)
def test_set_palette_redirects_home(self):
response = self.client.post("/dashboard/set_palette", data={"palette": "palette-default"})
self.assertRedirects(response, "/", fetch_redirect_response=False)
def test_set_palette_returns_json_when_requested(self):
response = self.client.post(
"/dashboard/set_palette",
data={"palette": "palette-cedar"},
headers={"Accept": "application/json"},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), {"palette": "palette-cedar"})
def test_locked_palette_returns_unchanged_json(self):
response = self.client.post(
"/dashboard/set_palette",
data={"palette": "palette-bardo"},
headers={"Accept": "application/json"},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), {"palette": "palette-default"})
def test_unlocked_swatches_count_matches_context(self):
response = self.client.get(self.url)
parsed = lxml.html.fromstring(response.content)
swatches = parsed.cssselect(".swatch:not(.locked)")
unlocked = [p for p in response.context["palettes"] if not p["locked"]]
self.assertEqual(len(swatches), len(unlocked))
def test_active_palette_swatch_has_active_class(self):
response = self.client.get(self.url)
parsed = lxml.html.fromstring(response.content)
[active] = parsed.cssselect(".swatch.active")
self.assertIn("palette-default", active.classes)
def test_locked_palettes_are_not_forms(self):
response = self.client.get(self.url)
parsed = lxml.html.fromstring(response.content)
locked = parsed.cssselect(".swatch.locked")
expected_locked = [p for p in response.context["palettes"] if p["locked"]]
self.assertEqual(len(locked), len(expected_locked))
# they mustn't be button els
for swatch in locked:
self.assertNotEqual(swatch.tag, "button")
def test_palette_picker_count_matches_context(self):
response = self.client.get(self.url)
parsed = lxml.html.fromstring(response.content)
swatches = parsed.cssselect(".swatch")
self.assertEqual(len(swatches), len(response.context["palettes"]))
class NotePaletteContextTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="recog_palette@test.io")
self.client.force_login(self.user)
Applet.objects.get_or_create(slug="palette", defaults={"name": "Palette"})
def test_note_palette_unlocks_swatch_in_context(self):
Note.objects.create(
user=self.user, slug="stargazer", earned_at=timezone.now(),
palette="palette-bardo",
)
response = self.client.get("/")
palettes = response.context["palettes"]
bardo = next(p for p in palettes if p["name"] == "palette-bardo")
self.assertFalse(bardo["locked"])
def test_note_palette_description_contains_note_title(self):
Note.objects.create(
user=self.user, slug="stargazer", earned_at=timezone.now(),
palette="palette-bardo",
)
response = self.client.get("/")
palettes = response.context["palettes"]
bardo = next(p for p in palettes if p["name"] == "palette-bardo")
self.assertIn("Stargazer", bardo["description"])
def test_default_palette_description_is_available_by_default(self):
response = self.client.get("/")
palettes = response.context["palettes"]
default = next(p for p in palettes if p["name"] == "palette-default")
self.assertEqual(default["description"], "available by default")
def test_locked_palette_description_is_explore_to_unlock(self):
response = self.client.get("/")
palettes = response.context["palettes"]
bardo = next(p for p in palettes if p["name"] == "palette-bardo")
self.assertEqual(bardo["description"], "explore to unlock")
def test_note_palette_description_is_recognized_via(self):
Note.objects.create(
user=self.user, slug="stargazer", earned_at=timezone.now(),
palette="palette-bardo",
)
response = self.client.get("/")
palettes = response.context["palettes"]
bardo = next(p for p in palettes if p["name"] == "palette-bardo")
self.assertEqual(bardo["description"], "recognized via Stargazer")
def test_note_palette_entry_includes_unlocked_date_iso(self):
earned = timezone.now()
Note.objects.create(
user=self.user, slug="stargazer", earned_at=earned,
palette="palette-bardo",
)
response = self.client.get("/")
palettes = response.context["palettes"]
bardo = next(p for p in palettes if p["name"] == "palette-bardo")
self.assertIn("unlocked_date", bardo)
self.assertEqual(bardo["unlocked_date"], earned.isoformat())
def test_note_without_palette_field_keeps_swatch_locked(self):
Note.objects.create(
user=self.user, slug="stargazer", earned_at=timezone.now(),
palette=None,
)
response = self.client.get("/")
palettes = response.context["palettes"]
bardo = next(p for p in palettes if p["name"] == "palette-bardo")
self.assertTrue(bardo["locked"])
def test_note_palette_allows_set_palette_via_view(self):
Note.objects.create(
user=self.user, slug="stargazer", earned_at=timezone.now(),
palette="palette-bardo",
)
self.client.post("/dashboard/set_palette", data={"palette": "palette-bardo"})
self.user.refresh_from_db()
self.assertEqual(self.user.palette, "palette-bardo")
@override_settings(COMPRESS_ENABLED=False)
class ProfileViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="discoman@example.com")
self.client.force_login(self.user)
def test_post_username_saves_to_user(self):
self.client.post("/dashboard/set_profile", data={"username": "discoman"})
self.user.refresh_from_db()
self.assertEqual(self.user.username, "discoman")
def test_post_username_requires_login(self):
self.client.logout()
response = self.client.post("/dashboard/set_profile", data={"username": "somnambulist"})
self.assertRedirects(response, "/?next=/dashboard/set_profile", fetch_redirect_response=False)
def test_dash_renders_username_applet(self):
response = self.client.get("/")
parsed = lxml.html.fromstring(response.content)
[applet] = parsed.cssselect("#id_applet_username")
self.assertIn("@", applet.text_content())
[input_el] = parsed.cssselect("#id_new_username")
self.assertEqual("", input_el.get("value"))
def test_dash_shows_display_name_in_applet(self):
self.user.username = "discoman"
self.user.save()
response = self.client.get("/")
parsed = lxml.html.fromstring(response.content)
[username_input] = parsed.cssselect("#id_new_username")
self.assertEqual("discoman", username_input.get("value"))
class ToggleDashAppletsViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="disco@test.io")
self.client.force_login(self.user)
self.username_applet, _ = Applet.objects.get_or_create(slug="username", defaults={"name": "Username"})
self.palette_applet, _ = Applet.objects.get_or_create(slug="palette", defaults={"name": "Palette"})
self.url = reverse("toggle_applets")
def test_unauthenticated_user_is_redirected(self):
self.client.logout()
response = self.client.post(self.url)
self.assertRedirects(
response, f"/?next={self.url}", fetch_redirect_response=False
)
def test_unchecked_applet_gets_user_applet_with_visible_false(self):
self.client.post(self.url, {"applets": ["username"]})
ua = UserApplet.objects.get(user=self.user, applet=self.palette_applet)
self.assertFalse(ua.visible)
def test_redirects_on_normal_post(self):
response = self.client.post(
self.url, {"applets": ["username", "palette"]}
)
self.assertRedirects(response, reverse("home"), fetch_redirect_response=False)
def test_returns_200_on_htmx_post(self):
response = self.client.post(
self.url,
{"applets": ["username", "palette"]},
HTTP_HX_REQUEST="true",
)
self.assertEqual(response.status_code, 200)
def test_htmx_post_renders_visible_applets_only(self):
response = self.client.post(
self.url,
{"applets": ["username"]},
HTTP_HX_REQUEST="true",
)
parsed = lxml.html.fromstring(response.content)
self.assertEqual(len(parsed.cssselect("#id_applet_username")), 1)
self.assertEqual(len(parsed.cssselect("#id_applet_palette")), 0)
def test_toggle_applets_does_not_affect_gameboard_applets(self):
game_applet, _ = Applet.objects.get_or_create(
slug="new-game", defaults={"name": "New Game", "context": "gameboard"}
)
self.client.post(self.url, {"applets": ["username", "palette"]})
self.assertFalse(UserApplet.objects.filter(user=self.user, applet=game_applet). exists())
class AppletVisibilityContextTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="disco@test.io")
self.client.force_login(self.user)
self.username_applet, _ = Applet.objects.get_or_create(slug="username", defaults={"name": "Username"})
self.palette_applet, _ = Applet.objects.get_or_create(slug="palette", defaults={"name": "Palette"})
UserApplet.objects.create(user=self.user, applet=self.palette_applet, visible=False)
def test_dash_reflects_user_applet_visibility(self):
response = self.client.get("/")
applet_map = {entry["applet"].slug: entry["visible"] for entry in response.context["applets"]}
self.assertFalse(applet_map["palette"])
self.assertTrue(applet_map["username"])
class FooterNavTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="disco@test.io")
self.client.force_login(self.user)
def test_footer_nav_present_on_dashboard(self):
response = self.client.get("/")
parsed = lxml.html.fromstring(response.content)
[nav] = parsed.cssselect("#id_footer_nav")
self.assertIsNotNone(nav)
def test_footer_nav_has_dashboard_link(self):
response = self.client.get("/")
parsed = lxml.html.fromstring(response.content)
[nav] = parsed.cssselect("#id_footer_nav")
links = [a.get("href") for a in nav.cssselect("a")]
self.assertIn("/", links)
def test_footer_nav_has_gameboard_link(self):
response = self.client.get("/")
parsed = lxml.html.fromstring(response.content)
[nav] = parsed.cssselect("#id_footer_nav")
links = [a.get("href") for a in nav.cssselect("a")]
self.assertIn("/gameboard/", links)
class WalletAppletTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="disco@test.io")
self.client.force_login(self.user)
Applet.objects.get_or_create(slug="wallet", defaults={"name": "Wallet"})
response = self.client.get("/")
self.parsed = lxml.html.fromstring(response.content)
def test_wallet_applet_present_on_dash(self):
[_] = self.parsed.cssselect("#id_applet_wallet")
def test_wallet_applet_has_manage_link(self):
[link] = self.parsed.cssselect("#id_applet_wallet a.wallet-manage-link")
self.assertEqual(link.get("href"), "/dashboard/wallet/")
ENRICHED_CHART = {
"planets": {"Sun": {"lon": 10.0, "sign": "Aries", "house": 1, "degree": 10.0}},
"houses": {"cusps": [float(i * 30) for i in range(12)]},
"elements": {
"Fire": {"count": 3, "contributors": ["Sun", "Mars", "Jupiter"]},
"Stone": {"count": 1, "contributors": ["Venus"]},
"Air": {"count": 2, "contributors": ["Mercury", "Uranus"]},
"Water": {"count": 0, "contributors": []},
"Time": {"count": 1, "stellia": ["Saturn"]},
"Space": {"count": 1, "parades": ["Neptune"]},
},
"distinctions": [],
"timezone": "UTC",
}
BIRTH_PAYLOAD = {
"birth_dt": "1990-06-15T12:00:00Z",
"birth_lat": 51.5,
"birth_lon": -0.1,
"birth_place": "London",
"house_system": "O",
"chart_data": {"stale": True},
}
@override_settings(PYSWISS_URL="http://pyswiss-test")
class SkySaveViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(
email="disco@test.io",
sky_birth_lat=51.5,
sky_birth_lon=-0.1,
sky_birth_place="London",
)
self.client.force_login(self.user)
def _post(self, payload=None):
return self.client.post(
"/dashboard/sky/save",
data=json.dumps(payload or BIRTH_PAYLOAD),
content_type="application/json",
)
def test_save_stores_client_chart_data(self):
"""sky_save stores the chart_data from the client (already enriched by sky_preview)."""
client_chart = {
"planets": {},
"houses": {"cusps": [float(i * 30) for i in range(12)], "asc": 123.4, "mc": 45.6},
"elements": {"Fire": {"count": 1, "contributors": []}},
}
payload = dict(BIRTH_PAYLOAD, chart_data=client_chart)
response = self._post(payload)
self.assertEqual(response.status_code, 200)
self.user.refresh_from_db()
self.assertAlmostEqual(self.user.sky_chart_data["houses"]["asc"], 123.4)
class SkyNatusDataViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="disco@test.io")
self.client.force_login(self.user)
def test_returns_stored_chart_with_asc_preserved(self):
"""sky_natus_data returns sky_chart_data — asc must match what was saved."""
stored = {
"planets": {},
"houses": {"cusps": [float(i * 30) for i in range(12)], "asc": 236.1, "mc": 159.1},
"elements": {"Fire": {"count": 1, "contributors": []}},
"aspects": [],
"house_system": "O",
}
self.user.sky_chart_data = stored
self.user.save(update_fields=["sky_chart_data"])
response = self.client.get("/dashboard/sky/data")
self.assertEqual(response.status_code, 200)
data = json.loads(response.content)
self.assertAlmostEqual(data["houses"]["asc"], 236.1)
self.assertIn("distinctions", data)
def test_returns_404_if_no_chart_data_saved(self):
response = self.client.get("/dashboard/sky/data")
self.assertEqual(response.status_code, 404)
def test_requires_login(self):
self.client.logout()
response = self.client.get("/dashboard/sky/data")
self.assertRedirects(response, "/?next=/dashboard/sky/data", fetch_redirect_response=False)