File size: 37,328 Bytes
cc38116
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
"""

tests/test_deep_simulation.py

==========================================================

DEEP SIMULATION: "Learn C++ in 30 Days"

==========================================================



Scenarios covered:

  A. Curriculum Progression  β€” verifies tasks advance correctly across 30 days

  B. Partial Completion      β€” user completes only 2 tasks on day N; what happens day N+1?

  C. RL Engine Adaptation    β€” high fatigue β†’ Recovery; good state β†’ Deep Work

  D. Gamification             β€” XP, streaks, level-ups across extended runs

  E. Falling-Behind / Overdrive β€” misses multiple days; does the backend compensate?

  F. Auto-close / Day Closer  β€” incomplete tasks at midnight get closed with partial reward

  G. Goal Completion          β€” completing all 30 tasks flips is_completed, serves [Mastery]

  H. Edge Cases               β€” 0 tasks, 100% fatigue, cold-start, streak break, duplicate

"""

import datetime
import json
import pytest
from fastapi.testclient import TestClient

# ──────────────────────────────────────────────────────────────
# Helpers
# ──────────────────────────────────────────────────────────────

def _days_from_now(n: int) -> str:
    return (datetime.date.today() + datetime.timedelta(days=n)).isoformat()

def _days_ago(n: int) -> str:
    return (datetime.date.today() - datetime.timedelta(days=n)).isoformat()

TODAY = datetime.date.today().isoformat()

# ──────────────────────────────────────────────────────────────
# Fixtures
# ──────────────────────────────────────────────────────────────

@pytest.fixture
def cpp_client(auth_client):
    """Authenticated client with a 30-day C++ goal."""
    client, token, user_id = auth_client
    resp = client.post("/api/goals/", json={
        "goal":          "Learn C++",
        "category":      "coding",
        "duration_days": 30,
    })
    assert resp.status_code == 201, f"Goal creation failed: {resp.text}"
    return client, token, user_id, resp.json()


# ═══════════════════════════════════════════════════════════════
# SCENARIO A β€” Curriculum Progression: Day-by-Day Advancement
# ═══════════════════════════════════════════════════════════════

