"""Tests for the interactive inspection shell (fablepool.shell). Two layers: * unit tests with a stub CLI, verifying the exact argv translation, the ordinal (``#N``) resolution, error handling, and the session loop; * one integration test driving the shell against a real seeded node. """ from __future__ import annotations import io import json from fablepool.shell import DEFAULT_QUESTION, FableShell CLAIMS = [ { "id": "claim-aaaa1111", "topic": "travel", "statement": "Often in Lisbon", "confidence": 0.82, "status": "active", }, { "id": "claim-bbbb2222", "topic": "work", "statement": "Works at Acme", "confidence": 0.61, "status": "active", }, ] class StubCli: """Records every argv and prints a canned response per subcommand.""" def __init__(self, responses=None, codes=None): self.calls = [] self.responses = dict(responses or {}) self.codes = dict(codes or {}) def __call__(self, argv): argv = list(argv) self.calls.append(argv) sub = argv[2] if len(argv) > 2 else "" resp = self.responses.get(sub) if resp is not None: print(resp) return self.codes.get(sub, 0) class BoomCli: def __init__(self): self.calls = [] def __call__(self, argv): self.calls.append(list(argv)) raise RuntimeError("boom") def make_shell(cli): out = io.StringIO() shell = FableShell("HOME", stdin=io.StringIO(), stdout=out, cli=cli) return shell, out def stub_with_claims(extra_responses=None, codes=None): responses = {"claims": json.dumps(CLAIMS)} responses.update(extra_responses or {}) return StubCli(responses=responses, codes=codes) # --------------------------------------------------------------------------- # # listing and rendering # --------------------------------------------------------------------------- # def test_claims_renders_numbered_listing(): stub = stub_with_claims() shell, out = make_shell(stub) assert shell.dispatch("claims") is True text = out.getvalue() assert "#1" in text and "#2" in text assert "claim-aaaa11" in text # 12-char id prefix assert "travel" in text and "Often in Lisbon" in text assert stub.calls[0] == ["--home", "HOME", "claims", "--json"] def test_claims_topic_and_all_flags_translate_to_argv(): stub = stub_with_claims() shell, _ = make_shell(stub) shell.dispatch("claims travel --all") assert stub.calls[0] == [ "--home", "HOME", "claims", "--json", "--topic", "travel", "--all", ] def test_claims_empty_listing_message(): stub = StubCli(responses={"claims": json.dumps([])}) shell, out = make_shell(stub) shell.dispatch("claims") assert "no claims" in out.getvalue().lower() def test_claims_falls_back_to_raw_output_on_unparseable_json(): stub = StubCli(responses={"claims": "plain text listing"}) shell, out = make_shell(stub) shell.dispatch("claims") assert "plain text listing" in out.getvalue() # --------------------------------------------------------------------------- # # ordinal (#N) resolution # --------------------------------------------------------------------------- # def test_ordinal_resolution_for_show(): stub = stub_with_claims() shell, _ = make_shell(stub) shell.dispatch("claims") shell.dispatch("show 2") assert stub.calls[-1] == ["--home", "HOME", "show", "claim-bbbb2222"] shell.dispatch("show #1") assert stub.calls[-1] == ["--home", "HOME", "show", "claim-aaaa1111"] def test_ordinal_resolution_for_why(): stub = stub_with_claims() shell, _ = make_shell(stub) shell.dispatch("claims") shell.dispatch("why #2") assert stub.calls[-1] == ["--home", "HOME", "why", "claim-bbbb2222"] def test_ordinal_without_prior_listing_is_an_error(): stub = stub_with_claims() shell, out = make_shell(stub) shell.dispatch("show 3") assert "no listing yet" in out.getvalue() assert stub.calls == [] # the CLI was never invoked def test_ordinal_out_of_range_is_an_error(): stub = stub_with_claims() shell, out = make_shell(stub) shell.dispatch("claims") calls_before = len(stub.calls) shell.dispatch("show #9") assert "out of range" in out.getvalue() assert len(stub.calls) == calls_before def test_raw_ids_pass_through_unchanged(): stub = stub_with_claims() shell, _ = make_shell(stub) shell.dispatch("show claim-aaaa1111") assert stub.calls[-1] == ["--home", "HOME", "show", "claim-aaaa1111"] # --------------------------------------------------------------------------- # # mutation command parsing # --------------------------------------------------------------------------- # def test_refute_joins_free_text_reason(): stub = stub_with_claims() shell, _ = make_shell(stub) shell.dispatch("claims") shell.dispatch("refute 1 wrong city entirely") assert stub.calls[-1] == [ "--home", "HOME", "refute", "claim-aaaa1111", "--reason", "wrong city entirely", ] def test_refute_supplies_default_reason(): stub = stub_with_claims() shell, _ = make_shell(stub) shell.dispatch("claims") shell.dispatch("refute #2") argv = stub.calls[-1] assert argv[:4] == ["--home", "HOME", "refute", "claim-bbbb2222"] assert argv[4] == "--reason" and argv[5] # non-empty default reason def test_refute_without_ref_is_usage_error(): stub = stub_with_claims() shell, out = make_shell(stub) shell.dispatch("refute") assert "usage: refute" in out.getvalue() assert stub.calls == [] def test_correct_parses_value_and_reason(): stub = stub_with_claims() shell, _ = make_shell(stub) shell.dispatch("claims") shell.dispatch("correct #2 Globex Corp --reason changed jobs") assert stub.calls[-1] == [ "--home", "HOME", "correct", "claim-bbbb2222", "--value", "Globex Corp", "--reason", "changed jobs", ] def test_correct_supplies_default_reason(): stub = stub_with_claims() shell, _ = make_shell(stub) shell.dispatch("claims") shell.dispatch("correct 1 Lisbon") argv = stub.calls[-1] assert argv[:6] == [ "--home", "HOME", "correct", "claim-aaaa1111", "--value", "Lisbon", ] assert argv[6] == "--reason" and argv[7] def test_correct_without_value_is_usage_error(): stub = stub_with_claims() shell, out = make_shell(stub) shell.dispatch("correct claim-aaaa1111") assert "usage: correct" in out.getvalue() assert stub.calls == [] # --------------------------------------------------------------------------- # # other command translations # --------------------------------------------------------------------------- # def test_ask_uses_canonical_default_question(): stub = StubCli() shell, _ = make_shell(stub) shell.dispatch("ask") assert stub.calls[-1] == ["--home", "HOME", "ask", DEFAULT_QUESTION] def test_ask_joins_question_words(): stub = StubCli() shell, _ = make_shell(stub) shell.dispatch("ask where do I work") assert stub.calls[-1] == ["--home", "HOME", "ask", "where do I work"] def test_export_parses_path_and_topics(): stub = StubCli() shell, _ = make_shell(stub) shell.dispatch("export out.fpl --topic travel --topic work") assert stub.calls[-1] == [ "--home", "HOME", "export", "--out", "out.fpl", "--topic", "travel", "--topic", "work", ] def test_export_without_args_exports_everything(): stub = StubCli() shell, _ = make_shell(stub) shell.dispatch("export") assert stub.calls[-1] == ["--home", "HOME", "export"] def test_log_limit_translation(): stub = StubCli() shell, _ = make_shell(stub) shell.dispatch("log 5") assert stub.calls[-1] == ["--home", "HOME", "log", "--limit", "5"] shell.dispatch("log") assert stub.calls[-1] == ["--home", "HOME", "log"] def test_audit_translation(): stub = StubCli() shell, _ = make_shell(stub) shell.dispatch("audit") assert stub.calls[-1] == ["--home", "HOME", "audit"] def test_evidence_translation(): stub = StubCli() shell, _ = make_shell(stub) shell.dispatch("evidence ev-1234") assert stub.calls[-1] == ["--home", "HOME", "evidence", "ev-1234"] def test_home_and_help_are_local_commands(): stub = StubCli() shell, out = make_shell(stub) shell.dispatch("home") shell.dispatch("help") text = out.getvalue() assert "HOME" in text assert "Commands" in text and "refute" in text assert stub.calls == [] # neither command touches the CLI # --------------------------------------------------------------------------- # # session behaviour # --------------------------------------------------------------------------- # def test_unknown_command_reports_and_continues(): stub = StubCli() shell, out = make_shell(stub) assert shell.dispatch("frobnicate") is True assert "unknown command" in out.getvalue() assert stub.calls == [] def test_blank_lines_and_comments_are_ignored(): stub = StubCli() shell, _ = make_shell(stub) assert shell.dispatch("") is True assert shell.dispatch(" ") is True assert shell.dispatch("# a scripted comment") is True assert stub.calls == [] def test_quit_stops_the_session(): stub = StubCli() shell, out = make_shell(stub) assert shell.dispatch("quit") is False assert "bye" in out.getvalue().lower() def test_cli_failure_surfaces_output_and_exit_code(): stub = StubCli(responses={"show": "no such claim"}, codes={"show": 2}) shell, out = make_shell(stub) assert shell.dispatch("show claim-x") is True # session continues assert "no such claim" in out.getvalue() assert shell.last_code == 2 def test_shell_survives_cli_exceptions(): boom = BoomCli() shell, out = make_shell(boom) assert shell.dispatch("audit") is True assert "error: boom" in out.getvalue() assert shell.last_code == 1 def test_run_loop_consumes_stream_and_exits(): stub = StubCli() out = io.StringIO() shell = FableShell( "HOME", stdin=io.StringIO("help\nquit\nnever-reached\n"), stdout=out, cli=stub ) assert shell.run() == 0 text = out.getvalue() assert "FablePool inspection shell" in text assert "bye" in text.lower() assert "never-reached" not in [" ".join(c) for c in stub.calls] def test_run_loop_ends_cleanly_on_eof(): stub = StubCli() out = io.StringIO() shell = FableShell("HOME", stdin=io.StringIO("topics\n"), stdout=out, cli=stub) assert shell.run() == 0 # stream ends without 'quit' # --------------------------------------------------------------------------- # # integration against a real seeded node # --------------------------------------------------------------------------- # def test_shell_against_real_node(home): out = io.StringIO() shell = FableShell( str(home), stdin=io.StringIO("topics\nclaims\nshow 1\nwhy 1\nquit\n"), stdout=out, ) assert shell.run() == 0 text = out.getvalue() assert "#1" in text, "real claims should render as a numbered listing" assert "bye" in text.lower() assert shell.last_code == 0 def test_shell_refute_against_real_node(home): out = io.StringIO() shell = FableShell( str(home), stdin=io.StringIO("claims\nrefute 1 not true\nclaims --all\nquit\n"), stdout=out, ) assert shell.run() == 0 assert "refuted" in out.getvalue().lower()