From 6b59e548e1392d38def1933d936ccf132797877f Mon Sep 17 00:00:00 2001 From: SloppyBobbert <19805162+bdtran2002@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:19:00 -0700 Subject: [PATCH 1/2] fix(phase1): harden editor and compiler workflows --- backend/api/compiler.py | 32 ++ backend/api/latex_utils.py | 14 +- .../0009_template_selected_formulas.py | 18 + backend/api/models.py | 1 + backend/api/serializers.py | 24 +- backend/api/tests.py | 176 +++++- backend/api/views.py | 84 ++- backend/cheat_sheet/settings.py | 5 + docs/REFACTORING_PLAN.md | 179 ++++++ frontend/src/App.jsx | 161 ++++-- frontend/src/App.test.jsx | 539 ++++++++++++++++++ frontend/src/Phase1Journey.test.jsx | 189 ++++++ frontend/src/components/CreateCheatSheet.jsx | 147 ++--- .../src/components/CreateCheatSheet.test.jsx | 151 +++-- frontend/src/hooks/formulas.js | 99 ++-- frontend/src/hooks/formulas.test.js | 136 ++++- frontend/src/hooks/latex.js | 345 +++++++---- frontend/src/hooks/latex.test.jsx | 341 ++++++++++- 18 files changed, 2240 insertions(+), 401 deletions(-) create mode 100644 backend/api/compiler.py create mode 100644 backend/api/migrations/0009_template_selected_formulas.py create mode 100644 docs/REFACTORING_PLAN.md create mode 100644 frontend/src/App.test.jsx create mode 100644 frontend/src/Phase1Journey.test.jsx diff --git a/backend/api/compiler.py b/backend/api/compiler.py new file mode 100644 index 0000000..9d585a1 --- /dev/null +++ b/backend/api/compiler.py @@ -0,0 +1,32 @@ +import re + +from django.conf import settings + + +MAX_CHEAT_SHEET_ID = 9_223_372_036_854_775_807 +CANONICAL_POSITIVE_INTEGER = re.compile(r"[1-9][0-9]*\Z") + + +def validate_cheat_sheet_id(value): + if type(value) is int: + cheat_sheet_id = value + elif type(value) is str and CANONICAL_POSITIVE_INTEGER.fullmatch(value): + cheat_sheet_id = int(value) + else: + return None + + if not 0 < cheat_sheet_id <= MAX_CHEAT_SHEET_ID: + return None + return cheat_sheet_id + + +def validate_source_text(content): + if not isinstance(content, str): + return "LaTeX content must be a string" + try: + source_size = len(content.encode("utf-8")) + except UnicodeEncodeError: + return "LaTeX content must be valid UTF-8" + if source_size > settings.COMPILER_SOURCE_MAX_BYTES: + return "LaTeX content exceeds the maximum allowed size" + return None diff --git a/backend/api/latex_utils.py b/backend/api/latex_utils.py index 498a560..97376cc 100644 --- a/backend/api/latex_utils.py +++ b/backend/api/latex_utils.py @@ -3,6 +3,8 @@ import subprocess import tempfile +from django.conf import settings + LATEX_HEADER = r"""\documentclass[fleqn]{article} \usepackage[margin=0.15in]{geometry} \usepackage{amsmath, amssymb} @@ -330,13 +332,13 @@ def compile_latex_to_pdf(content): subprocess.run( ["tectonic", tex_file_path], cwd=tempdir, - capture_output=True, - text=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, check=True, + timeout=settings.COMPILER_TIMEOUT_SECONDS, ) - except subprocess.CalledProcessError: - # Propagate the error; the temporary directory will still be cleaned up - raise + except (FileNotFoundError, subprocess.CalledProcessError, subprocess.TimeoutExpired): + raise RuntimeError("Failed to compile LaTeX") from None pdf_file_path = os.path.join(tempdir, "document.pdf") if not os.path.exists(pdf_file_path): @@ -344,4 +346,4 @@ def compile_latex_to_pdf(content): # Read and return the PDF bytes before the temporary directory is removed with open(pdf_file_path, "rb") as pdf_file: - return pdf_file.read() \ No newline at end of file + return pdf_file.read() diff --git a/backend/api/migrations/0009_template_selected_formulas.py b/backend/api/migrations/0009_template_selected_formulas.py new file mode 100644 index 0000000..e56d684 --- /dev/null +++ b/backend/api/migrations/0009_template_selected_formulas.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.5 on 2026-08-25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("api", "0008_cheatsheet_orientation"), + ] + + operations = [ + migrations.AddField( + model_name="template", + name="selected_formulas", + field=models.JSONField(blank=True, default=list), + ), + ] diff --git a/backend/api/models.py b/backend/api/models.py index 19ef163..b8a2fd7 100644 --- a/backend/api/models.py +++ b/backend/api/models.py @@ -10,6 +10,7 @@ class Template(models.Model): latex_content = models.TextField() default_columns = models.IntegerField(default=2) default_margins = models.CharField(max_length=20, default="0.5in") + selected_formulas = models.JSONField(default=list, blank=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) diff --git a/backend/api/serializers.py b/backend/api/serializers.py index 663440a..161a5b0 100644 --- a/backend/api/serializers.py +++ b/backend/api/serializers.py @@ -48,6 +48,7 @@ class Meta: "latex_content", "default_margins", "default_columns", + "selected_formulas", "created_at", "updated_at", ] @@ -55,6 +56,15 @@ class Meta: class PracticeProblemSerializer(serializers.ModelSerializer): + def get_fields(self): + fields = super().get_fields() + request = self.context.get("request") + if request and request.user.is_authenticated: + fields["cheat_sheet"].queryset = CheatSheet.objects.filter(user=request.user) + else: + fields["cheat_sheet"].queryset = CheatSheet.objects.none() + return fields + class Meta: model = PracticeProblem fields = [ @@ -109,17 +119,3 @@ def validate(self, attrs): else: attrs["content_source"] = "manual" return attrs - - -class CompileRequestSerializer(serializers.Serializer): - """Accepts either raw content OR a cheat_sheet id to compile.""" - - content = serializers.CharField(required=False, default="") - cheat_sheet_id = serializers.IntegerField(required=False, default=None) - - def validate(self, data): - if not data.get("content") and not data.get("cheat_sheet_id"): - raise serializers.ValidationError( - "Provide either 'content' or 'cheat_sheet_id'." - ) - return data diff --git a/backend/api/tests.py b/backend/api/tests.py index a5f2532..75c3b24 100644 --- a/backend/api/tests.py +++ b/backend/api/tests.py @@ -3,16 +3,20 @@ Run with: pytest (from the backend/ directory) """ +import subprocess + import pytest from unittest.mock import patch from urllib.error import HTTPError from io import BytesIO from django.contrib.auth.models import User -from django.test import TestCase -from rest_framework.test import APIClient -from api.latex_utils import LATEX_HEADER, build_dynamic_header, build_latex_for_formulas, normalize_latex_layout +from django.core.cache import cache +from django.test import TestCase, override_settings +from rest_framework.test import APIClient, APIRequestFactory +from api.latex_utils import LATEX_HEADER, build_dynamic_header, build_latex_for_formulas, compile_latex_to_pdf, normalize_latex_layout from api.models import Template, CheatSheet, PracticeProblem -from api.views import YOUTUBE_RESOURCE_CACHE, fetch_top_youtube_video, get_youtube_http_error_message +from api.compiler import validate_cheat_sheet_id +from api.views import CompileAnonThrottle, CompileUserThrottle, YOUTUBE_RESOURCE_CACHE, fetch_top_youtube_video, get_youtube_http_error_message @pytest.fixture @@ -518,7 +522,7 @@ def test_filter_templates_by_subject(self, auth_client, sample_template): data = resp.json() assert len(data) >= 1 - def test_create_template(self, auth_client): + def test_nonstaff_cannot_create_template(self, auth_client): resp = auth_client.post( "/api/templates/", { @@ -528,7 +532,7 @@ def test_create_template(self, auth_client): }, format="json", ) - assert resp.status_code == 201 + assert resp.status_code in (401, 403) @pytest.mark.django_db @@ -822,6 +826,32 @@ def test_create_from_template(self, auth_client, sample_template): assert data["template"] == sample_template.id assert data["columns"] == sample_template.default_columns + def test_create_from_template_marks_copied_content_as_generated(self, auth_client): + formulas = [{"class": "ALGEBRA I", "name": "Slope Formula"}] + template = Template.objects.create( + name="Generated template", + subject="algebra", + latex_content="Template content", + default_columns=3, + default_margins="0.5in", + selected_formulas=formulas, + ) + + response = auth_client.post( + "/api/cheatsheets/from-template/", + {"template_id": template.id, "title": "Generated copy"}, + format="json", + ) + + assert response.status_code == 201 + assert response.json()["content_source"] == "generated" + sheet = CheatSheet.objects.get(pk=response.json()["id"]) + assert sheet.content_source == "generated" + assert sheet.user == auth_client.handler._force_user + assert sheet.selected_formulas == formulas + assert sheet.columns == template.default_columns + assert sheet.margins == template.default_margins + def test_create_from_template_missing_id(self, auth_client): resp = auth_client.post( "/api/cheatsheets/from-template/", @@ -1497,3 +1527,137 @@ def test_token_refresh_invalid_token(self, api_client): format="json", ) assert resp.status_code == 401 + + +@pytest.mark.django_db +class TestPhaseOneTransfer: + def test_signed_64_bit_sheet_id_boundary_is_canonical(self): + maximum = 9_223_372_036_854_775_807 + assert validate_cheat_sheet_id(maximum) == maximum + assert validate_cheat_sheet_id(maximum + 1) is None + + def test_templates_are_public_but_writes_require_staff(self, api_client, sample_template): + assert api_client.get("/api/templates/").status_code == 200 + assert api_client.post( + "/api/templates/", {"name": "Nope", "subject": "math", "latex_content": "x"}, format="json" + ).status_code in (401, 403) + staff = User.objects.create_user(username="staff", password="testpass123", is_staff=True) + api_client.force_authenticate(user=staff) + assert api_client.post( + "/api/templates/", {"name": "Allowed", "subject": "math", "latex_content": "x"}, format="json" + ).status_code == 201 + + def test_template_selection_is_exposed_and_copied(self, api_client, auth_client): + formulas = [{"class": "ALGEBRA I", "name": "Slope Formula"}] + template = Template.objects.create(name="Formula template", subject="algebra", latex_content="Content", selected_formulas=formulas) + assert api_client.get(f"/api/templates/{template.id}/").json()["selected_formulas"] == formulas + response = auth_client.post("/api/cheatsheets/from-template/", {"template_id": template.id}, format="json") + assert response.status_code == 201 + assert response.json()["selected_formulas"] == formulas + + def test_problems_are_scoped_to_the_authenticated_sheet_owner(self, auth_client, sample_problem): + other = User.objects.create_user(username="other-problem-user", password="testpass123") + client = APIClient() + client.force_authenticate(user=other) + assert client.get("/api/problems/").json() == [] + assert client.get(f"/api/problems/{sample_problem.id}/").status_code == 404 + assert client.post("/api/problems/", {"cheat_sheet": sample_problem.cheat_sheet_id, "question_latex": "No"}, format="json").status_code == 400 + + @pytest.mark.parametrize("cheat_sheet_id", ["0", "-1", "01", "1.0", "invalid"]) + def test_problem_sheet_filter_rejects_noncanonical_ids(self, auth_client, cheat_sheet_id): + response = auth_client.get(f"/api/problems/?cheat_sheet={cheat_sheet_id}") + assert response.status_code == 400 + + def test_problem_sheet_filter_keeps_owner_scope(self, auth_client, sample_problem): + response = auth_client.get(f"/api/problems/?cheat_sheet={sample_problem.cheat_sheet_id}") + assert response.status_code == 200 + assert [problem["id"] for problem in response.json()] == [sample_problem.id] + + @pytest.mark.parametrize("cheat_sheet_id", [True, 1.0, 0, -1, "01", "1.0", 9_223_372_036_854_775_808]) + def test_compile_rejects_noncanonical_sheet_ids(self, auth_client, cheat_sheet_id): + assert auth_client.post("/api/compile/", {"cheat_sheet_id": cheat_sheet_id}, format="json").status_code == 400 + + @override_settings(COMPILER_SOURCE_MAX_BYTES=4) + def test_compile_enforces_utf8_source_byte_limit(self, api_client): + assert api_client.post("/api/compile/", {"content": "1234", "normalize_only": True}, format="json").status_code == 200 + assert api_client.post("/api/compile/", {"content": "éé", "normalize_only": True}, format="json").status_code == 200 + assert api_client.post("/api/compile/", {"content": "ééé", "normalize_only": True}, format="json").status_code == 413 + + def test_compile_rejects_json_lone_surrogate(self, api_client): + response = api_client.post( + "/api/compile/", + b'{"content":"\\ud800","normalize_only":true}', + content_type="application/json", + ) + assert response.status_code == 400 + + @patch("api.views.subprocess.run") + @override_settings(COMPILER_TIMEOUT_SECONDS=7) + def test_compile_timeout_and_failure_are_generic(self, run, api_client): + run.side_effect = subprocess.TimeoutExpired("tectonic", 7) + assert api_client.post("/api/compile/", {"content": "x"}, format="json").status_code == 408 + assert run.call_args.kwargs["timeout"] == 7 + run.side_effect = subprocess.CalledProcessError(1, "tectonic", stderr="sensitive compiler path") + response = api_client.post("/api/compile/", {"content": "x"}, format="json") + assert response.json() == {"error": "Failed to compile LaTeX"} + assert run.call_args.kwargs["stdout"] is subprocess.DEVNULL + assert run.call_args.kwargs["stderr"] is subprocess.DEVNULL + + @patch("api.views.subprocess.run", side_effect=FileNotFoundError("sensitive executable path")) + def test_compile_missing_executable_is_generic(self, _run, api_client): + response = api_client.post("/api/compile/", {"content": "x"}, format="json") + assert response.status_code == 500 + assert response.json() == {"error": "Failed to compile LaTeX"} + + @patch("api.latex_utils.subprocess.run") + def test_compile_helper_discards_compiler_diagnostics(self, run): + run.side_effect = subprocess.CalledProcessError(1, "tectonic", stderr="sensitive compiler path") + with pytest.raises(RuntimeError, match="Failed to compile LaTeX"): + compile_latex_to_pdf("content") + assert run.call_args.kwargs["stdout"] is subprocess.DEVNULL + assert run.call_args.kwargs["stderr"] is subprocess.DEVNULL + + @patch("api.latex_utils.subprocess.run", side_effect=FileNotFoundError("sensitive executable path")) + def test_compile_helper_missing_executable_is_generic(self, _run): + with pytest.raises(RuntimeError, match="Failed to compile LaTeX"): + compile_latex_to_pdf("content") + + def test_compile_throttles_use_configured_rates(self): + cache.clear() + anonymous = APIRequestFactory().post("/api/compile/", {"content": "x"}, format="json", REMOTE_ADDR="198.51.100.1") + anonymous.user = type("Anonymous", (), {"is_authenticated": False})() + with override_settings(COMPILER_ANON_RATE="1/hour"): + throttle = CompileAnonThrottle() + throttle.rate = throttle.get_rate() + throttle.num_requests, throttle.duration = throttle.parse_rate(throttle.rate) + assert throttle.allow_request(anonymous, None) + assert not throttle.allow_request(anonymous, None) + cache.clear() + authenticated = APIRequestFactory().post("/api/compile/", {"content": "x"}, format="json") + authenticated.user = User(id=999, username="rate-limited") + with override_settings(COMPILER_USER_RATE="1/hour"): + throttle = CompileUserThrottle() + throttle.rate = throttle.get_rate() + throttle.num_requests, throttle.duration = throttle.parse_rate(throttle.rate) + assert throttle.allow_request(authenticated, None) + assert not throttle.allow_request(authenticated, None) + + def test_compile_endpoint_enforces_anonymous_throttle(self, api_client): + cache.clear() + with override_settings(COMPILER_ANON_RATE="1/hour"): + assert api_client.post("/api/compile/", {"content": "x", "normalize_only": True}, format="json").status_code == 200 + assert api_client.post("/api/compile/", {"content": "x", "normalize_only": True}, format="json").status_code == 429 + + def test_compile_endpoint_enforces_authenticated_throttle(self, auth_client): + cache.clear() + with override_settings(COMPILER_USER_RATE="1/hour"): + assert auth_client.post("/api/compile/", {"content": "x", "normalize_only": True}, format="json").status_code == 200 + assert auth_client.post("/api/compile/", {"content": "x", "normalize_only": True}, format="json").status_code == 429 + + def test_compile_endpoint_registers_both_throttle_classes(self): + from api.views import compile_latex + + assert compile_latex.cls.throttle_classes == [CompileAnonThrottle, CompileUserThrottle] + + def test_anonymous_sheet_compile_requires_authentication(self, api_client, sample_sheet): + assert api_client.post("/api/compile/", {"cheat_sheet_id": sample_sheet.id}, format="json").status_code == 401 diff --git a/backend/api/views.py b/backend/api/views.py index e987706..d803e3c 100644 --- a/backend/api/views.py +++ b/backend/api/views.py @@ -1,10 +1,13 @@ -from rest_framework.decorators import api_view, action, permission_classes +from rest_framework.decorators import api_view, action, permission_classes, throttle_classes from rest_framework.response import Response from rest_framework import status, viewsets from django.http import FileResponse +from django.conf import settings from django.contrib.auth.models import User from rest_framework.generics import CreateAPIView -from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework.permissions import AllowAny, IsAdminUser, IsAuthenticated +from rest_framework.throttling import AnonRateThrottle, UserRateThrottle +from rest_framework.exceptions import ValidationError from django.shortcuts import get_object_or_404 import subprocess import tempfile @@ -22,6 +25,7 @@ from rest_framework_simplejwt.views import TokenObtainPairView from .formula_data import get_formula_data, get_classes_with_details, get_special_class_formula, is_special_class from .latex_utils import build_latex_for_formulas, normalize_latex_layout +from .compiler import validate_cheat_sheet_id, validate_source_text YOUTUBE_MAX_TOPICS = 6 YOUTUBE_SEARCH_RESULT_LIMIT = 5 @@ -61,6 +65,20 @@ def is_truthy(value): return value.strip().lower() in {"1", "true", "yes", "on"} return bool(value) + +class CompileAnonThrottle(AnonRateThrottle): + scope = "compile_anon" + + def get_rate(self): + return settings.COMPILER_ANON_RATE + + +class CompileUserThrottle(UserRateThrottle): + scope = "compile_user" + + def get_rate(self): + return settings.COMPILER_USER_RATE + def validate_layout_params(columns, font_size, margins, spacing, orientation="portrait"): try: columns = max(1, min(5, int(columns))) @@ -351,6 +369,7 @@ def generate_sheet(request): @api_view(["POST"]) @permission_classes([AllowAny]) +@throttle_classes([CompileAnonThrottle, CompileUserThrottle]) def compile_latex(request): """ POST /api/compile/ @@ -366,7 +385,12 @@ def compile_latex(request): columns, font_size, margins, spacing, orientation = validate_layout_params(columns, font_size, margins, spacing, orientation) - if cheat_sheet_id: + if cheat_sheet_id is not None: + if not request.user.is_authenticated: + return Response({"error": "Authentication required"}, status=status.HTTP_401_UNAUTHORIZED) + cheat_sheet_id = validate_cheat_sheet_id(cheat_sheet_id) + if cheat_sheet_id is None: + return Response({"error": "cheat_sheet_id must be a positive integer"}, status=400) cheatsheet = get_object_or_404(CheatSheet, pk=cheat_sheet_id, user=request.user) columns = cheatsheet.columns font_size = cheatsheet.font_size @@ -374,7 +398,13 @@ def compile_latex(request): spacing = cheatsheet.spacing orientation = getattr(cheatsheet, "orientation", None) or "portrait" content = cheatsheet.build_full_latex() - + + source_error = validate_source_text(content) + if source_error: + return Response( + {"error": source_error}, + status=413 if source_error == "LaTeX content exceeds the maximum allowed size" else 400, + ) if not content: return Response({"error": "No LaTeX content provided"}, status=400) @@ -403,28 +433,19 @@ def compile_latex(request): subprocess.run( ["tectonic", tex_file_path], cwd=tempdir, - capture_output=True, - text=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, check=True, + timeout=settings.COMPILER_TIMEOUT_SECONDS, ) + except subprocess.TimeoutExpired: + return Response({"error": "LaTeX compilation timed out"}, status=408) except FileNotFoundError: - return Response( - {"error": "Tectonic is not installed on the backend."}, - status=500, - ) - except subprocess.CalledProcessError as e: - return Response( - { - "error": "Failed to compile LaTeX", - "details": e.stderr or e.stdout or "LaTeX compilation failed without additional output.", - }, - status=400, - ) - except Exception as e: - return Response( - {"error": "Failed to compile LaTeX", "details": str(e)}, - status=500, - ) + return Response({"error": "Failed to compile LaTeX"}, status=500) + except subprocess.CalledProcessError: + return Response({"error": "Failed to compile LaTeX"}, status=400) + except Exception: + return Response({"error": "Failed to compile LaTeX"}, status=500) pdf_file_path = os.path.join(tempdir, "document.pdf") if os.path.exists(pdf_file_path): @@ -508,6 +529,11 @@ class TemplateViewSet(viewsets.ModelViewSet): queryset = Template.objects.all() serializer_class = TemplateSerializer + def get_permissions(self): + if self.action in {"list", "retrieve"}: + return [AllowAny()] + return [IsAdminUser()] + def get_queryset(self): queryset = super().get_queryset() subject = self.request.query_params.get('subject') @@ -542,8 +568,10 @@ def from_template(self, request): user=request.user, template=template, latex_content=template.latex_content, + content_source="generated", margins=template.default_margins, columns=template.default_columns, + selected_formulas=template.selected_formulas, ) serializer = self.get_serializer(cheatsheet) @@ -553,10 +581,14 @@ def from_template(self, request): class PracticeProblemViewSet(viewsets.ModelViewSet): queryset = PracticeProblem.objects.all() serializer_class = PracticeProblemSerializer + permission_classes = [IsAuthenticated] def get_queryset(self): - queryset = super().get_queryset() + queryset = super().get_queryset().filter(cheat_sheet__user=self.request.user) cheat_sheet_id = self.request.query_params.get('cheat_sheet') - if cheat_sheet_id: + if cheat_sheet_id is not None: + cheat_sheet_id = validate_cheat_sheet_id(cheat_sheet_id) + if cheat_sheet_id is None: + raise ValidationError({"cheat_sheet": "Must be a canonical positive integer."}) queryset = queryset.filter(cheat_sheet=cheat_sheet_id) - return queryset \ No newline at end of file + return queryset diff --git a/backend/cheat_sheet/settings.py b/backend/cheat_sheet/settings.py index e55aa32..af8a8da 100644 --- a/backend/cheat_sheet/settings.py +++ b/backend/cheat_sheet/settings.py @@ -133,3 +133,8 @@ SIMPLE_JWT = { "SIGNING_KEY": JWT_SIGNING_KEY, } + +COMPILER_SOURCE_MAX_BYTES = int(os.getenv("COMPILER_SOURCE_MAX_BYTES", 256 * 1024)) +COMPILER_TIMEOUT_SECONDS = int(os.getenv("COMPILER_TIMEOUT_SECONDS", 15)) +COMPILER_ANON_RATE = os.getenv("COMPILER_ANON_RATE", "10/hour") +COMPILER_USER_RATE = os.getenv("COMPILER_USER_RATE", "60/hour") diff --git a/docs/REFACTORING_PLAN.md b/docs/REFACTORING_PLAN.md new file mode 100644 index 0000000..4725323 --- /dev/null +++ b/docs/REFACTORING_PLAN.md @@ -0,0 +1,179 @@ +# TeXGen Refactoring Plan + +Approved: August 25, 2026 + +## Objective + +Prepare TeXGen for a small public production launch without a full rewrite. The work should make template editing, topic removal, manual LaTeX editing, compilation, saving, and reloading deterministic while improving security, maintainability, accessibility, and deployment safety. + +## Product decisions + +- Support limited anonymous PDF compilation with strict quotas and isolation. +- Support structured editing and an advanced raw-LaTeX mode. +- Preserve manual edits by default; replacing source requires explicit regeneration. +- Treat templates as staff-curated and publicly readable. +- Optimize initially for a small public launch on portable Linux containers. +- No existing production data or external API clients require compatibility. + +## Confirmed problem areas + +- Template-created sheets do not preserve formula-selection provenance. +- Topic removal changes selection state but does not alter existing raw LaTeX. +- Regeneration removes deselected topics but replaces manual edits. +- Individual formula removal can leave category controls inconsistent with generated formulas. +- Multiple local-storage representations can combine one sheet's formulas with another sheet's source. +- Late generation, compilation, or save responses can undo a clear/reset operation. +- Backend permissions and public Tectonic compilation are unsafe for public deployment. +- Rendering, normalization, and compilation responsibilities are duplicated across models, views, and utilities. +- The creator and Dashboard contain responsive, accessibility, error-recovery, and request-ordering failures. +- CI, dependency versions, and production runtime expectations are inconsistent. + +## Phase 1 — Baseline, regression tests, and emergency fixes + +- [x] Rebuild the PR lane from current `origin/main` without unrelated history. +- [x] Establish an isolated branch or worktree and a known baseline commit. +- [x] Run backend tests, frontend tests, lint, and builds; record pre-existing failures. +- [x] Add regression coverage for template → edit → remove topic → compile → save → reload. +- [x] Add coverage for stale local storage, formula/category removal, request races, and compiler failures. +- [x] Fix formula, category, and class selection-state inconsistencies. +- [x] Apply deterministic precedence between matching namespaced drafts and explicit sheet/template selections. +- [x] Namespace drafts by sheet or draft ID. +- [x] Define separate Generate and Compile behavior for structured and raw source. +- [x] Prevent invalidated generation, normalization, compilation, download, and save work from publishing after a manual/history edit, clear, reset, or unmount. +- [x] Require ownership for user resources and staff-only template writes. +- [x] Add initial compiler authentication policy, anonymous quotas, timeouts, size limits, and safe diagnostics. + +### Gate + +The reported template workflow is deterministic and regression tests cover the repaired behavior. Anonymous compilation remains unsuitable for production until the required shared throttling, trusted client-IP handling, and isolation controls exist. + +### Phase 1 execution status — August 26, 2026 + +Evidence recorded in `omos/phase1-correctness-pr`, rebuilt from `origin/main` at `70c10ec` to avoid unrelated history: + +- Clean-base validation passed 100 frontend tests and 128 backend tests, frontend lint and production build, Django check, migration dry-run, and Python compilation. +- Matching namespaced drafts recover over stale same-identity server selections. Explicit selections, including `[]`, win only when no matching namespace exists; unrelated and legacy drafts do not override. +- Formula removal preserves sibling, final-category, and same-class selection invariants. +- Generate intentionally rebuilds source from selections and compiles. Compile uses current non-empty source, auto-generating only when source is empty. +- Manual or history edits invalidate active generation, normalization, compilation, and download work. The newest current operation owns publication; downloads do not mutate editor content; successful PDF URLs retain their exact compile snapshots. +- `Phase1Journey.test.jsx` is component integration with mocked persistence, covering explicit/template data → manual edit → removal → compile → save → storage clear → reload with a distinct persisted identity. App-level tests separately cover real save payload/response mapping, matching-draft recovery, reload, and pending-save unmount behavior. +- Template selection persistence, permissions and ownership, authenticated sheet-ID compile-path validation and related source validation, plus throttles, timeouts, and generic compiler errors are covered. +- Some document fields already exist in current `origin/main`; this is baseline context, not Phase 2 completion or work attributed to this diff. + +Phase 1 is not complete. Remaining containment work: + +- Configure shared/global throttle storage and trusted proxy/client-IP handling; the current in-process cache cannot enforce global quotas across workers. +- Add process-group, container, filesystem, network, CPU, memory, process-count, and output isolation. Anonymous compilation is not production-safe until these controls and the shared throttling and client-IP controls exist. + +## Phase 2 — Document model and backend rendering core + +- [ ] Define one canonical document contract containing title, source mode, source LaTeX, layout, formula selections, and timestamps. +- [ ] Persist every layout field, including spacing. +- [ ] Classify each field as plain text or raw LaTeX and enforce one escaping policy. +- [ ] Represent generated sections with stable topic and formula identities. +- [ ] Preserve custom user content separately from generated sections. +- [ ] Make raw source authoritative in advanced mode. +- [ ] Require an explicit, reversible regeneration action before replacing manual source. +- [ ] Extract layout validation, formula resolution, document assembly, and practice-problem rendering into focused services. +- [ ] Create one compiler adapter and remove duplicate Tectonic invocation paths. +- [ ] Keep Django models focused on persistence and views focused on HTTP orchestration. +- [ ] Restrict normalization to known generated documents rather than parsing arbitrary TeX with regexes. +- [ ] Fully isolate Tectonic with offline assets, resource limits, bounded diagnostics, and verified downloads. +- [ ] Add golden document tests and one real offline compilation smoke test. + +### Gate + +One backend path owns document construction and compilation, and structured documents can remove topics without damaging unrelated manual content. + +## Phase 3 — Frontend state, networking, and race-condition cleanup + +- [ ] Replace competing document copies with one reducer-backed editor session. +- [ ] Version and migrate the local draft format. +- [ ] Derive checkbox state, selected counts, and generation payloads from one canonical ordered selection. +- [ ] Centralize API paths, auth headers, token refresh, payload mapping, errors, and cancellation. +- [ ] Remove raw `fetch()` orchestration from UI components. +- [ ] Add request IDs or editor-version tokens and ignore obsolete responses. +- [ ] Ensure only the newest generation and compilation can update content or preview. +- [ ] Abort work invalidated by clear, reset, navigation, or document changes. +- [ ] Preserve the last valid PDF after a failed compile and clean up obsolete object URLs. +- [ ] Model Generate, Compile, Save, Clear, Restore, and Regenerate as explicit transitions. +- [ ] Add reducer, hook integration, save/reload, and stale-response tests. + +### Gate + +One state owner controls the editor, selection and source cannot silently disagree, and stale asynchronous responses cannot corrupt a session. + +## Phase 4 — UI stabilization and component decomposition + +- [ ] Fix the inline grid rule that defeats mobile creator layouts. +- [ ] Verify the creator at 320px, 375px, 768px, and desktop widths. +- [ ] Fix Dashboard card and action overflow. +- [ ] Make dialogs usable on short and landscape viewports. +- [ ] Add visible loading, failure, retry, and recovery states. +- [ ] Make selection controls and collapsible groups keyboard-operable. +- [ ] Add an accessible video dialog with focus management and Escape handling. +- [ ] Add live status announcements and reduced-motion behavior. +- [ ] Reduce `CreateCheatSheet.jsx` to a coordinator after state consolidation. +- [ ] Extract formula selection, reorder, layout, editor, preview, resources, and dialog components. +- [ ] Split LaTeX behavior into editor state, rendering operations, history, and PDF lifecycle. +- [ ] Split CSS by feature while preserving the current visual design. +- [ ] Remove dead controls and unused handlers. + +### Gate + +Creator and Dashboard workflows work on mobile and desktop and are operable using a keyboard. + +## Phase 5 — Full validation, CI, and production readiness + +- [ ] Add App/editor integration tests and a real-backend Playwright smoke journey. +- [ ] Remove conditional and swallowed end-to-end assertions. +- [ ] Complete API permission and compiler-abuse test matrices. +- [ ] Consolidate the two CI workflows and standardize Python and Node versions. +- [ ] Run backend lint/tests/security checks and frontend lint/tests/build in one required pipeline. +- [ ] Lock Python dependencies and use `npm ci` consistently. +- [ ] Verify and checksum Tectonic assets. +- [ ] Create separate development and production container targets. +- [ ] Run Django through a production WSGI/ASGI server and serve static frontend assets appropriately. +- [ ] Add deployment checks, health checks, explicit migrations, and offline compilation verification. +- [ ] Deploy initially to managed Linux containers with explicit resource and concurrency limits. + +### Gate + +One CI pipeline proves the complete supported workflow, and production images run representative document compilation offline and within enforced limits. + +## Deferred work + +- Replacing Django or React. +- Introducing microservices before measured need. +- Adding Celery or Redis solely for compilation. +- Adding Redux solely for editor state. +- Migrating to TypeScript as an initial cleanup. +- Building a general-purpose LaTeX parser. +- Redesigning the formula catalog before editor correctness is restored. +- Broad visual restyling during state and responsive repairs. +- Pursuing arbitrary 100% coverage targets. + +## Completion criteria + +- [ ] Template → edit → topic removal has deterministic behavior. +- [ ] Manual edits are never silently discarded. +- [ ] Clear/reset cannot be reversed by stale responses. +- [ ] Selection controls always match generated payloads. +- [ ] Content, formulas, and layout survive save and reload. +- [ ] Anonymous compilation is isolated, bounded, and rate-limited. +- [ ] Cross-user resource access is prevented. +- [ ] Mobile and keyboard workflows pass. +- [ ] One CI pipeline validates the production workflow. +- [ ] Production images compile representative documents offline. + +## Download and installation gate + +Saving this plan does not authorize downloads or installations. Before execution, obtain fresh approval for each applicable item with its exact version, source, and measured disk impact: + +- Frontend dependencies from the npm registry for `npm ci`. +- Python dependencies from PyPI or the project's selected package source. +- Playwright browser binaries from Microsoft's Playwright distribution source. +- Tectonic binaries, bundles, or package caches from verified official sources. +- Docker base images and supporting service images from their configured registries. + +**Download danger summary:** No persisted download or installation is approved by this document. Items exceeding 1 GiB require separate explicit approval even if an earlier, smaller download was approved. diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 624cf0b..72049f4 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -13,6 +13,17 @@ const CURRENT_SHEET_STORAGE_KEY = 'currentCheatSheet'; const UNTITLED_COUNTER_STORAGE_KEY = 'untitledSheetCounter'; const COMPILE_HISTORY_STORAGE_PREFIX = 'cheatSheetCompileHistory'; const CONTENT_SOURCE_STORAGE_PREFIX = 'cheatSheetContentSource'; +const createDraftId = () => `draft-${Date.now()}-${Math.random().toString(36).slice(2)}`; +const withDraftIdentity = (sheet) => sheet?.id || sheet?.draftId ? sheet : { ...sheet, draftId: createDraftId() }; +const stripTransientPdfBlobs = (value) => { + if (Array.isArray(value)) return value.map(stripTransientPdfBlobs); + if (!value || typeof value !== 'object') return value; + + return Object.fromEntries(Object.entries(value) + .filter(([key]) => key !== 'pdfBlob') + .map(([key, item]) => [key, stripTransientPdfBlobs(item)])); +}; +const sanitizeSheet = (sheet) => stripTransientPdfBlobs(sheet) || {}; const getNextUntitledTitle = () => { const currentValue = Number(localStorage.getItem(UNTITLED_COUNTER_STORAGE_KEY) || '0'); @@ -29,6 +40,7 @@ const createDefaultSheet = () => ({ fontSize: '9pt', spacing: 'small', margins: '0.15in', + orientation: 'portrait', selectedFormulas: [], compileHistory: [], }); @@ -38,7 +50,16 @@ const inferContentSource = ({ content = '' } = {}) => { return 'manual'; }; -const sameFormulas = (left = [], right = []) => JSON.stringify(left) === JSON.stringify(right); +const sameFormulas = (left, right) => ( + Array.isArray(left) + && Array.isArray(right) + && left.length === right.length + && left.every((formula, index) => ( + formula?.class === right[index]?.class + && formula?.category === right[index]?.category + && formula?.name === right[index]?.name + )) +); const sameSnapshot = (left, right) => { if (!left || !right) return false; @@ -51,11 +72,12 @@ const sameSnapshot = (left, right) => { && left.fontSize === right.fontSize && left.spacing === right.spacing && left.margins === right.margins + && left.orientation === right.orientation && sameFormulas(left.selectedFormulas, right.selectedFormulas) ); }; -const buildRestoredSheet = (baseSheet, snapshot) => ({ +const buildRestoredSheet = (baseSheet, snapshot) => sanitizeSheet({ ...baseSheet, title: snapshot.title ?? baseSheet.title, content: snapshot.content ?? '', @@ -64,8 +86,9 @@ const buildRestoredSheet = (baseSheet, snapshot) => ({ fontSize: snapshot.fontSize ?? baseSheet.fontSize, spacing: snapshot.spacing ?? baseSheet.spacing, margins: snapshot.margins ?? baseSheet.margins, + orientation: snapshot.orientation ?? baseSheet.orientation, selectedFormulas: snapshot.selectedFormulas ?? [], - compileHistory: Array.isArray(baseSheet.compileHistory) ? baseSheet.compileHistory : [], + compileHistory: Array.isArray(baseSheet.compileHistory) ? stripTransientPdfBlobs(baseSheet.compileHistory) : [], }); const loadStoredSheet = () => { @@ -73,7 +96,7 @@ const loadStoredSheet = () => { if (!saved) return null; try { - return JSON.parse(saved); + return withDraftIdentity(sanitizeSheet(JSON.parse(saved))); } catch (e) { console.error('Failed to parse sheet', e); return null; @@ -90,7 +113,7 @@ const getStoredCompileHistory = (sheetId) => { if (savedHistory) { try { const parsedHistory = JSON.parse(savedHistory); - if (Array.isArray(parsedHistory)) return parsedHistory; + if (Array.isArray(parsedHistory)) return stripTransientPdfBlobs(parsedHistory); } catch (e) { console.error('Failed to parse compile history', e); } @@ -101,12 +124,12 @@ const getStoredCompileHistory = (sheetId) => { return []; } - return Array.isArray(storedSheet.compileHistory) ? storedSheet.compileHistory : []; + return Array.isArray(storedSheet.compileHistory) ? stripTransientPdfBlobs(storedSheet.compileHistory) : []; }; const saveStoredCompileHistory = (sheetId, compileHistory = []) => { if (!sheetId) return; - localStorage.setItem(getCompileHistoryStorageKey(sheetId), JSON.stringify(compileHistory)); + localStorage.setItem(getCompileHistoryStorageKey(sheetId), JSON.stringify(stripTransientPdfBlobs(compileHistory))); }; const getStoredContentSource = (sheetId) => { @@ -161,18 +184,24 @@ function App() { const saved = localStorage.getItem(CURRENT_SHEET_STORAGE_KEY); if (saved) { try { - return JSON.parse(saved); + const sheet = withDraftIdentity(sanitizeSheet(JSON.parse(saved))); + localStorage.setItem(CURRENT_SHEET_STORAGE_KEY, JSON.stringify(sheet)); + return sheet; } catch (e) { console.error("Failed to parse sheet", e); } } - return createDefaultSheet(); + const sheet = withDraftIdentity(createDefaultSheet()); + localStorage.setItem(CURRENT_SHEET_STORAGE_KEY, JSON.stringify(sheet)); + return sheet; }); const [editorSessionKey, setEditorSessionKey] = useState(0); const [isSaving, setIsSaving] = useState(false); const cheatSheetRef = useRef(cheatSheet); const pendingCreatePromiseRef = useRef(null); + const saveEpochRef = useRef(0); + const saveControllerRef = useRef(null); const [theme, setTheme] = useState(() => { const saved = localStorage.getItem('theme'); return normalizeTheme(saved); @@ -187,11 +216,22 @@ function App() { cheatSheetRef.current = cheatSheet; }, [cheatSheet]); + useEffect(() => () => { + saveEpochRef.current += 1; + saveControllerRef.current?.abort(); + saveControllerRef.current = null; + pendingCreatePromiseRef.current = null; + }, []); + const { user, authTokens, logoutUser } = useContext(AuthContext); const handleReset = () => { - const nextSheet = createDefaultSheet(); + saveEpochRef.current += 1; + saveControllerRef.current?.abort(); + pendingCreatePromiseRef.current = null; + setIsSaving(false); + const nextSheet = withDraftIdentity(createDefaultSheet()); setCheatSheet(nextSheet); setEditorSessionKey((prev) => prev + 1); localStorage.setItem(CURRENT_SHEET_STORAGE_KEY, JSON.stringify(nextSheet)); @@ -207,23 +247,27 @@ function App() { }, []); const handleSave = async (data, showFeedback = true) => { - const currentSheet = cheatSheetRef.current; - const nextContentSource = data.contentSource ?? currentSheet.contentSource ?? inferContentSource(data); - const previousHistory = Array.isArray(currentSheet.compileHistory) ? currentSheet.compileHistory : []; + const saveEpoch = showFeedback ? ++saveEpochRef.current : saveEpochRef.current; + const sanitizedData = sanitizeSheet(data); + const currentSheet = sanitizeSheet(cheatSheetRef.current); + const nextContentSource = sanitizedData.contentSource ?? currentSheet.contentSource ?? inferContentSource(sanitizedData); + const previousHistory = Array.isArray(currentSheet.compileHistory) ? stripTransientPdfBlobs(currentSheet.compileHistory) : []; const latestSnapshot = previousHistory[previousHistory.length - 1]; - const nextHistory = data.compileSnapshot - ? (sameSnapshot(latestSnapshot, data.compileSnapshot) + const nextSnapshot = sanitizedData.compileSnapshot; + const nextHistory = nextSnapshot + ? (sameSnapshot(latestSnapshot, nextSnapshot) ? previousHistory - : [...previousHistory, data.compileSnapshot]) + : [...previousHistory, nextSnapshot]) : previousHistory; const nextSheet = { ...currentSheet, - ...data, + ...sanitizedData, contentSource: nextContentSource, - selectedFormulas: data.selectedFormulas ?? currentSheet.selectedFormulas ?? [], + selectedFormulas: sanitizedData.selectedFormulas ?? currentSheet.selectedFormulas ?? [], compileHistory: nextHistory, }; delete nextSheet.compileSnapshot; + const submittedSelectedFormulas = stripTransientPdfBlobs(nextSheet.selectedFormulas ?? []); cheatSheetRef.current = nextSheet; setCheatSheet(nextSheet); @@ -243,12 +287,15 @@ function App() { } setIsSaving(true); + const controller = new globalThis.AbortController(); + saveControllerRef.current?.abort(); + saveControllerRef.current = controller; try { let sheetId = nextSheet.id; if (!sheetId && pendingCreatePromiseRef.current) { - const pendingSheet = await pendingCreatePromiseRef.current.catch(() => null); + const pendingSheet = await pendingCreatePromiseRef.current.promise.catch(() => null); if (pendingSheet?.id) { sheetId = pendingSheet.id; } @@ -260,6 +307,7 @@ function App() { 'Content-Type': 'application/json', ...(authTokens?.access ? { 'Authorization': `Bearer ${authTokens.access}` } : {}), }, + signal: controller.signal, body: JSON.stringify({ title: nextSheet.title, latex_content: nextSheet.content, @@ -268,12 +316,13 @@ function App() { margins: nextSheet.margins, font_size: nextSheet.fontSize, spacing: nextSheet.spacing, - selected_formulas: nextSheet.selectedFormulas, + orientation: nextSheet.orientation, + selected_formulas: submittedSelectedFormulas, }), }); if (!sheetId) { - pendingCreatePromiseRef.current = requestPromise + pendingCreatePromiseRef.current = { epoch: saveEpoch, promise: requestPromise .then(async (response) => { if (!response.ok) { const errorData = await response.json().catch(() => ({})); @@ -288,8 +337,8 @@ function App() { contentSource: savedSheet.content_source ?? nextSheet.contentSource, fontSize: savedSheet.font_size ?? nextSheet.fontSize, spacing: savedSheet.spacing ?? nextSheet.spacing, - selectedFormulas: savedSheet.selected_formulas ?? nextSheet.selectedFormulas, - })); + selectedFormulas: stripTransientPdfBlobs(savedSheet.selected_formulas) ?? nextSheet.selectedFormulas, + })) }; } const response = await requestPromise; @@ -298,39 +347,62 @@ function App() { throw new Error(errorData.detail || errorData.error || 'Failed to save cheat sheet'); } - const savedSheet = await response.json(); - const persistedSheet = { - ...nextSheet, - id: savedSheet.id, - content: savedSheet.latex_content ?? nextSheet.content, - contentSource: savedSheet.content_source ?? nextSheet.contentSource, - fontSize: savedSheet.font_size ?? nextSheet.fontSize, - spacing: savedSheet.spacing ?? nextSheet.spacing, - selectedFormulas: savedSheet.selected_formulas ?? nextSheet.selectedFormulas, - }; - - cheatSheetRef.current = persistedSheet; - setCheatSheet(persistedSheet); - localStorage.setItem(CURRENT_SHEET_STORAGE_KEY, JSON.stringify(persistedSheet)); + const savedSheet = sanitizeSheet(await response.json()); + if (saveEpoch !== saveEpochRef.current) return nextSheet; + let persistedSheet; + setCheatSheet((currentSheet) => { + const sanitizedCurrentSheet = sanitizeSheet(currentSheet); + if (sanitizedCurrentSheet.draftId !== nextSheet.draftId) return sanitizedCurrentSheet; + const serverFields = { + id: savedSheet.id, + title: savedSheet.title, + content: savedSheet.latex_content, + contentSource: savedSheet.content_source, + columns: savedSheet.columns, + margins: savedSheet.margins, + fontSize: savedSheet.font_size, + spacing: savedSheet.spacing, + orientation: savedSheet.orientation, + }; + persistedSheet = { ...sanitizedCurrentSheet, id: savedSheet.id ?? sanitizedCurrentSheet.id }; + Object.entries(serverFields).forEach(([field, value]) => { + if (value !== undefined && sanitizedCurrentSheet[field] === nextSheet[field]) persistedSheet[field] = value; + }); + if (Array.isArray(savedSheet.selected_formulas) + && sameFormulas(sanitizedCurrentSheet.selectedFormulas, submittedSelectedFormulas)) { + persistedSheet.selectedFormulas = stripTransientPdfBlobs(savedSheet.selected_formulas); + } + persistedSheet = sanitizeSheet(persistedSheet); + cheatSheetRef.current = persistedSheet; + localStorage.setItem(CURRENT_SHEET_STORAGE_KEY, JSON.stringify(persistedSheet)); + return persistedSheet; + }); + if (!persistedSheet) return nextSheet; saveStoredCompileHistory(persistedSheet.id, persistedSheet.compileHistory); saveStoredContentSource(persistedSheet.id, persistedSheet.contentSource); alert('Progress saved!'); return persistedSheet; } catch (error) { + if (saveEpoch !== saveEpochRef.current) return nextSheet; console.error('Failed to save cheat sheet', error); alert(`Failed to save progress: ${error.message}`); throw error; } finally { - if (pendingCreatePromiseRef.current) { + if (pendingCreatePromiseRef.current?.epoch === saveEpoch) { pendingCreatePromiseRef.current = null; } - setIsSaving(false); + if (saveEpoch === saveEpochRef.current) setIsSaving(false); + if (saveControllerRef.current === controller) saveControllerRef.current = null; } }; const handleEditSheet = (sheet) => { - const selectedFormulas = sheet.selected_formulas || []; - const editSheet = { + saveEpochRef.current += 1; + saveControllerRef.current?.abort(); + pendingCreatePromiseRef.current = null; + setIsSaving(false); + const selectedFormulas = stripTransientPdfBlobs(sheet.selected_formulas || []); + const editSheet = sanitizeSheet({ id: sheet.id, title: sheet.title, content: sheet.latex_content, @@ -342,9 +414,11 @@ function App() { margins: sheet.margins, fontSize: sheet.font_size, spacing: sheet.spacing, + orientation: sheet.orientation, selectedFormulas, compileHistory: getStoredCompileHistory(sheet.id), - }; + draftId: `sheet-${sheet.id}`, + }); setCheatSheet(editSheet); setEditorSessionKey((prev) => prev + 1); localStorage.setItem(CURRENT_SHEET_STORAGE_KEY, JSON.stringify(editSheet)); @@ -432,8 +506,9 @@ function App() { ({ childMount: vi.fn() })); + +vi.mock('framer-motion', () => ({ + motion: new Proxy({}, { get: () => 'div' }), +})); + +vi.mock('lucide-react', () => ({ + Home: () => null, + LayoutDashboard: () => null, + LogIn: () => null, + LogOut: () => null, + Palette: () => null, +})); + +vi.mock('./components/CreateCheatSheet', () => ({ + default: function MockCreateCheatSheet({ initialData, isSaving, onReset, onRestoreSnapshot, onSave }) { + const [localEdit, setLocalEdit] = useState(''); + useEffect(() => { + mocks.childMount(); + }, []); + + const save = (title, content = `${title} content`) => onSave({ + title, + content, + contentSource: 'manual', + orientation: 'landscape', + selectedFormulas: [{ name: `${title} formula` }], + }); + + return ( +
+ setLocalEdit(event.target.value)} /> + {JSON.stringify(initialData)} + {String(isSaving)} + + + + + + + + + + Dashboard +
+ ); + }, +})); + +vi.mock('react-pdf', () => ({ + Document: ({ children, onLoadSuccess }) => { + useEffect(() => onLoadSuccess?.({ numPages: 1 }), [onLoadSuccess]); + return
{children}
; + }, + Page: () =>
, + pdfjs: { GlobalWorkerOptions: { workerSrc: '' } }, +})); + +vi.mock('./components/Dashboard', () => ({ + default: ({ onEditSheet }) => ( + + ), +})); + +vi.mock('./components/Login', () => ({ default: () =>
Login
})); +vi.mock('./components/SignUp', () => ({ default: () =>
Sign up
})); + +const deferred = () => { + let resolve; + const promise = new Promise((done) => { resolve = done; }); + return { promise, resolve }; +}; + +const response = (data) => ({ + ok: true, + json: vi.fn().mockResolvedValue(data), + clone() { + return { json: vi.fn().mockResolvedValue(data) }; + }, +}); + +const renderApp = () => render( + + + + + , +); + +const storedSheet = () => JSON.parse(localStorage.getItem('currentCheatSheet')); +const containsTransientBlob = (value) => { + if (Array.isArray(value)) return value.some(containsTransientBlob); + return value && typeof value === 'object' && Object.entries(value).some(([key, item]) => key === 'pdfBlob' || containsTransientBlob(item)); +}; + +describe('App save lifecycle regressions', () => { + beforeEach(() => { + localStorage.clear(); + window.history.replaceState({}, '', '/'); + mocks.childMount.mockClear(); + vi.stubGlobal('alert', vi.fn()); + }); + + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + localStorage.clear(); + }); + + it('assigns a created sheet ID without remounting or overwriting a child-local edit', async () => { + const create = deferred(); + vi.stubGlobal('fetch', vi.fn(() => create.promise)); + renderApp(); + + fireEvent.change(screen.getByLabelText('child local edit'), { target: { value: 'unsaved child edit' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save first' })); + await waitFor(() => expect(fetch).toHaveBeenCalledWith('/api/cheatsheets/', expect.objectContaining({ method: 'POST' }))); + + await act(async () => create.resolve(response({ id: 41, title: 'first', latex_content: 'server content' }))); + + await waitFor(() => expect(storedSheet()).toEqual(expect.objectContaining({ id: 41 }))); + expect(screen.getByLabelText('child local edit')).toHaveValue('unsaved child edit'); + expect(mocks.childMount).toHaveBeenCalledTimes(1); + }); + + it.each([ + ['reset', async () => fireEvent.click(screen.getByRole('button', { name: 'Reset sheet' }))], + ['edit', async () => { + fireEvent.click(screen.getByRole('link', { name: 'Open test dashboard' })); + await screen.findByRole('button', { name: 'Edit sheet' }); + fireEvent.click(screen.getByRole('button', { name: 'Edit sheet' })); + }], + ])('invalidates a stale save when the sheet is changed by %s', async (_name, changeSheet) => { + const save = deferred(); + vi.stubGlobal('fetch', vi.fn(() => save.promise)); + renderApp(); + + fireEvent.click(screen.getByRole('button', { name: 'Save first' })); + await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1)); + await changeSheet(); + const currentTitle = storedSheet().title; + + await act(async () => save.resolve(response({ id: 99, title: 'stale title', latex_content: 'stale content' }))); + await waitFor(() => expect(storedSheet().title).toBe(currentTitle)); + expect(storedSheet()).not.toEqual(expect.objectContaining({ id: 99, title: 'stale title' })); + expect(alert).not.toHaveBeenCalled(); + }); + + it('keeps the newest feedback save data, ID, and loading state when responses resolve out of order', async () => { + localStorage.setItem('currentCheatSheet', JSON.stringify({ + id: 5, draftId: 'sheet-5', title: 'existing', content: '', contentSource: 'empty', columns: 4, + fontSize: '9pt', spacing: 'small', margins: '0.15in', orientation: 'portrait', selectedFormulas: [], compileHistory: [], + })); + const first = deferred(); + const second = deferred(); + vi.stubGlobal('fetch', vi.fn().mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise)); + renderApp(); + + fireEvent.click(screen.getByRole('button', { name: 'Save first' })); + fireEvent.click(screen.getByRole('button', { name: 'Save second' })); + await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); + expect(screen.getByTestId('saving-state')).toHaveTextContent('true'); + + await act(async () => second.resolve(response({ id: 5, title: 'second', latex_content: 'second server content', content_source: 'manual' }))); + await waitFor(() => expect(storedSheet()).toEqual(expect.objectContaining({ id: 5, title: 'second', content: 'second server content' }))); + expect(screen.getByTestId('saving-state')).toHaveTextContent('false'); + + await act(async () => first.resolve(response({ id: 5, title: 'first', latex_content: 'first server content' }))); + await waitFor(() => expect(storedSheet().title).toBe('second')); + expect(storedSheet().content).toBe('second server content'); + expect(screen.getByTestId('saving-state')).toHaveTextContent('false'); + }); + + it('persists save metadata through reload and restores an orientation-only snapshot', async () => { + vi.stubGlobal('fetch', vi.fn()); + const firstRender = renderApp(); + + fireEvent.click(screen.getByRole('button', { name: 'Save snapshot' })); + await waitFor(() => expect(storedSheet()).toEqual(expect.objectContaining({ + contentSource: 'generated', orientation: 'portrait', selectedFormulas: [{ name: 'snapshot formula' }], + compileHistory: [expect.objectContaining({ title: 'snapshot title' })], + }))); + firstRender.unmount(); + renderApp(); + + expect(JSON.parse(screen.getByTestId('sheet-state').textContent)).toEqual(expect.objectContaining({ + contentSource: 'generated', orientation: 'portrait', selectedFormulas: [{ name: 'snapshot formula' }], + compileHistory: [expect.objectContaining({ title: 'snapshot title' })], + })); + fireEvent.click(screen.getByRole('button', { name: 'Restore orientation' })); + await waitFor(() => expect(storedSheet().orientation).toBe('landscape')); + }); + + it('strips transient blob URLs from saved sheets and compile history', async () => { + vi.stubGlobal('fetch', vi.fn()); + localStorage.setItem('currentCheatSheet', JSON.stringify({ + id: 7, draftId: 'sheet-7', title: 'existing', content: '', contentSource: 'empty', columns: 4, + fontSize: '9pt', spacing: 'small', margins: '0.15in', orientation: 'portrait', selectedFormulas: [], compileHistory: [], + })); + renderApp(); + + fireEvent.click(screen.getByRole('button', { name: 'Save transient snapshot' })); + await waitFor(() => expect(storedSheet().compileHistory).toHaveLength(1)); + expect(containsTransientBlob(storedSheet())).toBe(false); + expect(containsTransientBlob(JSON.parse(localStorage.getItem('cheatSheetCompileHistory:7')))).toBe(false); + expect(storedSheet()).toEqual(expect.objectContaining({ title: 'transient snapshot', content: 'transient content' })); + }); + + it('preserves durable blob-prefixed data while excluding pdfBlob from remote and local persistence', async () => { + localStorage.setItem('currentCheatSheet', JSON.stringify({ + id: 7, draftId: 'sheet-7', title: 'existing', content: '', contentSource: 'empty', columns: 4, + fontSize: '9pt', spacing: 'small', margins: '0.15in', orientation: 'portrait', selectedFormulas: [], compileHistory: [], + })); + vi.stubGlobal('fetch', vi.fn(() => Promise.resolve(response({ + id: 7, title: 'blob:notes', latex_content: 'blob:legitimate LaTeX text', content_source: 'manual', + columns: 4, font_size: '9pt', spacing: 'small', margins: '0.15in', orientation: 'portrait', + selected_formulas: [{ name: 'blob:durable', pdfBlob: 'blob:response-transient', nested: { text: 'blob:keep', pdfBlob: 'blob:response-drop' } }], + })))); + renderApp(); + + fireEvent.click(screen.getByRole('button', { name: 'Save durable blob text' })); + await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1)); + const requestBody = JSON.parse(fetch.mock.calls[0][1].body); + expect(requestBody).toEqual(expect.objectContaining({ + title: 'blob:notes', latex_content: 'blob:legitimate LaTeX text', + selected_formulas: [{ name: 'blob:durable', nested: { text: 'blob:keep' } }], + })); + expect(containsTransientBlob(requestBody)).toBe(false); + await waitFor(() => expect(storedSheet().compileHistory).toHaveLength(1)); + const storedHistory = JSON.parse(localStorage.getItem('cheatSheetCompileHistory:7')); + expect(storedSheet()).toEqual(expect.objectContaining({ + title: 'blob:notes', content: 'blob:legitimate LaTeX text', provenance: 'blob:durable provenance', + selectedFormulas: [{ name: 'blob:durable', nested: { text: 'blob:keep' } }], + })); + expect(storedHistory[0]).toEqual(expect.objectContaining({ + content: 'blob:legitimate LaTeX text', nested: { note: 'blob:durable nested text' }, + })); + expect(storedHistory[0].selectedFormulas).toEqual([{ name: 'blob:durable', nested: { text: 'blob:keep' } }]); + expect(containsTransientBlob(storedSheet())).toBe(false); + expect(containsTransientBlob(storedHistory)).toBe(false); + }); + + it('sanitizes Dashboard server formulas before storing the editor sheet', async () => { + renderApp(); + + fireEvent.click(screen.getByRole('link', { name: 'Open test dashboard' })); + await screen.findByRole('button', { name: 'Edit sheet' }); + fireEvent.click(screen.getByRole('button', { name: 'Edit sheet' })); + + await waitFor(() => expect(storedSheet().id).toBe(77)); + expect(storedSheet().selectedFormulas).toEqual([{ name: 'blob:durable', nested: { text: 'blob:keep' } }]); + expect(containsTransientBlob(storedSheet())).toBe(false); + }); + + it('merges canonical server formulas only when the submitted selection remains current', async () => { + const save = deferred(); + const canonicalFormula = { class: 'Math', category: 'Canonical', name: 'B', nested: { text: 'blob:server', pdfBlob: 'blob:drop' }, pdfBlob: 'blob:transient' }; + localStorage.setItem('currentCheatSheet', JSON.stringify({ + draftId: 'draft-7', title: 'existing', content: '', contentSource: 'empty', columns: 4, + fontSize: '9pt', spacing: 'small', margins: '0.15in', orientation: 'portrait', selectedFormulas: [], compileHistory: [], + })); + vi.stubGlobal('fetch', vi.fn(() => save.promise)); + renderApp(); + + fireEvent.click(screen.getByRole('button', { name: 'Save formula A' })); + await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1)); + expect(JSON.parse(fetch.mock.calls[0][1].body).selected_formulas).toEqual([{ class: 'Math', category: 'Algebra', name: 'A', nested: { text: 'blob:keep' } }]); + await act(async () => save.resolve(response({ id: 7, selected_formulas: [canonicalFormula] }))); + + await waitFor(() => expect(storedSheet().selectedFormulas).toEqual([{ class: 'Math', category: 'Canonical', name: 'B', nested: { text: 'blob:server' } }])); + expect(storedSheet().id).toBe(7); + expect(containsTransientBlob(storedSheet())).toBe(false); + }); + + it('retains an intervening local formula selection when a save response arrives', async () => { + const save = deferred(); + localStorage.setItem('currentCheatSheet', JSON.stringify({ + id: 7, draftId: 'sheet-7', title: 'existing', content: '', contentSource: 'empty', columns: 4, + fontSize: '9pt', spacing: 'small', margins: '0.15in', orientation: 'portrait', selectedFormulas: [], compileHistory: [], + })); + vi.stubGlobal('fetch', vi.fn(() => save.promise)); + renderApp(); + + fireEvent.click(screen.getByRole('button', { name: 'Save formula A' })); + await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1)); + fireEvent.click(screen.getByRole('button', { name: 'Save formula C locally' })); + await act(async () => save.resolve(response({ id: 7, selected_formulas: null }))); + + await waitFor(() => expect(storedSheet().selectedFormulas).toEqual([{ class: 'Math', category: 'Algebra', name: 'C', nested: { text: 'blob:local' } }])); + expect(storedSheet().id).toBe(7); + expect(containsTransientBlob(storedSheet())).toBe(false); + }); + + it('retains a local formula C when canonical server formula B resolves an earlier save of A', async () => { + const save = deferred(); + const canonicalFormula = { + class: 'Math', category: 'Canonical', name: 'B', durable: 'blob:server value', + nested: { text: 'blob:server nested', pdfBlob: 'blob:drop' }, pdfBlob: 'blob:transient', + }; + vi.stubGlobal('fetch', vi.fn(() => save.promise)); + renderApp(); + + fireEvent.click(screen.getByRole('button', { name: 'Save formula A' })); + await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1)); + expect(JSON.parse(fetch.mock.calls[0][1].body).selected_formulas).toEqual([ + { class: 'Math', category: 'Algebra', name: 'A', nested: { text: 'blob:keep' } }, + ]); + + fireEvent.click(screen.getByRole('button', { name: 'Save formula C locally' })); + await act(async () => save.resolve(response({ id: 88, selected_formulas: [canonicalFormula] }))); + + const formulaC = [{ class: 'Math', category: 'Algebra', name: 'C', nested: { text: 'blob:local' } }]; + await waitFor(() => expect(storedSheet()).toEqual(expect.objectContaining({ id: 88, selectedFormulas: formulaC }))); + expect(JSON.parse(screen.getByTestId('sheet-state').textContent)).toEqual(expect.objectContaining({ id: 88, selectedFormulas: formulaC })); + expect(containsTransientBlob(storedSheet())).toBe(false); + expect(storedSheet().selectedFormulas[0].nested.text).toBe('blob:local'); + }); +}); + +describe('App recovery and remote persistence integration', () => { + beforeEach(() => { + localStorage.clear(); + window.history.replaceState({}, '', '/'); + vi.stubGlobal('alert', vi.fn()); + vi.stubGlobal('ResizeObserver', class { + observe() {} + disconnect() {} + }); + vi.stubGlobal('requestAnimationFrame', (callback) => setTimeout(callback, 0)); + vi.stubGlobal('cancelAnimationFrame', clearTimeout); + vi.stubGlobal('URL', Object.assign(class extends globalThis.URL {}, { + createObjectURL: vi.fn(() => 'blob:app-test'), + revokeObjectURL: vi.fn(), + })); + }); + + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + localStorage.clear(); + }); + + it('saves a matching recovered draft through the API and reloads its mapped server state', async () => { + const recoveredFormula = { class: 'Physics 101', category: 'Forces', name: 'Newton Second Law' }; + const savedResponse = { + id: 42, + title: 'Recovered mechanics', + latex_content: '\\documentclass{article}\nRecovered body', + content_source: 'manual', + columns: 2, + margins: '0.25in', + font_size: '10pt', + spacing: 'medium', + orientation: 'landscape', + selected_formulas: [recoveredFormula], + }; + localStorage.setItem('currentCheatSheet', JSON.stringify({ + id: 42, + draftId: 'sheet-42', + title: 'Stale server title', + content: 'stale server content', + contentSource: 'generated', + columns: 4, + fontSize: '9pt', + spacing: 'small', + margins: '0.15in', + orientation: 'portrait', + selectedFormulas: [{ class: 'Physics 101', category: 'Motion', name: 'Velocity' }], + compileHistory: [], + })); + localStorage.setItem('cheatSheetLatex:sheet-42', JSON.stringify({ + title: savedResponse.title, + content: savedResponse.latex_content, + contentSource: savedResponse.content_source, + columns: savedResponse.columns, + fontSize: savedResponse.font_size, + spacing: savedResponse.spacing, + margins: savedResponse.margins, + orientation: savedResponse.orientation, + })); + localStorage.setItem('cheatSheetData:sheet-42', JSON.stringify({ + selectedClasses: { 'Physics 101': true }, + selectedCategories: { 'Physics 101:Forces': true }, + groupedFormulas: [{ class: 'Physics 101', formulas: [recoveredFormula] }], + })); + + vi.stubGlobal('fetch', vi.fn((url, _options = {}) => { + if (url === '/api/classes/') { + return Promise.resolve({ ok: true, json: async () => ({ classes: [{ name: 'Physics 101', categories: [ + { name: 'Motion', formulas: [{ name: 'Velocity' }] }, + { name: 'Forces', formulas: [{ name: 'Newton Second Law' }] }, + ] }] }) }); + } + if (url === '/api/cheatsheets/42/') return Promise.resolve(response(savedResponse)); + throw new Error(`Unexpected request: ${url}`); + })); + + vi.doUnmock('./components/CreateCheatSheet'); + vi.resetModules(); + const [{ default: RealApp }, { default: RealAuthContext }] = await Promise.all([ + import('./App'), + import('./context/AuthContext'), + ]); + const renderRealApp = () => render( + + + + + , + ); + + const firstRender = renderRealApp(); + await screen.findByLabelText('Physics 101'); + expect(screen.getByLabelText(/Motion \(1 formulas\)/i)).not.toBeChecked(); + expect(screen.getByLabelText(/Forces \(1 formulas\)/i)).toBeChecked(); + expect(screen.getByLabelText(/Orientation:/i)).toHaveValue('landscape'); + expect(screen.getByLabelText(/Spacing:/i)).toHaveValue('medium'); + + fireEvent.click(screen.getByTitle('Save (Ctrl + S)')); + await waitFor(() => expect(fetch).toHaveBeenCalledWith('/api/cheatsheets/42/', expect.objectContaining({ + method: 'PATCH', + body: JSON.stringify({ + title: savedResponse.title, + latex_content: savedResponse.latex_content, + content_source: 'manual', + columns: 2, + margins: '0.25in', + font_size: '10pt', + spacing: 'medium', + orientation: 'landscape', + selected_formulas: [recoveredFormula], + }), + }))); + await waitFor(() => expect(storedSheet()).toEqual(expect.objectContaining({ + id: 42, + title: savedResponse.title, + content: savedResponse.latex_content, + contentSource: 'manual', + columns: 2, + fontSize: '10pt', + spacing: 'medium', + margins: '0.25in', + orientation: 'landscape', + selectedFormulas: [recoveredFormula], + }))); + + firstRender.unmount(); + localStorage.removeItem('cheatSheetLatex:sheet-42'); + localStorage.removeItem('cheatSheetData:sheet-42'); + renderRealApp(); + await screen.findByLabelText('Physics 101'); + expect(screen.getByLabelText(/Forces \(1 formulas\)/i)).toBeChecked(); + expect(screen.getByLabelText(/Orientation:/i)).toHaveValue('landscape'); + expect(screen.getByLabelText(/Spacing:/i)).toHaveValue('medium'); + }); + + it('aborts an in-flight save on unmount and ignores its later response', async () => { + const save = deferred(); + const abort = vi.fn(); + vi.stubGlobal('fetch', vi.fn((_url, options) => { + options.signal.addEventListener('abort', abort); + return save.promise; + })); + const rendered = renderApp(); + + fireEvent.click(screen.getByRole('button', { name: 'Save first' })); + await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1)); + const beforeUnmount = storedSheet(); + rendered.unmount(); + expect(abort).toHaveBeenCalledTimes(1); + + await act(async () => save.resolve(response({ id: 99, title: 'stale title', latex_content: 'stale content' }))); + expect(storedSheet()).toEqual(beforeUnmount); + expect(alert).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/Phase1Journey.test.jsx b/frontend/src/Phase1Journey.test.jsx new file mode 100644 index 0000000..4cc8e2a --- /dev/null +++ b/frontend/src/Phase1Journey.test.jsx @@ -0,0 +1,189 @@ +import React from 'react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import CreateCheatSheet from './components/CreateCheatSheet'; +import AuthContext from './context/AuthContext'; + +vi.mock('react-pdf', () => ({ + Document: ({ children, onLoadSuccess }) => { + React.useEffect(() => onLoadSuccess({ numPages: 1 }), [onLoadSuccess]); + return
{children}
; + }, + Page: () =>
, + pdfjs: { GlobalWorkerOptions: { workerSrc: '' } }, +})); + +const classes = [{ + name: 'Physics 101', + categories: [ + { name: 'Motion', formulas: [{ name: 'Velocity' }] }, + { name: 'Forces', formulas: [{ name: 'Newton Second Law' }] }, + ], +}]; + +const template = { + title: 'Physics template', + content: '\\documentclass{article}\nTemplate body', + contentSource: 'generated', + columns: 2, + fontSize: '10pt', + spacing: 'small', + margins: '0.25in', + orientation: 'portrait', + selectedFormulas: [ + { class: 'Physics 101', category: 'Motion', name: 'Velocity' }, + { class: 'Physics 101', category: 'Forces', name: 'Newton Second Law' }, + ], + compileHistory: [], +}; +const deferred = () => { + let resolve; + const promise = new Promise((done) => { resolve = done; }); + return { promise, resolve }; +}; + +const renderEditor = (props) => render( + + + , +); + +describe('phase 1 component persistence journey with mocked save callback', () => { + beforeEach(() => { + localStorage.clear(); + global.fetch = vi.fn((url, options = {}) => { + if (url === '/api/classes/') { + return Promise.resolve({ ok: true, json: async () => ({ classes }) }); + } + + if (url === '/api/compile/') { + const body = JSON.parse(options.body); + if (body.normalize_only) { + return Promise.resolve({ ok: true, json: async () => ({ tex_code: `${body.content}\n% normalized` }) }); + } + return Promise.resolve({ ok: true, blob: async () => new Blob(['pdf'], { type: 'application/pdf' }) }); + } + + throw new Error(`Unexpected request: ${url}`); + }); + vi.stubGlobal('ResizeObserver', class { + observe() {} + disconnect() {} + }); + vi.stubGlobal('requestAnimationFrame', (callback) => setTimeout(callback, 0)); + vi.stubGlobal('cancelAnimationFrame', clearTimeout); + vi.stubGlobal('URL', { createObjectURL: vi.fn(() => 'blob:phase-1'), revokeObjectURL: vi.fn() }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + localStorage.clear(); + }); + + it('retains a normalized manual edit when remounted with supplied persisted data', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + const firstDraftIdentity = 'template-draft'; + const { unmount } = renderEditor({ initialData: template, draftIdentity: firstDraftIdentity, onSave }); + + await screen.findByLabelText('Physics 101'); + fireEvent.click(screen.getByRole('button', { name: /Show LaTeX editor/i })); + fireEvent.change(screen.getByLabelText(/Generated LaTeX Code:/i), { + target: { value: '\\documentclass{article}\nManual body' }, + }); + fireEvent.change(screen.getByLabelText(/Orientation:/i), { target: { value: 'landscape' } }); + fireEvent.change(screen.getByLabelText(/Spacing:/i), { target: { value: 'medium' } }); + fireEvent.click(screen.getByLabelText(/Motion \(1 formulas\)/i)); + + fireEvent.click(screen.getByRole('button', { name: /Compile PDF/i })); + + await waitFor(() => expect(global.fetch).toHaveBeenCalledWith( + '/api/compile/', + expect.objectContaining({ body: JSON.stringify({ + content: '\\documentclass{article}\nManual body', + columns: 2, + font_size: '10pt', + spacing: 'medium', + margins: '0.25in', + orientation: 'landscape', + normalize_only: true, + }) }), + )); + await waitFor(() => expect(global.fetch).toHaveBeenCalledWith( + '/api/compile/', + expect.objectContaining({ body: JSON.stringify({ + content: '\\documentclass{article}\nManual body\n% normalized', + columns: 2, + font_size: '10pt', + spacing: 'medium', + margins: '0.25in', + orientation: 'landscape', + }) }), + )); + expect(global.fetch).not.toHaveBeenCalledWith('/api/generate-sheet/', expect.anything()); + expect(await screen.findByTestId('pdf-document')).toBeInTheDocument(); + + await waitFor(() => expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ compileSnapshot: expect.any(Object) }), false)); + const savedPayload = onSave.mock.calls.find(([, showFeedback]) => showFeedback === false)[0]; + expect(savedPayload).toMatchObject({ + content: expect.stringContaining('Manual body'), + contentSource: 'manual', + spacing: 'medium', + orientation: 'landscape', + selectedFormulas: [{ class: 'Physics 101', category: 'Forces', name: 'Newton Second Law' }], + }); + expect(savedPayload.compileSnapshot).toMatchObject({ contentSource: 'manual', spacing: 'medium', orientation: 'landscape' }); + + await waitFor(() => expect(localStorage.getItem(`cheatSheetLatex:${firstDraftIdentity}`)).not.toBeNull()); + expect(localStorage.getItem(`cheatSheetData:${firstDraftIdentity}`)).not.toBeNull(); + localStorage.removeItem(`cheatSheetLatex:${firstDraftIdentity}`); + localStorage.removeItem(`cheatSheetData:${firstDraftIdentity}`); + expect(localStorage.getItem(`cheatSheetLatex:${firstDraftIdentity}`)).toBeNull(); + expect(localStorage.getItem(`cheatSheetData:${firstDraftIdentity}`)).toBeNull(); + unmount(); + + const savedServerSheet = { + ...savedPayload, + id: 42, + draftId: 'server-sheet-42', + compileHistory: [savedPayload.compileSnapshot], + }; + renderEditor({ initialData: savedServerSheet, draftIdentity: 'server-sheet-42', onSave: vi.fn().mockResolvedValue(undefined) }); + + await screen.findByLabelText('Physics 101'); + expect(screen.getByLabelText(/Orientation:/i)).toHaveValue('landscape'); + expect(screen.getByLabelText(/Spacing:/i)).toHaveValue('medium'); + expect(screen.getByLabelText(/Motion \(1 formulas\)/i)).not.toBeChecked(); + expect(screen.getByLabelText(/Forces \(1 formulas\)/i)).toBeChecked(); + expect(screen.getByRole('button', { name: /Snapshots \(1\)/i })).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /Show LaTeX editor/i })); + expect(screen.getByLabelText(/Generated LaTeX Code:/i)).toHaveValue('\\documentclass{article}\nManual body\n% normalized'); + }); + + it('waits for hydrated formulas before restoring exactly one preview and autosaving it', async () => { + const classesRequest = deferred(); + const onSave = vi.fn().mockResolvedValue(undefined); + const restored = { + ...template, + content: '\\documentclass{article}\nRestored body', + compileHistory: [{ content: 'previous compile' }], + selectedFormulas: [{ class: 'Physics 101', category: 'Motion', name: 'Velocity' }], + }; + global.fetch = vi.fn((url) => { + if (url === '/api/classes/') return classesRequest.promise; + if (url === '/api/compile/') return Promise.resolve({ ok: true, blob: async () => new Blob(['pdf'], { type: 'application/pdf' }) }); + throw new Error(`Unexpected request: ${url}`); + }); + + renderEditor({ initialData: restored, draftIdentity: 'restored-sheet', onSave }); + expect(global.fetch).toHaveBeenCalledTimes(1); + await act(async () => classesRequest.resolve({ ok: true, json: async () => ({ classes }) })); + + await waitFor(() => expect(global.fetch).toHaveBeenCalledWith('/api/compile/', expect.anything())); + expect(global.fetch.mock.calls.filter(([url]) => url === '/api/compile/')).toHaveLength(1); + await waitFor(() => expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ + selectedFormulas: restored.selectedFormulas, + }), false)); + expect(onSave.mock.calls.filter(([, showFeedback]) => showFeedback === false)).toHaveLength(1); + }); +}); diff --git a/frontend/src/components/CreateCheatSheet.jsx b/frontend/src/components/CreateCheatSheet.jsx index a235011..a1ac818 100644 --- a/frontend/src/components/CreateCheatSheet.jsx +++ b/frontend/src/components/CreateCheatSheet.jsx @@ -714,7 +714,7 @@ const LatexEditor = ({ content, onChange, isModified, compileError }) => { value={content} onChange={(e) => onChange(e.target.value)} onScroll={handleScroll} - placeholder='Select classes and categories above, then click "GET CHEAT SHEET" to see the LaTeX code here.' + placeholder='Select classes and categories above, then generate a cheat sheet to see the LaTeX code here.' className={`textarea-field ${isModified ? 'modified' : ''}`} rows={15} spellCheck="false" @@ -904,14 +904,15 @@ const PdfPreview = ({ pdfBlob, compileError, isCompiling, layoutSignature }) =>
- {compileError ? ( -
- Compilation Error: + {compileError && ( +
+ Latest compile failed. Showing the last successful PDF.

{compileError}
- ) : pdfBlob ? ( + )} + {pdfBlob ? ( <> { +const CreateCheatSheet = ({ onSave, onReset, onRestoreSnapshot, initialData, draftIdentity, isSaving = false }) => { const { classesData, selectedClasses, @@ -1126,8 +1127,9 @@ const CreateCheatSheet = ({ onSave, onReset, onRestoreSnapshot, initialData, isS removeClassFromOrder, removeSingleFormula, selectedCount, - hasSelectedClasses - } = useFormulas(initialData); + hasSelectedClasses, + isFormulaSelectionInitialized, + } = useFormulas(initialData, draftIdentity); const { title, @@ -1135,7 +1137,6 @@ const CreateCheatSheet = ({ onSave, onReset, onRestoreSnapshot, initialData, isS content, contentModified, contentSource, - canRegenerateFromSelections, hasLayoutChanges, handleContentChange, columns, @@ -1149,17 +1150,20 @@ const CreateCheatSheet = ({ onSave, onReset, onRestoreSnapshot, initialData, isS orientation, setOrientation, pdfBlob, + isGenerating, isCompiling, compileError, + lastCompileSnapshot, goBack, goForward, - handlePreview, handleCompileOnly, + handlePreview, + handleGenerateSheet, handleDownloadPDF, handleDownloadTex, handlePrintPDF, clearLatex - } = useLatex(initialData); + } = useLatex(initialData, draftIdentity, getSelectedFormulasList() || []); const [showLatex, setShowLatex] = useState(false); const [showSnapshots, setShowSnapshots] = useState(false); @@ -1174,15 +1178,23 @@ const CreateCheatSheet = ({ onSave, onReset, onRestoreSnapshot, initialData, isS const [classesCollapseSignal, setClassesCollapseSignal] = useState(0); const pendingPanelLayoutRef = useRef(panelLayout); const hasCollapsedLeftPanelOnceRef = useRef(false); - const hasGeneratedFromSelectionsRef = useRef(false); - const lastGeneratedSelectionSignatureRef = useRef(''); const lastAutoSavedPdfRef = useRef(null); + const hasRestoredPreviewRef = useRef(false); + const shouldRestorePreviewRef = useRef(Boolean(initialData?.content?.trim() && initialData?.compileHistory?.length)); const lastVideoOpenerRef = useRef(null); const modalDialogRef = useRef(null); const appBodyRef = useRef(null); const centerPanelRef = useRef(null); const compileBtnRef = useRef(null); const snapshots = useMemo(() => [...(initialData?.compileHistory || [])].reverse(), [initialData?.compileHistory]); + + useEffect(() => { + if (!shouldRestorePreviewRef.current || hasRestoredPreviewRef.current) return; + if (!isFormulaSelectionInitialized || !content?.trim()) return; + + hasRestoredPreviewRef.current = true; + handlePreview(content); + }, [content, handlePreview, isFormulaSelectionInitialized]); const selectedClassNames = useMemo( () => classesData.filter((cls) => selectedClasses[cls.name]).map((cls) => cls.name), [classesData, selectedClasses], @@ -1267,19 +1279,6 @@ const CreateCheatSheet = ({ onSave, onReset, onRestoreSnapshot, initialData, isS return ''; }; - useEffect(() => { - if (!initialData) return; - if (initialData.title) setTitle(initialData.title); - if (initialData.content) { - handleContentChange(initialData.content); - } - if (initialData.columns) setColumns(initialData.columns); - if (initialData.fontSize) setFontSize(initialData.fontSize); - if (initialData.spacing) setSpacing(initialData.spacing); - if (initialData.margins) setMargins(initialData.margins); - if (initialData.orientation) setOrientation(initialData.orientation); - }, [handleContentChange, initialData, setColumns, setFontSize, setMargins, setOrientation, setSpacing, setTitle]); - useEffect(() => { const hasCompiledBefore = Boolean(initialData?.compileHistory?.length || pdfBlob || content.trim()); if (hasCompiledBefore) return; @@ -1385,35 +1384,16 @@ const CreateCheatSheet = ({ onSave, onReset, onRestoreSnapshot, initialData, isS }, [leftPanelVisible, rightPanelVisible, showLatex]); useEffect(() => { - if (!pdfBlob || compileError || lastAutoSavedPdfRef.current === pdfBlob) { + if (!lastCompileSnapshot || compileError || lastAutoSavedPdfRef.current === lastCompileSnapshot.pdfBlob) { return; } - lastAutoSavedPdfRef.current = pdfBlob; + lastAutoSavedPdfRef.current = lastCompileSnapshot.pdfBlob; setSaveStatus('saving'); setLastSavedAt(Date.now()); onSave({ - title, - content, - contentSource, - columns, - fontSize, - spacing, - margins, - orientation, - selectedFormulas: getSelectedFormulasList(), - compileSnapshot: { - title, - content, - contentSource, - columns, - fontSize, - spacing, - margins, - orientation, - selectedFormulas: getSelectedFormulasList(), - compiledAt: new Date().toISOString(), - }, + ...lastCompileSnapshot, + compileSnapshot: lastCompileSnapshot, }, false) .then(() => { setSaveStatus('saved'); @@ -1422,7 +1402,7 @@ const CreateCheatSheet = ({ onSave, onReset, onRestoreSnapshot, initialData, isS console.error('Failed to autosave compiled sheet', error); setSaveStatus('offline'); }); - }, [columns, compileError, content, contentSource, fontSize, getSelectedFormulasList, margins, onSave, orientation, pdfBlob, spacing, title]); + }, [compileError, lastCompileSnapshot, onSave]); const startResize = useCallback((panel) => (event) => { event.preventDefault(); @@ -1515,13 +1495,7 @@ const CreateCheatSheet = ({ onSave, onReset, onRestoreSnapshot, initialData, isS return () => clearTimeout(timer); }, [pdfBlob, isCompiling]); - useEffect(() => { - if (contentSource === 'generated') { - hasGeneratedFromSelectionsRef.current = true; - } - }, [contentSource]); - - const handleCompileClick = useCallback(() => { + const prepareFirstActionLayout = useCallback(() => { if (!hasCollapsedLeftPanelOnceRef.current) { // First compile: keep controls reachable while reclaiming preview space. hasCollapsedLeftPanelOnceRef.current = true; @@ -1537,29 +1511,19 @@ const CreateCheatSheet = ({ onSave, onReset, onRestoreSnapshot, initialData, isS return nextLayout; }); } + }, []); + const handleGenerateClick = useCallback(() => { + prepareFirstActionLayout(); const selectedFormulas = getSelectedFormulasList(); - const selectionSignature = selectedFormulas - .map((formula) => `${formula.class}|${formula.category}|${formula.name}`) - .join('||'); - const hasSelectionChangedSinceGenerate = - lastGeneratedSelectionSignatureRef.current && - lastGeneratedSelectionSignatureRef.current !== selectionSignature; - const isPreviouslyGeneratedAndUnmodified = hasGeneratedFromSelectionsRef.current && !contentModified; - const shouldRegenerateFromSelections = selectedFormulas.length > 0 && ( - canRegenerateFromSelections || - isPreviouslyGeneratedAndUnmodified || - (!contentModified && hasSelectionChangedSinceGenerate) - ); - - if (shouldRegenerateFromSelections) { - lastGeneratedSelectionSignatureRef.current = selectionSignature; - handlePreview(null, { formulas: selectedFormulas, columns, fontSize, spacing }); - return; - } + handleGenerateSheet(selectedFormulas); + }, [getSelectedFormulasList, handleGenerateSheet, prepareFirstActionLayout]); + const handleCompileClick = useCallback(() => { + prepareFirstActionLayout(); + const selectedFormulas = getSelectedFormulasList(); handleCompileOnly(selectedFormulas); - }, [canRegenerateFromSelections, columns, contentModified, fontSize, getSelectedFormulasList, handleCompileOnly, handlePreview, spacing]); + }, [getSelectedFormulasList, handleCompileOnly, prepareFirstActionLayout]); const handleSave = useCallback(async (e) => { e?.preventDefault?.(); @@ -1689,25 +1653,34 @@ const CreateCheatSheet = ({ onSave, onReset, onRestoreSnapshot, initialData, isS {/* Footer buttons */}
+ +

+ Generate replaces editor source. Compile keeps it. +