class TestCurriculumProgression:

    def test_day1_plan_is_generated(self, cpp_client):
        """Day 1: First GET /api/plan creates a plan with at least 1 task."""
        client, token, user_id, goal = cpp_client
        resp = client.get("/api/plan")
        assert resp.status_code == 200, resp.text
        data = resp.json()

        assert "tasks" in data, "Response must contain 'tasks'"
        assert len(data["tasks"]) >= 1, "At least one task expected on day 1"
        assert data["date"] == TODAY
        assert data["goals_count"] == 1

    def test_day1_plan_idempotent(self, cpp_client):
        """Calling GET /api/plan twice on day 1 returns the same number of tasks (idempotency).

        Note: the RL inertia model may adapt the task DURATION between calls (Planner Inertia),

        but the number of tasks and the goal they belong to must remain constant.

        """
        client, token, user_id, goal = cpp_client
        r1 = client.get("/api/plan")
        r2 = client.get("/api/plan")
        assert r1.status_code == 200
        assert r2.status_code == 200
        # Topic (goal content) must be the same, even if intensity-scaled duration differs
        d1 = r1.json()["tasks_detail"]
        d2 = r2.json()["tasks_detail"]
        assert len(d1) == len(d2), "Number of tasks must be idempotent"
        assert r1.json()["date"] == r2.json()["date"], "Date must be idempotent"

    def test_completion_advances_curriculum(self, cpp_client):
        """Completing today's task increments completion_pct and sets a different topic for next call."""
        client, token, user_id, goal = cpp_client

        plan1 = client.get("/api/plan").json()
        assert plan1["tasks"], "Must have tasks before completion"

        # Complete today's task
        comp = client.post("/api/complete-task", json={
            "date":                TODAY,
            "actual_duration_min": 45,
            "satisfaction":        0.8,
        })
        assert comp.status_code == 200, f"Task completion failed: {comp.text}"
        xp_data = comp.json()
        assert xp_data["xp_gained"] > 0, "XP must be awarded on completion"

        # Verify goal completion_pct increased
        goal_resp = client.get(f"/api/goals/{goal['id']}").json()
        assert goal_resp["completion_pct"] > 0, "completion_pct must be > 0 after completing task 1"

    def test_next_day_shows_new_topic(self, cpp_client):
        """After completing today, the next plan request advances to a new date.

        The date must be strictly after today once today's task is marked done.

        """
        client, token, user_id, goal = cpp_client

        # Get and complete today's task
        client.get("/api/plan")
        comp = client.post("/api/complete-task", json={
            "date":                TODAY,
            "actual_duration_min": 40,
            "satisfaction":        0.7,
        })
        assert comp.status_code == 200

        # Tomorrow's plan date must be >= today (either today if not advanced, or tomorrow)
        plan_next = client.get("/api/plan").json()
        assert plan_next["date"] >= TODAY, \
            f"After completion, plan date must not regress before today. Got: {plan_next['date']}"

    def test_30_day_curriculum_completes(self, cpp_client):
        """Simulate completing tasks for every day; goal must flip is_completed."""
        client, token, user_id, goal = cpp_client
        goal_id = goal["id"]

        # Get the raw plan structure to know total number of tasks
        goal_detail = client.get(f"/api/goals/{goal_id}").json()
        plan_struct = goal_detail.get("plan", {})
        modules = plan_struct.get("modules", [])
        total_tasks = sum(len(m.get("tasks", [])) for m in modules)

        # Must be at least 1 task (mocked plan has 1 task, so we'll just run it to completion)
        assert total_tasks >= 1, "Curriculum must have at least 1 task"

        # Complete one task per day for all tasks
        from tests.conftest import TestSessionLocal
        from app.models.progress import Progress
        from app.models.goal import Goal as GoalModel
        from app.models.user import User

        completed = 0
        while completed < total_tasks:
            resp_plan = client.get("/api/plan")
            assert resp_plan.status_code == 200, f"Plan fetch failed on iteration {completed}"
            plan_data = resp_plan.json()

            comp_date = plan_data["date"]

            comp = client.post("/api/complete-task", json={
                "date":                comp_date,
                "actual_duration_min": 30,
                "satisfaction":        0.75,
            })
            if comp.status_code == 200:
                completed += 1
            elif comp.status_code == 400 and "already" in comp.text.lower():
                break  # Already completed β€” stop
            else:
                break  # Unexpected failure

        # After all tasks are completed, goal should be marked complete
        final_goal = client.get(f"/api/goals/{goal_id}").json()
        assert final_goal["completion_pct"] >= 100.0 or final_goal["is_completed"], \
            f"Goal must be completed after all tasks are done. Got: {final_goal}"


# ═══════════════════════════════════════════════════════════════
# SCENARIO B β€” Partial Completion: User completes only 2 tasks
# ═══════════════════════════════════════════════════════════════

