mirror of
https://github.com/harvard-edge/cs249r_book.git
synced 2026-07-17 08:28:07 -05:00
The dashboard did not reflect CLI progress for users across Windows and Ubuntu. Three independent client defects, now fixed: 1. Automatic sync was silently skipped on any non-TTY shell. _trigger_submission gated on `not sys.stdin.isatty()`, which is False on Windows Git Bash/MinTTY and many IDE terminals even when interactive, so `tito module complete` updated local progress.json but never uploaded, with no message. Decouple "should we sync" (logged-in and not CI) from "should we prompt" (needs a TTY): non-interactive real users now sync without a prompt instead of being skipped. 2. A 2xx response with synced_modules null/0 was reported as success using the local count, hiding backend failures. sync_progress now returns a SyncResult and reports an honest accepted-but-unconfirmed warning when the server does not confirm persistence. 3. No standalone resync path and login did not sync, so modules completed before logging in never reached the dashboard. Add `tito community sync` and an offer to sync existing progress after login. The sync trigger decision now lives in one shared helper (auto_sync_after_completion) used by the module, milestone, and login paths, plus a small core/runtime.py that owns is_ci()/is_interactive(). Adds tests/cli/test_progress_sync.py (15 tests) covering the non-TTY regression, the CI/not-logged-in branches, honest 2xx interpretation, and the new command.
226 lines
7.9 KiB
Python
226 lines
7.9 KiB
Python
"""
|
|
Regression tests for community progress sync (issue #1849).
|
|
|
|
These guard the bug where the TinyTorch dashboard did not reflect CLI progress:
|
|
|
|
1. Automatic sync was silently skipped on any non-TTY shell (Git Bash / MinTTY
|
|
on Windows, IDE terminals), because the trigger gated on
|
|
``sys.stdin.isatty()``. The fix decouples "should we sync" from "should we
|
|
prompt": a logged-in, non-CI user always syncs; the prompt is only skipped
|
|
when there is no usable TTY.
|
|
2. A 2xx response with ``synced_modules`` null/0 was reported as success using
|
|
the local count, hiding backend failures. The fix reports an honest
|
|
accepted-but-unconfirmed warning instead.
|
|
3. There was no standalone resync command and login did not sync, so progress
|
|
completed before logging in never reached the dashboard.
|
|
"""
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
# Add tito to path (CI exports the package first; these tests only need tito).
|
|
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
|
|
|
from rich.console import Console
|
|
|
|
from tito.core import runtime
|
|
from tito.core import submission
|
|
from tito.core.submission import (
|
|
SyncResult,
|
|
SubmissionHandler,
|
|
auto_sync_after_completion,
|
|
)
|
|
from tito.core.config import CLIConfig
|
|
|
|
|
|
@pytest.fixture
|
|
def handler():
|
|
config = CLIConfig.from_project_root(Path.cwd())
|
|
return SubmissionHandler(config, Console())
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# runtime: CI vs interactivity are independent
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_is_ci_detects_env(monkeypatch):
|
|
monkeypatch.delenv("CI", raising=False)
|
|
monkeypatch.delenv("GITHUB_ACTIONS", raising=False)
|
|
assert runtime.is_ci() is False
|
|
monkeypatch.setenv("CI", "true")
|
|
assert runtime.is_ci() is True
|
|
|
|
|
|
def test_is_interactive_false_in_ci(monkeypatch):
|
|
monkeypatch.setenv("CI", "true")
|
|
assert runtime.is_interactive() is False
|
|
|
|
|
|
def test_is_interactive_false_without_tty(monkeypatch):
|
|
monkeypatch.delenv("CI", raising=False)
|
|
monkeypatch.delenv("GITHUB_ACTIONS", raising=False)
|
|
|
|
class _NoTTY:
|
|
def isatty(self):
|
|
return False
|
|
|
|
monkeypatch.setattr(sys, "stdin", _NoTTY())
|
|
monkeypatch.setattr(sys, "stdout", _NoTTY())
|
|
assert runtime.is_interactive() is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# auto_sync_after_completion: the trigger decision
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_auto_sync_runs_when_non_interactive_but_logged_in(monkeypatch):
|
|
"""THE core regression: a logged-in user on a non-TTY shell must still sync.
|
|
|
|
Previously this path returned early on ``not sys.stdin.isatty()`` and never
|
|
uploaded -- the silent skip behind the dashboard desync.
|
|
"""
|
|
monkeypatch.setattr(submission.runtime, "is_ci", lambda: False)
|
|
monkeypatch.setattr(submission.runtime, "is_interactive", lambda: False)
|
|
monkeypatch.setattr(submission.auth, "is_logged_in", lambda: True)
|
|
|
|
calls = {}
|
|
|
|
def fake_sync(self, total_modules=20, is_retry=False):
|
|
calls["ran"] = True
|
|
return SyncResult(ok=True, accepted=True, synced_modules=2, sent_modules=2)
|
|
|
|
monkeypatch.setattr(SubmissionHandler, "sync_progress", fake_sync)
|
|
|
|
result = auto_sync_after_completion(CLIConfig.from_project_root(Path.cwd()), Console())
|
|
assert calls.get("ran") is True
|
|
assert result is not None and result.ok
|
|
|
|
|
|
def test_auto_sync_prompts_when_interactive(monkeypatch):
|
|
monkeypatch.setattr(submission.runtime, "is_ci", lambda: False)
|
|
monkeypatch.setattr(submission.runtime, "is_interactive", lambda: True)
|
|
monkeypatch.setattr(submission.auth, "is_logged_in", lambda: True)
|
|
|
|
asked = {}
|
|
monkeypatch.setattr(submission.Confirm, "ask", lambda *a, **k: asked.setdefault("asked", True) or True)
|
|
|
|
ran = {}
|
|
monkeypatch.setattr(
|
|
SubmissionHandler, "sync_progress",
|
|
lambda self, total_modules=20, is_retry=False: ran.setdefault("ran", True) or SyncResult(ok=True),
|
|
)
|
|
|
|
auto_sync_after_completion(CLIConfig.from_project_root(Path.cwd()), Console())
|
|
assert asked.get("asked") is True
|
|
assert ran.get("ran") is True
|
|
|
|
|
|
def test_auto_sync_interactive_decline_does_not_sync(monkeypatch):
|
|
monkeypatch.setattr(submission.runtime, "is_ci", lambda: False)
|
|
monkeypatch.setattr(submission.runtime, "is_interactive", lambda: True)
|
|
monkeypatch.setattr(submission.auth, "is_logged_in", lambda: True)
|
|
monkeypatch.setattr(submission.Confirm, "ask", lambda *a, **k: False)
|
|
|
|
ran = {}
|
|
monkeypatch.setattr(
|
|
SubmissionHandler, "sync_progress",
|
|
lambda self, total_modules=20, is_retry=False: ran.setdefault("ran", True),
|
|
)
|
|
|
|
result = auto_sync_after_completion(CLIConfig.from_project_root(Path.cwd()), Console())
|
|
assert "ran" not in ran
|
|
assert result is None
|
|
|
|
|
|
def test_auto_sync_skipped_in_ci(monkeypatch):
|
|
monkeypatch.setattr(submission.runtime, "is_ci", lambda: True)
|
|
monkeypatch.setattr(submission.auth, "is_logged_in", lambda: True)
|
|
|
|
ran = {}
|
|
monkeypatch.setattr(
|
|
SubmissionHandler, "sync_progress",
|
|
lambda self, total_modules=20, is_retry=False: ran.setdefault("ran", True),
|
|
)
|
|
|
|
result = auto_sync_after_completion(CLIConfig.from_project_root(Path.cwd()), Console())
|
|
assert "ran" not in ran
|
|
assert result is None
|
|
|
|
|
|
def test_auto_sync_hint_when_not_logged_in(monkeypatch):
|
|
monkeypatch.setattr(submission.runtime, "is_ci", lambda: False)
|
|
monkeypatch.setattr(submission.auth, "is_logged_in", lambda: False)
|
|
|
|
ran = {}
|
|
monkeypatch.setattr(
|
|
SubmissionHandler, "sync_progress",
|
|
lambda self, total_modules=20, is_retry=False: ran.setdefault("ran", True),
|
|
)
|
|
|
|
result = auto_sync_after_completion(CLIConfig.from_project_root(Path.cwd()), Console())
|
|
assert "ran" not in ran
|
|
assert result is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# honest 2xx interpretation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_2xx_confirmed_is_ok(handler):
|
|
result = handler._interpret_2xx(synced=2, sent=2)
|
|
assert result.ok is True
|
|
assert result.accepted is True
|
|
assert result.synced_modules == 2
|
|
|
|
|
|
def test_2xx_null_synced_is_unconfirmed_warning(handler):
|
|
"""2xx but server reports nothing persisted while we sent modules -> NOT ok."""
|
|
result = handler._interpret_2xx(synced=None, sent=2)
|
|
assert result.ok is False
|
|
assert result.accepted is True
|
|
assert result.sent_modules == 2
|
|
|
|
|
|
def test_2xx_zero_synced_is_unconfirmed_warning(handler):
|
|
result = handler._interpret_2xx(synced=0, sent=3)
|
|
assert result.ok is False
|
|
assert result.accepted is True
|
|
|
|
|
|
def test_2xx_nothing_to_sync_is_ok(handler):
|
|
result = handler._interpret_2xx(synced=None, sent=0)
|
|
assert result.ok is True
|
|
assert result.accepted is True
|
|
|
|
|
|
def test_sync_result_truthiness():
|
|
assert bool(SyncResult(ok=True)) is True
|
|
assert bool(SyncResult(ok=False, accepted=True)) is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# the recovery command exists and is wired
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_community_sync_subcommand_registered():
|
|
import argparse
|
|
from tito.commands.community import CommunityCommand
|
|
|
|
cmd = CommunityCommand(CLIConfig.from_project_root(Path.cwd()))
|
|
parser = argparse.ArgumentParser()
|
|
cmd.add_arguments(parser)
|
|
args = parser.parse_args(["sync"])
|
|
assert args.community_command == "sync"
|
|
|
|
|
|
def test_community_sync_requires_login(monkeypatch):
|
|
from tito.commands.community import CommunityCommand
|
|
import argparse
|
|
|
|
monkeypatch.setattr("tito.commands.community.auth.is_logged_in", lambda: False)
|
|
cmd = CommunityCommand(CLIConfig.from_project_root(Path.cwd()))
|
|
ns = argparse.Namespace(community_command="sync")
|
|
assert cmd.run(ns) == 1 # not logged in -> nonzero exit, no upload attempted
|