forked from Keelrleek/Harbor
Messages, threads, acknowledgements, claims, bands, tasks, review items and stop flags for a team of AI agents; the chat page and board; host hooks and MCP server; autopilot; tools/watch.py. Put under version control after the weekend's changes so they have history and can be rolled back. Local only.
370 lines
21 KiB
Python
370 lines
21 KiB
Python
"""Harbor store tests (run: py -3.12 -B -m unittest discover -s R:\\Workspace\\Tools\\Harbor\\tests)."""
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import time
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
from helpers import LEGACY_LINE, ROSTER, Clock, HarborCase, make_home, temp_dir
|
|
from harbor import projects
|
|
from harbor.core import Harbor, HarborError, parse_scope
|
|
|
|
TOOLS = Path(r"R:\Workspace\Tools")
|
|
|
|
|
|
class Messages(HarborCase):
|
|
def test_send_inbox_ack(self):
|
|
r = self.h.send("Claude", ["DeepSeek"], "Please fix 0x529350", thread="fn:0x529350",
|
|
subject="AudioManagerTick", kind="request", ack_required=True)
|
|
self.assertEqual(r["targets"], ["DeepSeek"])
|
|
self.assertEqual([m["id"] for m in self.h.inbox("DeepSeek")], [r["id"]])
|
|
self.assertEqual(self.h.inbox("DeepSeek"), [])
|
|
self.assertEqual(len(self.h.pending_acks(sender="Claude")), 1)
|
|
self.h.ack("DeepSeek", [r["id"]], note="fixed")
|
|
self.assertEqual(self.h.pending_acks(sender="Claude"), [])
|
|
with self.assertRaises(HarborError):
|
|
self.h.ack("Kimi", [r["id"]])
|
|
|
|
def test_all_expands_to_everyone_but_sender(self):
|
|
r = self.h.send("Codex", "All", "hello")
|
|
self.assertEqual(sorted(r["targets"]), sorted(["Claude", "DeepSeek", "OpenCode", "Kimi", "User"]))
|
|
|
|
def test_reply_keeps_thread_and_people(self):
|
|
first = self.h.send("Claude", ["DeepSeek", "Kimi"], "question", thread="fn:0x50A720")
|
|
reply = self.h.send("DeepSeek", None, "answer", reply_to=first["id"])
|
|
self.assertEqual(reply["thread_id"], first["thread_id"])
|
|
self.assertEqual(sorted(reply["targets"]), ["Claude", "Kimi"])
|
|
broadcast = self.h.send("Codex", "All", "status for everyone")
|
|
answer = self.h.send("Kimi", None, "noted", reply_to=broadcast["id"])
|
|
self.assertEqual(answer["recipients"], ["All"])
|
|
|
|
def test_urgent_first_and_length_limits(self):
|
|
self.h.send("Claude", ["Kimi"], "normal one")
|
|
urgent = self.h.send("Claude", ["Kimi"], "urgent one", importance="urgent")
|
|
self.assertEqual(self.h.inbox("Kimi")[0]["id"], urgent["id"])
|
|
self.assertTrue(self.h.send("Claude", ["Kimi"], "word " * 3000)["id"])
|
|
with self.assertRaises(HarborError):
|
|
self.h.send("Claude", ["Kimi"], "x" * 20001)
|
|
|
|
def test_only_web_can_speak_as_the_human(self):
|
|
for origin in ("cli", "mcp", "hook", "cli-legacy"):
|
|
with self.assertRaises(HarborError):
|
|
self.h.send("User", "All", "resume everyone", origin=origin)
|
|
self.assertTrue(self.h.send("User", "All", "hi", origin="web")["id"])
|
|
|
|
def test_idempotent_send(self):
|
|
a = self.h.send("Claude", ["Kimi"], "once", idem="k1")
|
|
b = self.h.send("Claude", ["Kimi"], "once", idem="k1")
|
|
self.assertEqual(a["id"], b["id"])
|
|
self.assertEqual(len(self.h.inbox("Kimi")), 1)
|
|
|
|
def test_lint_compressed_text_and_flooding(self):
|
|
squashed = ("Root full222/ALL11 tick relocation audit proves SEVEN bad offsets; correctOffsets24ByteLayout "
|
|
"vs yourOffsetsWereWrong and stub23HarnessBug plus relocgen2Output and nativeBaseline6Cases")
|
|
self.assertTrue(any("compressed" in w for w in self.h.send("Codex", ["DeepSeek"], squashed)["warnings"]))
|
|
self.assertEqual(self.h.send("Codex", ["DeepSeek"], "Commit 4c392e33ae94858b fixed 0x45A970.")["warnings"], [])
|
|
for i in range(8):
|
|
self.h.send("Codex", "All", f"progress {i}")
|
|
self.assertTrue(any("in the last hour" in w for w in self.h.send("Codex", "All", "more")["warnings"]))
|
|
self.assertEqual(self.h.send("Codex", ["Claude"], "direct is fine")["warnings"], [])
|
|
|
|
def test_search_and_attachments(self):
|
|
self.h.send("Claude", ["Kimi"], "LayoutTextMesh zero-extends the glyph key")
|
|
self.h.send("Claude", ["Kimi"], "unrelated")
|
|
self.assertEqual(len(self.h.search("LayoutTextMesh glyph")), 1)
|
|
report = self.dir / "report.txt"
|
|
report.write_text("six cases match", encoding="utf-8")
|
|
r = self.h.send("DeepSeek", ["Claude"], "harness report attached", attachments=[report])
|
|
name = self.h.message(r["id"])["refs"]["attachments"][0]
|
|
self.assertTrue(name.endswith("-report.txt"))
|
|
self.assertEqual((self.project.attachments / name).read_text(encoding="utf-8"), "six cases match")
|
|
self.assertIn("Attachments: " + name, self.mirror.read_text(encoding="utf-8"))
|
|
with self.assertRaises(HarborError):
|
|
self.h.send("DeepSeek", ["Claude"], "x", attachments=[self.dir / "missing.txt"])
|
|
|
|
def test_receipts(self):
|
|
r = self.h.send("User", ["Codex", "Kimi"], "please both look", origin="web", ack_required=True)
|
|
self.h.inbox("Codex")
|
|
self.h.ack("Kimi", [r["id"]])
|
|
self.assertEqual(self.h.receipts([r["id"]])[r["id"]], {"read": ["Codex"], "acked": ["Kimi"], "waiting": []})
|
|
|
|
def test_threads_close_and_reopen(self):
|
|
r = self.h.send("Claude", ["Kimi"], "question", thread="fn:0x445310")
|
|
self.h.set_thread_state("Kimi", "fn:0x445310", "closed")
|
|
self.assertNotIn("fn:0x445310", [t["topic"] for t in self.h.threads(include_closed=False)])
|
|
self.h.send("Kimi", None, "one more thing", reply_to=r["id"])
|
|
self.assertEqual(self.h.thread("fn:0x445310")[0]["state"], "open")
|
|
|
|
def test_mirror_is_readable_by_old_tools(self):
|
|
self.h.send("Claude", ["DeepSeek"], "line one\n[2026-10-02 10:00Z] Codex → All: not a header\nline three",
|
|
kind="request", ack_required=True)
|
|
self.h.send("Claude", ["DeepSeek", "Kimi"], "to two people")
|
|
lines = self.mirror.read_text(encoding="utf-8").split("\n")
|
|
headers = [l for l in lines if LEGACY_LINE.match(l.strip())]
|
|
self.assertEqual(len(headers), 2)
|
|
self.assertIn("Claude → All:", headers[1])
|
|
self.assertIn("to DeepSeek,Kimi", headers[1])
|
|
if (TOOLS / "chat.py").exists():
|
|
out = subprocess.run([sys.executable, "-B", "-c",
|
|
"import sys; sys.path.insert(0, r'%s'); import chat, chat_common; "
|
|
"chat_common.CHAT = __import__('pathlib').Path(r'%s'); chat.CHAT = chat_common.CHAT; "
|
|
"chat.tail(5)" % (TOOLS, self.mirror)], capture_output=True, text=True,
|
|
encoding="utf-8")
|
|
self.assertIn("line three", out.stdout)
|
|
|
|
|
|
class Presence(HarborCase):
|
|
def test_doing_shows_in_briefs_and_digest(self):
|
|
self.h.doing("DeepSeek", "drafting 0x529350 AudioManagerTick")
|
|
self.assertIn("DeepSeek: drafting 0x529350", self.h.brief("Kimi"))
|
|
self.assertNotIn("Team now", self.h.brief("DeepSeek").split("DeepSeek: drafting")[0][-1:])
|
|
with self.assertRaises(HarborError):
|
|
self.h.doing("User", "x")
|
|
d = self.h.digest("1h")
|
|
self.assertEqual(next(a for a in d["agents"] if a["name"] == "DeepSeek")["doing"],
|
|
"drafting 0x529350 AudioManagerTick")
|
|
self.h.doing("DeepSeek", "")
|
|
self.assertIsNone(next(a for a in self.h.agents() if a["name"] == "DeepSeek")["doing"])
|
|
|
|
def test_wait_returns_on_message_stop_or_timeout(self):
|
|
self.h.clock = __import__("time").time
|
|
self.assertTrue(self.h.wait("Kimi", timeout=0.3, poll=0.05)["timed_out"])
|
|
def later():
|
|
time.sleep(0.4)
|
|
sender = Harbor(self.project)
|
|
sender.send("Claude", ["Kimi"], "your turn")
|
|
sender.close()
|
|
threading.Thread(target=later).start()
|
|
started = time.monotonic()
|
|
r = self.h.wait("Kimi", timeout=10, poll=0.05)
|
|
self.assertLess(time.monotonic() - started, 5)
|
|
self.assertEqual([m["body"] for m in r["messages"]], ["your turn"])
|
|
self.h.set_flag("stop:Kimi", "on", "Claude", "cli")
|
|
self.h.mark_read("Kimi")
|
|
self.assertEqual(self.h.wait("Kimi", timeout=10, poll=0.05)["stopped"], ["stop:Kimi"])
|
|
|
|
def test_digest_and_attention(self):
|
|
self.h.send("Codex", ["Claude"], "a")
|
|
self.h.send("User", ["Codex"], "status please", origin="web")
|
|
item = self.h.submit("DeepSeek", "thing", scope="fn:0x445310", commit="abc")
|
|
self.h.transition("Claude", item["id"], "in_review")
|
|
self.h.send("Claude", ["Kimi"], "please confirm", ack_required=True)
|
|
self.h.touch("OpenCode")
|
|
d = self.h.digest("1h")
|
|
codex = next(a for a in d["agents"] if a["name"] == "Codex")
|
|
self.assertEqual((codex["messages"], codex["threads"]), (1, 1))
|
|
self.assertEqual([e["to_state"] for e in d["item_events"]], ["submitted", "in_review"])
|
|
self.assertEqual(self.h.digest("1h")["to_human"], [])
|
|
self.h.send("Codex", ["User"], "here is the status")
|
|
self.assertEqual([m["body"] for m in self.h.digest("1h")["to_human"]], ["here is the status"])
|
|
self.assertEqual(self.h.attention(), [])
|
|
self.clock.t += 3 * 3600
|
|
kinds = sorted(a["kind"] for a in self.h.attention())
|
|
self.assertEqual(kinds, ["ack_overdue", "agent_quiet", "agent_quiet", "agent_quiet", "agent_quiet", "item_stale"])
|
|
self.h.set_flag("stop:all", "on", "Claude", "cli")
|
|
self.assertNotIn("agent_quiet", [a["kind"] for a in self.h.attention()])
|
|
with self.assertRaises(HarborError):
|
|
self.h.digest("yesterday-ish")
|
|
|
|
|
|
class Claims(HarborCase):
|
|
def test_claims_conflicts_ttl_and_bands(self):
|
|
self.h.claim("Claude", "range:0x440000-0x47FFFF", kind="band", on_behalf_of="DeepSeek")
|
|
self.h.claim("Claude", "range:0x480000-0x4FFFFF", kind="band", on_behalf_of="OpenCode")
|
|
with self.assertRaises(HarborError):
|
|
self.h.claim("Kimi", "range:0x400000-0x401000", kind="band")
|
|
d = self.h.claim("DeepSeek", "fn:0x445310", kind="draft")
|
|
with self.assertRaises(HarborError):
|
|
self.h.claim("OpenCode", "0x445310", kind="draft")
|
|
with self.assertRaises(HarborError):
|
|
self.h.claim("OpenCode", "fn:0x450000", kind="draft")
|
|
self.assertTrue(self.h.claim("OpenCode", "fn:0x490000", kind="draft")["id"])
|
|
self.assertTrue(self.h.claim("Kimi", "fn:0x445310", kind="review")["id"])
|
|
renewed = self.h.claim("DeepSeek", "fn:0x445310", kind="draft", ttl_hours=1)
|
|
self.assertEqual((renewed["renewed"], renewed["id"]), (True, d["id"]))
|
|
self.clock.t += 2 * 3600
|
|
self.assertEqual([c for c in self.h.active_claims(kind="draft") if c["scope"] == "fn:0x445310"], [])
|
|
over = self.h.claim("Claude", "fn:0x450010", kind="draft", override=True)
|
|
self.assertEqual(len(over["conflicts"]), 1)
|
|
self.assertEqual(self.h.release("Claude", scope="fn:0x450010"), [over["id"]])
|
|
self.assertTrue(self.h.claim("Codex", "task:write the README")["id"])
|
|
with self.assertRaises(HarborError):
|
|
self.h.claim("Kimi", "task:Write the README")
|
|
|
|
def test_scope_parsing(self):
|
|
self.assertEqual(parse_scope("0x445310")[0], "fn:0x445310")
|
|
self.assertEqual(parse_scope("range:0x440000-0x47ffff")[1:], (0x440000, 0x47FFFF))
|
|
with self.assertRaises(HarborError):
|
|
parse_scope("somewhere")
|
|
|
|
|
|
class Review(HarborCase):
|
|
def test_review_item_lifecycle(self):
|
|
item = self.h.submit("DeepSeek", "RouteCellCost readable", scope="fn:0x445310", commit="abc1234",
|
|
branch="deepseek/decomp")
|
|
self.assertEqual([m["kind"] for m in self.h.inbox("Claude")], ["request"])
|
|
with self.assertRaises(HarborError):
|
|
self.h.transition("DeepSeek", item["id"], "approved")
|
|
self.h.transition("Claude", item["id"], "in_review")
|
|
self.h.transition("Claude", item["id"], "changes_requested", note="movsx: signed read")
|
|
self.assertEqual(self.h.inbox("DeepSeek")[-1]["kind"], "request")
|
|
self.h.transition("DeepSeek", item["id"], "submitted", commit="def5678")
|
|
self.h.transition("Kimi", item["id"], "approved")
|
|
with self.assertRaises(HarborError):
|
|
self.h.transition("Kimi", item["id"], "landed", commit="x")
|
|
with self.assertRaises(HarborError):
|
|
self.h.transition("Claude", item["id"], "landed")
|
|
done = self.h.transition("Claude", item["id"], "landed", commit="b4b44f0")
|
|
self.assertEqual((done["state"], done["commit_hash"]), ("landed", "def5678"))
|
|
with self.assertRaises(HarborError):
|
|
self.h.transition("Claude", item["id"], "held")
|
|
self.assertEqual([e["to_state"] for e in self.h.item(item["id"])[1]],
|
|
["submitted", "in_review", "changes_requested", "submitted", "approved", "landed"])
|
|
|
|
|
|
class Control(HarborCase):
|
|
def test_stop_and_resume_rules(self):
|
|
with self.assertRaises(HarborError):
|
|
self.h.set_flag("stop:all", "on", "User", "cli")
|
|
self.h.set_flag("stop:all", "on", "User", "web", reason="lets stop for now")
|
|
self.assertTrue(self.h.stopped("DeepSeek"))
|
|
self.assertIn("STOP", self.h.brief("DeepSeek"))
|
|
with self.assertRaises(HarborError):
|
|
self.h.set_flag("stop:all", "off", "Codex", "cli")
|
|
agent_msg = self.h.send("Kimi", "All", "resume please")
|
|
with self.assertRaises(HarborError):
|
|
self.h.set_flag("stop:all", "off", "Codex", "cli", relay_of=agent_msg["id"])
|
|
with self.assertRaises(HarborError):
|
|
self.h.set_flag("stop:all", "off", "Claude", "cli") # the user set it, not Claude
|
|
user_msg = self.h.send("User", "All", "okay resume everyone", origin="web")
|
|
with self.assertRaises(HarborError):
|
|
self.h.set_flag("stop:all", "off", "Codex", "cli", relay_of=user_msg["id"]) # only the human resumes
|
|
self.h.set_flag("stop:all", "off", "User", "web")
|
|
self.assertFalse(self.h.stopped("DeepSeek"))
|
|
self.h.set_flag("stop:Kimi", "on", "Kimi", "cli", reason="out of allowance")
|
|
with self.assertRaises(HarborError):
|
|
self.h.set_flag("stop:Kimi", "off", "Kimi", "cli")
|
|
with self.assertRaises(HarborError):
|
|
self.h.set_flag("stop:DeepSeek", "on", "Kimi", "cli")
|
|
self.h.set_flag("stop:Codex", "on", "Claude", "cli", reason="mirror the user's stop")
|
|
self.h.set_flag("stop:Codex", "off", "Claude", "cli", reason="set by mistake") # own stop, untouched
|
|
self.h.set_flag("stop:Codex", "on", "Claude", "cli")
|
|
self.h.set_flag("stop:Codex", "on", "User", "web")
|
|
with self.assertRaises(HarborError):
|
|
self.h.set_flag("stop:Codex", "off", "Claude", "cli") # the user has confirmed it
|
|
|
|
def test_brief_lists_what_needs_attention(self):
|
|
self.h.send("Claude", ["DeepSeek"], "please look", ack_required=True, importance="urgent")
|
|
self.h.claim("DeepSeek", "fn:0x445310", kind="draft", note="writing")
|
|
self.h.submit("DeepSeek", "thing", scope="fn:0x445310", commit="abc")
|
|
text = self.h.brief("DeepSeek")
|
|
for part in ("Unread: 1 (1 urgent)", "Waiting for your acknowledgement", "Your claims", "Your review items"):
|
|
self.assertIn(part, text)
|
|
self.assertIn("Review queue for you: 1 open", self.h.brief("Claude"))
|
|
self.assertNotIn("Review queue for you", self.h.brief("Codex"))
|
|
|
|
def test_legacy_import_is_idempotent_and_read(self):
|
|
legacy = self.dir / "old-chat.md"
|
|
legacy.write_text("# chat\n\n---\n\n[2026-10-01 13:32Z] User → All: rundown please\n\n"
|
|
"[2026-10-01 13:40Z] Codex → User: here it is\nsecond line\n\n"
|
|
"[2026-10-01 13:40Z] Codex → User: here it is\nsecond line\n\n"
|
|
"[2026-10-02 08:00Z] Kimi → Codex: next day\n", encoding="utf-8")
|
|
self.assertEqual(self.h.import_legacy(legacy), {"parsed": 4, "added": 4})
|
|
self.assertEqual(self.h.import_legacy(legacy)["added"], 0)
|
|
self.assertEqual(self.h.inbox("Codex"), [])
|
|
self.assertEqual(sorted(t["topic"] for t in self.h.threads(include_legacy=True)),
|
|
["legacy:2026-10-01", "legacy:2026-10-02"])
|
|
|
|
def test_backup_keeps_newest(self):
|
|
self.h.send("Claude", ["Kimi"], "keep me")
|
|
for i in range(3):
|
|
self.clock.t += 60
|
|
self.h.backup(keep=2)
|
|
copies = sorted(self.project.backups.glob("harbor-*.db"))
|
|
self.assertEqual(len(copies), 2)
|
|
import sqlite3
|
|
with sqlite3.connect(str(copies[-1])) as c:
|
|
self.assertEqual(c.execute("SELECT body FROM messages").fetchone()[0], "keep me")
|
|
|
|
|
|
class Projects(unittest.TestCase):
|
|
def setUp(self):
|
|
self.dir = temp_dir()
|
|
|
|
def tearDown(self):
|
|
shutil.rmtree(self.dir, ignore_errors=True)
|
|
|
|
def test_roster_comes_from_the_project(self):
|
|
p = projects.project_from_dict({"name": "garden", "human": "Kai", "coordinators": ["Lead"],
|
|
"database": str(self.dir / "g.db"),
|
|
"agents": [{"name": "Lead"}, {"name": "Helper"}]})
|
|
h = Harbor(p)
|
|
self.assertEqual(h.agent_names(), ["Lead", "Helper", "Kai"])
|
|
self.assertEqual(sorted(h.send("Lead", "All", "hi")["targets"]), ["Helper", "Kai"])
|
|
with self.assertRaises(HarborError):
|
|
h.send("Kai", "All", "pretend", origin="cli")
|
|
with self.assertRaises(HarborError):
|
|
h.send("Lead", ["Codex"], "not on this team")
|
|
item = h.submit("Helper", "first task") # reviewers default to Lead
|
|
self.assertEqual(h.inbox("Lead")[-1]["refs"]["item"], item["id"])
|
|
h.close()
|
|
smaller = projects.project_from_dict({"name": "garden", "human": "Kai", "database": str(self.dir / "g.db"),
|
|
"agents": [{"name": "Lead"}]})
|
|
h = Harbor(smaller)
|
|
self.assertEqual([a["name"] for a in h.agents()], ["Lead", "Kai"]) # Helper keeps history, inactive
|
|
h.close()
|
|
other = projects.project_from_dict({"name": "other", "database": str(self.dir / "g.db"),
|
|
"agents": [{"name": "Lead"}]})
|
|
with self.assertRaises(HarborError):
|
|
Harbor(other) # the database belongs to garden
|
|
|
|
def test_validation(self):
|
|
for bad in ({"name": "Bad Name"}, {"name": "x", "agents": [{"name": "All"}]},
|
|
{"name": "x", "agents": [{"name": "A"}, {"name": "A"}]},
|
|
{"name": "x", "coordinators": ["Ghost"], "agents": [{"name": "A"}]}):
|
|
with self.assertRaises(projects.ProjectError):
|
|
projects.project_from_dict(bad, home=self.dir)
|
|
|
|
def test_resolution_order_and_identities(self):
|
|
made = make_home(self.dir, names=("first", "second"))
|
|
self.assertEqual(projects.resolve(home=self.dir, cwd=self.dir).name, "first") # home default
|
|
self.assertEqual(projects.resolve("second", home=self.dir).name, "second") # explicit
|
|
tree = self.dir / "repo" / "worktree-deepseek" / "src"
|
|
tree.mkdir(parents=True)
|
|
(self.dir / "repo" / ".harbor.toml").write_text('project = "second"\n', encoding="utf-8")
|
|
found = projects.resolve(home=self.dir, cwd=tree)
|
|
self.assertEqual(found.name, "second") # nearest marker
|
|
self.assertEqual(found.identity_for(tree), "DeepSeek")
|
|
self.assertIsNone(found.identity_for(self.dir))
|
|
with self.assertRaises(projects.ProjectError):
|
|
projects.resolve("missing", home=self.dir)
|
|
self.assertEqual(made["first"].ai_names, ["Claude", "Codex", "DeepSeek", "OpenCode", "Kimi"])
|
|
|
|
|
|
class Concurrency(unittest.TestCase):
|
|
def test_many_processes_write_without_loss(self):
|
|
d = temp_dir()
|
|
project = make_home(d)["test"]
|
|
Harbor(project).close()
|
|
root = Path(__file__).resolve().parents[1]
|
|
script = ("import sys; sys.path.insert(0, r'%s'); from harbor import projects; from harbor.core import Harbor; "
|
|
"h = Harbor(projects.load_named('test', home=r'%s')); "
|
|
"[h.send(sys.argv[1], ['Claude'], f'{sys.argv[1]} message {i}') for i in range(40)]" % (root, d))
|
|
senders = ["Codex", "DeepSeek", "OpenCode", "Kimi", "Codex", "DeepSeek"]
|
|
procs = [subprocess.Popen([sys.executable, "-B", "-c", script, s]) for s in senders]
|
|
self.assertEqual([p.wait(timeout=180) for p in procs], [0] * len(senders))
|
|
h = Harbor(project)
|
|
rows = h.conn.execute("SELECT COUNT(*) n, COUNT(DISTINCT id) u FROM messages").fetchone()
|
|
self.assertEqual((rows["n"], rows["u"]), (240, 240))
|
|
headers = [l for l in project.mirror.read_text(encoding="utf-8").split("\n") if LEGACY_LINE.match(l.strip())]
|
|
self.assertEqual(len(headers), 240)
|
|
self.assertEqual(h.unread_count("Claude")[0], 240)
|
|
h.close()
|
|
shutil.rmtree(d, ignore_errors=True)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|