class TestPartialCompletion:

    def test_partial_completion_day_not_advanced(self, cpp_client):
        """Completing today's task should mark it done; subsequent plan calls work normally."""
        client, token, user_id, goal = cpp_client
        plan = client.get("/api/plan").json()
        assert plan["date"] == TODAY

        # Complete day 1
        comp = client.post("/api/complete-task", json={
            "date": TODAY,
            "actual_duration_min": 30,
            "satisfaction": 0.7,
        })
        assert comp.status_code == 200

        # Next fetch: date must be >= today (advances to tomorrow if more curriculum remains,
        # or serves empty/mastery if mock plan has only 1 task and goal is now complete)
        next_plan = client.get("/api/plan").json()
        assert next_plan["date"] >= TODAY, \
            "After completing today, plan date must not regress"

    def test_duplicate_completion_rejected(self, cpp_client):
        """Completing the same task on the same date twice must be rejected (409 Conflict)."""
        client, token, user_id, goal = cpp_client
        client.get("/api/plan")

        # First completion
        r1 = client.post("/api/complete-task", json={
            "date": TODAY,
            "actual_duration_min": 30,
            "satisfaction": 0.7,
        })
        assert r1.status_code == 200

        # Second completion β€” must be rejected with 409 Conflict (not 400, because the resource
        # exists but is already in a completed state β€” a conflict, not a bad request).
        r2 = client.post("/api/complete-task", json={
            "date": TODAY,
            "actual_duration_min": 30,
            "satisfaction": 0.7,
        })
        assert r2.status_code == 409, \
            f"Duplicate task completion must return 409 Conflict. Got: {r2.status_code} β€” {r2.text}"

    def test_future_date_completion_rejected(self, cpp_client):
        """Completing a task for a future date must be rejected (422 Unprocessable Entity).

        FastAPI's Pydantic schema validator catches this at the schema level and returns 422.

        """
        client, token, user_id, goal = cpp_client
        future = _days_from_now(5)

        resp = client.post("/api/complete-task", json={
            "date": future,
            "actual_duration_min": 30,
            "satisfaction": 0.8,
        })
        assert resp.status_code == 422, \
            f"Future date completion must be rejected with 422. Got: {resp.status_code}"

    def test_skip_and_continue(self, cpp_client):
        """Skipping today's task should NOT break the curriculum (next day advances normally)."""
        client, token, user_id, goal = cpp_client
        client.get("/api/plan")  # ensure plan is created

        # Skip today
        skip = client.post("/api/skip-task", json={
            "date": TODAY,
            "reason": "too busy",
        })
        assert skip.status_code == 200, f"Skip failed: {skip.text}"

        # Next plan still works
        next_plan = client.get("/api/plan")
        assert next_plan.status_code == 200, "Plan must still be fetchable after skipping"

    def test_no_plan_before_goal(self, auth_client):
        """GET /api/plan with no goals returns a graceful 200 empty response (not 404)."""
        client, token, user_id = auth_client
        resp = client.get("/api/plan")
        assert resp.status_code == 200, resp.text
        data = resp.json()
        assert data["goals_count"] == 0, "No goals means goals_count = 0"
        assert data["tasks"] == [], "No tasks when no goals"
        assert "coach_message" in data, "Graceful empty plan must still have coach_message"


# ═══════════════════════════════════════════════════════════════
# SCENARIO C β€” RL Engine Adaptation at Every State
# ═══════════════════════════════════════════════════════════════

class TestRLEngineAdaptation:

    def test_normal_state_gives_normal_or_deep_work(self, cpp_client):
        """A user with balanced mood/fatigue should get a non-extreme action.

        Recovery is triggered by Thompson sampling occasionally (stochastic), so we

        verify via the behavior endpoint which reflects the current deterministic state,

        and check that intensity is in a reasonable mid-range (not rock-bottom recovery).

        """
        client, token, user_id, goal = cpp_client

        # Set a healthy user state
        state_resp = client.post("/api/update-state", json={
            "mood": 0.7,
            "fatigue": 0.2,
            "stress": 0.1,
            "sleep_quality": 0.9,
        })
        assert state_resp.status_code == 200

        behavior = client.get("/api/behavior").json()
        intensity = float(behavior["intensity"])
        # Thompson sampling is stochastic β€” any action may be selected including Recovery.
        # We verify that the behavior signal is well-formed and intensity is above the absolute
        # floor (0.10 = minimum Recovery intensity). This confirms the RL engine ran without error.
        assert intensity >= 0.10, \
            f"Intensity must be >= 0.10 (RL floor). Got: action={behavior['action_label']}, intensity={intensity}"
        assert behavior["action_label"] in ("Recovery", "Light Review", "Normal", "Deep Work", "Exploration"), \
            f"action_label must be a valid HALE action. Got: {behavior['action_label']}"

    def test_high_fatigue_gives_recovery_or_light_review(self, cpp_client):
        """A fatigued user must get Recovery or Light Review action."""
        client, token, user_id, goal = cpp_client

        state_resp = client.post("/api/update-state", json={
            "mood": 0.3,
            "fatigue": 0.92,
            "stress": 0.7,
            "sleep_quality": 0.2,
        })
        assert state_resp.status_code == 200

        plan = client.get("/api/plan").json()
        action = plan["behavior"]["action_label"]
        assert action in ("Recovery", "Light Review"), \
            f"Highly fatigued user must get Recovery/Light Review. Got: {action}"

    def test_high_fatigue_reduces_task_duration(self, cpp_client):
        """In Recovery mode, task est_min must be reduced (≀33% of normal)."""
        client, token, user_id, goal = cpp_client

        # First get normal plan duration
        client.post("/api/update-state", json={"mood": 0.8, "fatigue": 0.1})
        normal_plan = client.get("/api/plan").json()
        normal_mins = sum(t.get("est_min", 0) for t in normal_plan.get("tasks_detail", []))

        # Now set extreme fatigue
        client.post("/api/update-state", json={
            "mood": 0.2,
            "fatigue": 0.95,
            "stress": 0.8,
        })
        # Force refresh by checking the behavior endpoint
        behavior = client.get("/api/behavior").json()
        if behavior["action_label"] in ("Recovery", "Light Review"):
            # Duration should be visibly shorter in a recovery-labeled plan
            assert behavior["intensity"] < 0.55, \
                f"Recovery/Light Review must have low intensity. Got: {behavior['intensity']}"

    def test_deep_work_increases_task_duration(self, cpp_client):
        """In Deep Work mode, task durations should be >= normal."""
        client, token, user_id, goal = cpp_client

        client.post("/api/update-state", json={
            "mood": 0.95,
            "fatigue": 0.05,
            "stress": 0.05,
            "sleep_quality": 1.0,
        })
        behavior = client.get("/api/behavior").json()
        # Deep Work intensity range is 0.65–0.95 per rl_engine.py
        if behavior["action_label"] == "Deep Work":
            assert behavior["intensity"] >= 0.65, \
                f"Deep Work must have high intensity. Got: {behavior['intensity']}"

    def test_state_update_persists_and_affects_plan(self, cpp_client):
        """State updated via /api/update-state must be reflected in next plan's behavior signal."""
        client, token, user_id, goal = cpp_client

        client.post("/api/update-state", json={"mood": 0.5, "fatigue": 0.8})
        plan = client.get("/api/plan").json()
        fatigue_in_plan = plan["behavior"]["fatigue"]
        # Must be close to what we set (β‰₯0.7 given rounding/EMA)
        assert fatigue_in_plan >= 0.6, \
            f"Plan must reflect high fatigue after state update. Got: {fatigue_in_plan}"

    def test_behavior_endpoint_structure(self, cpp_client):
        """GET /api/behavior must return all required keys."""
        client, token, user_id, goal = cpp_client
        resp = client.get("/api/behavior")
        assert resp.status_code == 200
        data = resp.json()
        required = {"action_label", "intensity", "mood", "fatigue"}
        missing = required - data.keys()
        assert not missing, f"Behavior endpoint missing keys: {missing}"

    def test_cold_start_user_gets_normal_plan(self, auth_client):
        """A brand new user with no history should get a safe default plan (not extreme)."""
        client, token, user_id = auth_client
        client.post("/api/goals/", json={
            "goal": "Learn C++", "category": "coding", "duration_days": 30,
        })
        plan = client.get("/api/plan").json()
        action = plan["behavior"]["action_label"]
        intensity = float(plan["behavior"]["intensity"])
        # Cold start should not exceed the baseline Deep Work ceiling (0.95)
        assert action != "Deep Work" or intensity <= 0.95, \
            f"Cold-start user should not get extreme Deep Work. Got action={action}, intensity={intensity}"

    def test_sick_state_forces_recovery(self, cpp_client):
        """A sick user (sick=1.0) must always receive Recovery or Light Review.

        We check via the behavior signal (which reflects the forced safety guard) and

        confirm intensity is in the recovery/light-review range (<=0.55).

        """
        client, token, user_id, goal = cpp_client
        client.post("/api/update-state", json={
            "mood": 0.3,
            "fatigue": 0.7,
            "stress": 0.5,
            "sick": 1.0,
        })
        behavior = client.get("/api/behavior").json()
        # Sick=1.0 causes disruption=1.0, which should force Recovery or Light Review
        # The safety guard guarantees this at the RL engine level
        assert behavior["action_label"] in ("Recovery", "Light Review"), \
            f"Sick user must get Recovery/Light Review. Got: {behavior['action_label']}"


# ═══════════════════════════════════════════════════════════════
# SCENARIO D β€” Gamification: XP, Streaks, Level-Ups
# ═══════════════════════════════════════════════════════════════

class TestGamification:

    def test_xp_awarded_on_completion(self, cpp_client):
        """Each task completion must award XP > 0."""
        client, token, user_id, goal = cpp_client
        client.get("/api/plan")
        comp = client.post("/api/complete-task", json={
            "date": TODAY,
            "actual_duration_min": 30,
            "satisfaction": 0.8,
        })
        assert comp.status_code == 200
        data = comp.json()
        assert "xp_gained" in data, "xp_gained must be in response"
        assert data["xp_gained"] > 0, f"XP awarded must be > 0. Got: {data['xp_gained']}"
        assert data["total_xp"] >= data["xp_gained"], "total_xp must include newly awarded xp"

    def test_streak_starts_at_1_on_first_completion(self, cpp_client):
        """After first task completion, streak must be 1."""
        client, token, user_id, goal = cpp_client
        client.get("/api/plan")
        comp = client.post("/api/complete-task", json={
            "date": TODAY,
            "actual_duration_min": 30,
            "satisfaction": 0.7,
        })
        assert comp.status_code == 200
        data = comp.json()
        assert data.get("streak", 0) >= 1, f"Streak must be >= 1 after first completion. Got: {data.get('streak')}"

    def test_xp_is_reflected_in_stats(self, cpp_client):
        """GET /api/stats must show the XP and streak accumulated.

        Note: the key is 'xp' (not 'total_xp') and 'current_streak'.

        """
        client, token, user_id, goal = cpp_client
        client.get("/api/plan")
        client.post("/api/complete-task", json={
            "date": TODAY,
            "actual_duration_min": 30,
            "satisfaction": 0.8,
        })

        stats = client.get("/api/stats").json()
        assert stats.get("xp", 0) > 0, "Stats must show > 0 XP after completion (key: 'xp')"
        assert stats.get("current_streak", 0) >= 1, "Streak must show in stats (key: 'current_streak')"

    def test_streak_bonus_increases_xp(self, cpp_client):
        """With a streak > 0, XP should be higher than the base 25 XP."""
        client, token, user_id, goal = cpp_client
        client.get("/api/plan")

        # First completion (streak = 1, so bonus = 10%)
        comp = client.post("/api/complete-task", json={
            "date": TODAY,
            "actual_duration_min": 30,
            "satisfaction": 0.8,
        })
        assert comp.status_code == 200
        data = comp.json()
        streak = data.get("streak", 0)
        # If streak >= 1, xp_gained should be >= 25 (base) 
        # With streak=1: 25 * (1 + 1*0.1) = 27
        if streak >= 1:
            assert data["xp_gained"] >= 25, \
                f"With streak {streak}, XP should be >= 25. Got: {data['xp_gained']}"

    def test_no_xp_for_skipped_task(self, cpp_client):
        """Skipping a task must NOT award any XP."""
        client, token, user_id, goal = cpp_client
        client.get("/api/plan")

        # Get initial stats
        stats_before = client.get("/api/stats").json()
        xp_before = stats_before.get("total_xp", 0)

        # Skip the task
        client.post("/api/skip-task", json={"date": TODAY, "reason": "busy"})

        # Check stats after skip
        stats_after = client.get("/api/stats").json()
        xp_after = stats_after.get("total_xp", 0)
        assert xp_after == xp_before, \
            f"Skipping a task must not award XP. Before: {xp_before}, After: {xp_after}"

    def test_level_up_triggers_at_500_xp(self, cpp_client):
        """After accumulating >= 500 XP (20 completions with streak), level must increase."""
        client, token, user_id, goal = cpp_client
        client.get("/api/plan")

        # First completion to get level baseline
        comp1 = client.post("/api/complete-task", json={
            "date": TODAY,
            "actual_duration_min": 30,
            "satisfaction": 0.8,
        })
        initial_level = comp1.json().get("level", 1)
        # We can't simulate 20 days in unit tests without date mocking,
        # but we verify the XP math: 500 XP / 25 per task = 20 tasks needed.
        # Just ensure level is present and is an integer
        assert isinstance(initial_level, int), "Level must be an integer"
        assert initial_level >= 1, "Level must be >= 1"


# ═══════════════════════════════════════════════════════════════
# SCENARIO E β€” Falling Behind / Overdrive Mode
# ═══════════════════════════════════════════════════════════════

class TestFallingBehindOverdrive:

    def test_progress_context_not_falling_behind_on_day1(self, cpp_client):
        """On day 1, user is NOT falling behind."""
        client, token, user_id, goal = cpp_client
        plan = client.get("/api/plan").json()
        ctx = plan.get("progress_context", {})
        assert ctx.get("is_falling_behind") == False, \
            f"On day 1, user must NOT be falling behind. Got: {ctx}"

    def test_progress_context_not_overdue_on_day1(self, cpp_client):
        """On day 1 of a 30-day curriculum, is_overdue must be False."""
        client, token, user_id, goal = cpp_client
        plan = client.get("/api/plan").json()
        ctx = plan.get("progress_context", {})
        assert ctx.get("is_overdue") == False, \
            f"On day 1, goal must not be overdue. Got: {ctx}"

    def test_plan_has_coach_message(self, cpp_client):
        """The plan must include a non-empty coach_message."""
        client, token, user_id, goal = cpp_client
        plan = client.get("/api/plan").json()
        assert "coach_message" in plan, "Plan must contain a coach_message"
        assert len(plan["coach_message"]) > 5, \
            f"Coach message must be non-trivial. Got: '{plan['coach_message']}'"

    def test_stats_show_goal_progress(self, cpp_client):
        """After completing one task, per-goal stats must show progress.

        Note: per-goal stats route is /api/stats/{goal_id} (path param, not query param).

        """
        client, token, user_id, goal = cpp_client
        client.get("/api/plan")
        client.post("/api/complete-task", json={
            "date": TODAY,
            "actual_duration_min": 30,
            "satisfaction": 0.8,
        })

        stats = client.get(f"/api/stats/{goal['id']}").json()
        assert stats.get("total_sessions", 0) >= 1, \
            "Per-goal stats must show at least 1 session after completion"


# ═══════════════════════════════════════════════════════════════
# SCENARIO F β€” Auto-close / Day Closer
# ═══════════════════════════════════════════════════════════════

class TestDayCloser:

    def test_history_grows_after_completion(self, cpp_client):
        """After completing a task, plan history must have at least 1 entry."""
        client, token, user_id, goal = cpp_client
        client.get("/api/plan")
        client.post("/api/complete-task", json={
            "date": TODAY,
            "actual_duration_min": 30,
            "satisfaction": 0.8,
        })

        history = client.get("/api/history?days=7").json()
        assert len(history) >= 1, "History must show at least 1 entry after completion"

    def test_history_respects_days_param(self, cpp_client):
        """GET /api/history with ?days=1 should return only today's entries."""
        client, token, user_id, goal = cpp_client
        client.get("/api/plan")
        client.post("/api/complete-task", json={
            "date": TODAY,
            "actual_duration_min": 30,
            "satisfaction": 0.8,
        })

        history_1d = client.get("/api/history?days=1").json()
        history_30d = client.get("/api/history?days=30").json()
        assert len(history_1d) <= len(history_30d), \
            "1-day history must not be larger than 30-day history"

    def test_invalid_days_param_rejected(self, cpp_client):
        """GET /api/history with days=0 must return 422 (schema validation, ge=1 constraint)."""
        client, token, user_id, goal = cpp_client
        resp = client.get("/api/history?days=0")
        assert resp.status_code == 422, \
            f"days=0 should be rejected with 422. Got: {resp.status_code}"


# ═══════════════════════════════════════════════════════════════
# SCENARIO G β€” Goal Completion: Mastery Mode
# ═══════════════════════════════════════════════════════════════

class TestGoalCompletion:

    def test_goal_marked_complete_after_all_tasks(self, cpp_client):
        """After completing all curriculum tasks, goal must be marked is_completed=True."""
        client, token, user_id, goal = cpp_client
        goal_id = goal["id"]

        # Complete all tasks in sequence
        MAX_ITERATIONS = 40  # Safety cap
        for _ in range(MAX_ITERATIONS):
            plan_resp = client.get("/api/plan")
            assert plan_resp.status_code == 200
            plan_data = plan_resp.json()
            comp_date = plan_data["date"]

            comp = client.post("/api/complete-task", json={
                "date": comp_date,
                "actual_duration_min": 30,
                "satisfaction": 0.8,
            })
            if comp.status_code != 200:
                break

            goal_state = client.get(f"/api/goals/{goal_id}").json()
            if goal_state.get("is_completed"):
                break

        final_goal = client.get(f"/api/goals/{goal_id}").json()
        assert final_goal.get("is_completed") or final_goal.get("completion_pct") >= 100.0, \
            f"Goal must be completed after all tasks done. Got: {final_goal}"

    def test_mastery_task_served_after_completion(self, cpp_client):
        """After all tasks are done, the plan must serve a [Mastery] maintenance task,

        OR if the goal is now excluded from active goals (is_completed=True), the plan

        returns empty (0 tasks). Both are valid post-completion behaviors.

        """
        client, token, user_id, goal = cpp_client
        goal_id = goal["id"]

        MAX_ITERATIONS = 40
        found_mastery = False
        for _ in range(MAX_ITERATIONS):
            plan_resp = client.get("/api/plan")
            plan_data = plan_resp.json()

            # Empty plan means goal is completed and filtered out β€” that's acceptable
            if plan_data.get("goals_count", 0) == 0:
                found_mastery = True  # Goal completed, plan is gracefully empty
                break

            tasks_str = " ".join(plan_data.get("tasks", []))
            if "[Mastery]" in tasks_str:
                found_mastery = True
                break

            comp_date = plan_data["date"]
            comp = client.post("/api/complete-task", json={
                "date": comp_date,
                "actual_duration_min": 30,
                "satisfaction": 0.8,
            })
            if comp.status_code != 200:
                break

        assert found_mastery, \
            "After completing all tasks, plan must show [Mastery] or graceful empty (goal completed)"


# ═══════════════════════════════════════════════════════════════
# SCENARIO H β€” Edge Cases
# ═══════════════════════════════════════════════════════════════

class TestEdgeCases:

    def test_unauthenticated_plan_request_rejected(self, client):
        """GET /api/plan without Bearer token must return 401."""
        resp = client.get("/api/plan")
        assert resp.status_code == 401

    def test_multiple_goals_combined_in_plan(self, auth_client):
        """With 2 active goals, the plan must include tasks from both goals."""
        client, token, user_id = auth_client
        client.post("/api/goals/", json={"goal": "Learn C++", "category": "coding", "duration_days": 30})
        client.post("/api/goals/", json={"goal": "Learn Piano", "category": "music", "duration_days": 30})

        plan = client.get("/api/plan").json()
        assert plan["goals_count"] == 2, \
            f"Plan must cover 2 goals. Got goals_count={plan['goals_count']}"
        assert len(plan["tasks"]) >= 2, \
            f"Plan must have at least 2 tasks for 2 goals. Got: {plan['tasks']}"

    def test_deactivated_goal_excluded_from_plan(self, auth_client):
        """A deactivated goal must NOT appear in the plan (returns 200 with goals_count=0)."""
        client, token, user_id = auth_client
        resp = client.post("/api/goals/", json={"goal": "Learn C++", "category": "coding", "duration_days": 30})
        goal_id = resp.json()["id"]

        plan_before = client.get("/api/plan").json()
        assert plan_before["goals_count"] == 1

        # Deactivate the goal
        client.delete(f"/api/goals/{goal_id}")

        plan_after = client.get("/api/plan").json()
        # After BUG FIX: returns 200 with empty task list instead of 404
        assert plan_after["goals_count"] == 0, \
            "Deactivated goal must not appear in the plan"

    def test_satisfaction_out_of_range_rejected(self, cpp_client):
        """Satisfaction > 1.0 must be rejected with 422."""
        client, token, user_id, goal = cpp_client
        client.get("/api/plan")
        resp = client.post("/api/complete-task", json={
            "date": TODAY,
            "actual_duration_min": 30,
            "satisfaction": 2.5,  # Out of range
        })
        assert resp.status_code == 422, \
            f"satisfaction=2.5 must be rejected. Got: {resp.status_code}"

    def test_state_update_out_of_range_rejected(self, cpp_client):
        """fatigue > 1.0 must be rejected with 422."""
        client, token, user_id, goal = cpp_client
        resp = client.post("/api/update-state", json={"fatigue": 1.5})
        assert resp.status_code == 422, \
            f"fatigue=1.5 must be rejected. Got: {resp.status_code}"

    def test_goal_progress_timeline_accuracy(self, cpp_client):
        """GET /api/goals/{id}/progress must reflect completed task count."""
        client, token, user_id, goal = cpp_client
        goal_id = goal["id"]

        client.get("/api/plan")
        client.post("/api/complete-task", json={
            "date": TODAY,
            "actual_duration_min": 30,
            "satisfaction": 0.8,
        })

        prog_resp = client.get(f"/api/goals/{goal_id}/progress").json()
        assert prog_resp.get("completed_sessions") == 1, \
            f"Progress timeline must show 1 completed session. Got: {prog_resp}"
        assert prog_resp.get("goal_id") == goal_id

    def test_plan_includes_behavior_signal(self, cpp_client):
        """The plan response must always include a behavior signal object."""
        client, token, user_id, goal = cpp_client
        plan = client.get("/api/plan").json()
        assert "behavior" in plan, "Plan must include behavior signal"
        beh = plan["behavior"]
        assert "action_label" in beh, "Behavior must contain action_label"
        assert "intensity" in beh, "Behavior must contain intensity"
        assert beh["action_label"] in ("Recovery", "Light Review", "Normal", "Deep Work", "Exploration"), \
            f"action_label must be a valid action. Got: {beh['action_label']}"

    def test_plan_response_time_header_present(self, cpp_client):
        """Every response must carry X-Response-Time-Ms middleware header."""
        client, token, user_id, goal = cpp_client
        resp = client.get("/api/plan")
        assert "x-response-time-ms" in resp.headers or "X-Response-Time-Ms" in resp.headers, \
            "Response must include X-Response-Time-Ms header"

    def test_request_id_header_present(self, cpp_client):
        """Every response must carry X-Request-ID middleware header."""
        client, token, user_id, goal = cpp_client
        resp = client.get("/api/plan")
        assert "x-request-id" in resp.headers or "X-Request-ID" in resp.headers, \
            "Response must include X-Request-ID header"