@heretek-ai/epistemic-swarm 0.7.21 → 0.7.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +1 -1
- package/bin/cli.js +3 -2
- package/extensions/pi/index.js +3 -2
- package/package.json +5 -1
- package/plugins/antigravity/plugin.json +1 -1
- package/plugins/factory/skills/factory/SKILL.md +1 -1
- package/plugins/opencode/analysis-bridge.js +566 -0
- package/plugins/opencode/config-io.js +598 -31
- package/plugins/opencode/dep-health.js +1039 -0
- package/plugins/opencode/hook-bus.js +434 -0
- package/plugins/opencode/index.js +252 -19
- package/plugins/opencode/tui.js +230 -17
- package/plugins/opencode/weight-baseline.json +16 -0
- package/plugins/opencode/weight-signals.js +769 -0
- package/plugins/research-cache/skills/research_cache/hasher.py +33 -8
- package/runner/__pycache__/__init__.cpython-311.pyc +0 -0
- package/runner/__pycache__/auctioneer.cpython-311.pyc +0 -0
- package/runner/__pycache__/auditor_engine.cpython-311.pyc +0 -0
- package/runner/__pycache__/claim_store.cpython-311.pyc +0 -0
- package/runner/__pycache__/claim_witness.cpython-311.pyc +0 -0
- package/runner/__pycache__/darkharvest_claims.cpython-311.pyc +0 -0
- package/runner/__pycache__/errors.cpython-311.pyc +0 -0
- package/runner/__pycache__/living_dossiers.cpython-311.pyc +0 -0
- package/runner/__pycache__/mcp_protocol.cpython-311.pyc +0 -0
- package/runner/__pycache__/mcp_server.cpython-311.pyc +0 -0
- package/runner/__pycache__/path_safety.cpython-311.pyc +0 -0
- package/runner/__pycache__/pcrb.cpython-311.pyc +0 -0
- package/runner/__pycache__/pcrb_verify.cpython-311.pyc +0 -0
- package/runner/__pycache__/preflight.cpython-311.pyc +0 -0
- package/runner/__pycache__/refinement.cpython-311.pyc +0 -0
- package/runner/__pycache__/research_swarm.cpython-311.pyc +0 -0
- package/runner/__pycache__/retrieval_log.cpython-311.pyc +0 -0
- package/runner/__pycache__/schema_validate.cpython-311.pyc +0 -0
- package/runner/__pycache__/schemas.cpython-311.pyc +0 -0
- package/runner/__pycache__/state_machine.cpython-311.pyc +0 -0
- package/runner/mcp_server.py +249 -81
- package/runner/refinement.py +5 -0
- package/runner/schema_validate.py +63 -1
- package/runner/schemas.py +9 -4
- package/runner/tests/__pycache__/test_analysis_bridge.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_antigravity_plugin.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_auction_order.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_backends.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_bet1_spike.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_claim_store.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_claim_witness.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_claude_plugin.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_config_schema.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_contract_tooling.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_darkharvest_audit.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_dep_health.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_domain_packs.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_dossier_contract.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_factory.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_fleet_seam.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_frontier_concurrency.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_frontier_mirror_standalone.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_grounding.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_hardening_contract.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_hook_bus.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_living_dossiers.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_manifest_concurrency.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_mcp_server.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_opencode_ux.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_opencode_v2_registrars.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_pcrb.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_phase08_waiver_reopens.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_refinement.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_search_anomaly.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_search_provider.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_search_provider_plugin.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_swarm.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_swarm_config.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_sweep_regressions.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_webcache.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_weight_signals.cpython-311.pyc +0 -0
- package/runner/tests/fixtures/config_validation_cases.json +60 -0
- package/runner/tests/test_analysis_bridge.py +356 -0
- package/runner/tests/test_config_schema.py +119 -15
- package/runner/tests/test_dep_health.py +484 -0
- package/runner/tests/test_hardening_contract.py +391 -0
- package/runner/tests/test_hook_bus.py +802 -0
- package/runner/tests/test_opencode_ux.py +232 -0
- package/runner/tests/test_opencode_v2_registrars.py +548 -16
- package/runner/tests/test_phase08_waiver_reopens.py +539 -0
- package/runner/tests/test_weight_signals.py +361 -0
- package/schemas/config.schema.json +4 -3
- package/scripts/__pycache__/bet1_advisory_spike.cpython-311.pyc +0 -0
- package/scripts/__pycache__/build_adapters.cpython-311.pyc +0 -0
- package/scripts/__pycache__/check_docs.cpython-311.pyc +0 -0
- package/scripts/__pycache__/divergence_experiment.cpython-311.pyc +0 -0
- package/scripts/__pycache__/gen_schemas.cpython-311.pyc +0 -0
- package/skills/epistemic_search/scripts/__pycache__/search.cpython-311.pyc +0 -0
- package/skills/factory/SKILL.md +1 -1
- package/skills/grilling/__pycache__/__init__.cpython-311.pyc +0 -0
- package/skills/grilling/__pycache__/socratic_tree.cpython-311.pyc +0 -0
- package/skills/research_cache/__pycache__/__init__.cpython-311.pyc +0 -0
- package/skills/research_cache/__pycache__/hasher.cpython-311.pyc +0 -0
- package/skills/research_cache/hasher.py +33 -8
- package/skills/swarm_config/SKILL.md +6 -1
- package/skills/swarm_config/__pycache__/__init__.cpython-311.pyc +0 -0
- package/skills/swarm_config/__pycache__/configure.cpython-311.pyc +0 -0
- package/skills/swarm_config/configure.py +254 -17
|
@@ -2202,5 +2202,237 @@ class TestUiPrefsBoundary(unittest.TestCase):
|
|
|
2202
2202
|
self.assertEqual(data["bare"], {})
|
|
2203
2203
|
|
|
2204
2204
|
|
|
2205
|
+
# ---------------------------------------------------------------------------
|
|
2206
|
+
# Phase 07 (tui-polish, H8): R4 advisory wording polish + keyed toast dedup.
|
|
2207
|
+
# Mock-host tests per T1-T2. Fresh node process per test (run_node), so the
|
|
2208
|
+
# module-level dedup window never leaks between tests.
|
|
2209
|
+
# ---------------------------------------------------------------------------
|
|
2210
|
+
|
|
2211
|
+
PHASE07_HARNESS = """
|
|
2212
|
+
import {
|
|
2213
|
+
openSettingsWizard, warnOnce, _resetAdvisoryDedup, _advisorySuppressed,
|
|
2214
|
+
ADVISORY_DEDUP_MS,
|
|
2215
|
+
} from "./plugins/opencode/tui.js";
|
|
2216
|
+
import fs from "node:fs";
|
|
2217
|
+
import path from "node:path";
|
|
2218
|
+
function mockHost(queues, opts = {}) {
|
|
2219
|
+
const toasts = [];
|
|
2220
|
+
const pop = (arr) => ((!arr || arr.length === 0) ? null : arr.shift());
|
|
2221
|
+
const host = {
|
|
2222
|
+
directory: opts.root || null,
|
|
2223
|
+
ui: { toast: { show: (t) => toasts.push(t) } },
|
|
2224
|
+
};
|
|
2225
|
+
if (!opts.noDialog) {
|
|
2226
|
+
host.ui.dialog = {
|
|
2227
|
+
select: async (a) => pop(queues.select),
|
|
2228
|
+
prompt: async (a) => pop(queues.prompt),
|
|
2229
|
+
confirm: async (a) => pop(queues.confirm),
|
|
2230
|
+
};
|
|
2231
|
+
}
|
|
2232
|
+
return { host, toasts };
|
|
2233
|
+
}
|
|
2234
|
+
"""
|
|
2235
|
+
|
|
2236
|
+
|
|
2237
|
+
class TestPhase07AdvisoryWording(unittest.TestCase):
|
|
2238
|
+
"""T1: advisories accurate without self-contradiction on all paths."""
|
|
2239
|
+
|
|
2240
|
+
def test_env_advisory_accurate_and_write_proceeds(self):
|
|
2241
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
2242
|
+
res = run_node(
|
|
2243
|
+
PHASE07_HARNESS
|
|
2244
|
+
+ f"""
|
|
2245
|
+
const root = {tmp!r};
|
|
2246
|
+
const m = mockHost({{select: ["searxng_url", "__iumbtems_save__"], prompt: ["http://new:8080"], confirm: [true]}}, {{root}});
|
|
2247
|
+
m.host.directory = root;
|
|
2248
|
+
const r = await openSettingsWizard(m.host, root, {{env: {{SEARXNG_URL: "http://env:1"}}}});
|
|
2249
|
+
const saved = JSON.parse(fs.readFileSync(path.join(root, ".research", "config.json"), "utf-8"));
|
|
2250
|
+
const text = m.toasts.map((t) => JSON.stringify(t)).join("\\n");
|
|
2251
|
+
console.log(JSON.stringify({{written: r.written, saved: saved.searxng_url, text}}));
|
|
2252
|
+
"""
|
|
2253
|
+
)
|
|
2254
|
+
self.assertEqual(res.returncode, 0, f"env wording failed: {res.stderr}")
|
|
2255
|
+
data = last_json_object(res.stdout)
|
|
2256
|
+
self.assertTrue(data["written"])
|
|
2257
|
+
self.assertEqual(data["saved"], "http://new:8080")
|
|
2258
|
+
# Accuracy phrases retained (blast-radius: existing R4 test regex).
|
|
2259
|
+
self.assertRegex(
|
|
2260
|
+
data["text"],
|
|
2261
|
+
r"(?i)environment shadows.*no runtime effect until.*unset",
|
|
2262
|
+
)
|
|
2263
|
+
self.assertIn("cancel writes nothing", data["text"])
|
|
2264
|
+
# No self-contradiction: the file value is "not effective", never
|
|
2265
|
+
# presented as effective; no display-only claim on a saved value.
|
|
2266
|
+
env_toasts = [
|
|
2267
|
+
line for line in data["text"].split("\n") if "nvironment" in line
|
|
2268
|
+
]
|
|
2269
|
+
self.assertTrue(env_toasts)
|
|
2270
|
+
for line in env_toasts:
|
|
2271
|
+
self.assertNotIn("display-only", line)
|
|
2272
|
+
|
|
2273
|
+
def test_deprecated_advisory_generic_and_write_proceeds(self):
|
|
2274
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
2275
|
+
res = run_node(
|
|
2276
|
+
PHASE07_HARNESS
|
|
2277
|
+
+ f"""
|
|
2278
|
+
const root = {tmp!r};
|
|
2279
|
+
const m = mockHost({{select: ["output_dir", "__iumbtems_save__"], prompt: [".renamed"], confirm: [true]}}, {{root}});
|
|
2280
|
+
m.host.directory = root;
|
|
2281
|
+
const r = await openSettingsWizard(m.host, root, {{env: {{}}}});
|
|
2282
|
+
const saved = JSON.parse(fs.readFileSync(path.join(root, ".research", "config.json"), "utf-8"));
|
|
2283
|
+
const text = m.toasts.map((t) => JSON.stringify(t)).join("\\n");
|
|
2284
|
+
console.log(JSON.stringify({{written: r.written, saved: saved.output_dir, text}}));
|
|
2285
|
+
"""
|
|
2286
|
+
)
|
|
2287
|
+
self.assertEqual(
|
|
2288
|
+
res.returncode, 0, f"deprecated wording failed: {res.stderr}"
|
|
2289
|
+
)
|
|
2290
|
+
data = last_json_object(res.stdout)
|
|
2291
|
+
self.assertTrue(data["written"])
|
|
2292
|
+
self.assertEqual(data["saved"], ".renamed")
|
|
2293
|
+
self.assertIn("eprecat", data["text"])
|
|
2294
|
+
self.assertIn("no runtime effect", data["text"])
|
|
2295
|
+
self.assertIn("cancel writes nothing", data["text"])
|
|
2296
|
+
# The old wording was output_dir-specific ("never selects the
|
|
2297
|
+
# workspace") and self-contradictory ("display-only" + "still
|
|
2298
|
+
# saved"): both must be gone from the deprecated advisory.
|
|
2299
|
+
dep_toasts = [
|
|
2300
|
+
line for line in data["text"].split("\n") if "eprecat" in line
|
|
2301
|
+
]
|
|
2302
|
+
self.assertTrue(dep_toasts)
|
|
2303
|
+
for line in dep_toasts:
|
|
2304
|
+
self.assertNotIn("display-only", line)
|
|
2305
|
+
self.assertNotIn("never selects the workspace", line)
|
|
2306
|
+
|
|
2307
|
+
def test_degraded_and_malformed_notices_retained_direct_fs(self):
|
|
2308
|
+
# Degraded fallback + malformed advisories survive the polish.
|
|
2309
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
2310
|
+
research = Path(tmp) / ".research"
|
|
2311
|
+
research.mkdir()
|
|
2312
|
+
garbage = "{oops not json"
|
|
2313
|
+
research.joinpath("config.json").write_text(garbage, encoding="utf-8")
|
|
2314
|
+
res = run_node(
|
|
2315
|
+
PHASE07_HARNESS
|
|
2316
|
+
+ f"""
|
|
2317
|
+
const root = {tmp!r};
|
|
2318
|
+
const m = mockHost({{select: ["__iumbtems_cancel__"]}}, {{root}});
|
|
2319
|
+
m.host.directory = root;
|
|
2320
|
+
const r = await openSettingsWizard(m.host, root, {{env: {{}}}});
|
|
2321
|
+
const text = m.toasts.map((t) => JSON.stringify(t)).join("\\n");
|
|
2322
|
+
console.log(JSON.stringify({{cancelled: r.cancelled, written: r.written, text}}));
|
|
2323
|
+
"""
|
|
2324
|
+
)
|
|
2325
|
+
self.assertEqual(res.returncode, 0, f"degraded path failed: {res.stderr}")
|
|
2326
|
+
data = last_json_object(res.stdout)
|
|
2327
|
+
self.assertTrue(data["cancelled"])
|
|
2328
|
+
self.assertFalse(data["written"])
|
|
2329
|
+
self.assertIn("degraded path", data["text"])
|
|
2330
|
+
self.assertIn("Could not parse", data["text"])
|
|
2331
|
+
|
|
2332
|
+
|
|
2333
|
+
class TestPhase07ToastDedup(unittest.TestCase):
|
|
2334
|
+
"""T2: keyed window collapses repeats, re-shows after expiry, never
|
|
2335
|
+
swallows errors/degraded; durations preserved."""
|
|
2336
|
+
|
|
2337
|
+
def test_warnonce_collapse_expiry_and_independence(self):
|
|
2338
|
+
res = run_node(
|
|
2339
|
+
PHASE07_HARNESS
|
|
2340
|
+
+ """
|
|
2341
|
+
_resetAdvisoryDedup();
|
|
2342
|
+
const toasts = [];
|
|
2343
|
+
const host = { ui: { toast: { show: (t) => toasts.push(t) } } };
|
|
2344
|
+
const t0 = 1000000;
|
|
2345
|
+
const ttl = 5000;
|
|
2346
|
+
const first = warnOnce(host, "env:searxng_url", "Environment override", "msg-a", { ttlMs: ttl, now: t0 });
|
|
2347
|
+
const repeat = warnOnce(host, "env:searxng_url", "Environment override", "msg-a", { ttlMs: ttl, now: t0 + 1000 });
|
|
2348
|
+
const repeat2 = warnOnce(host, "env:searxng_url", "Environment override", "msg-a", { ttlMs: ttl, now: t0 + 2000 });
|
|
2349
|
+
const other = warnOnce(host, "deprecated:output_dir", "Deprecated field", "msg-b", { ttlMs: ttl, now: t0 + 2000 });
|
|
2350
|
+
const pending = _advisorySuppressed("env:searxng_url");
|
|
2351
|
+
const reshow = warnOnce(host, "env:searxng_url", "Environment override", "msg-a", { ttlMs: ttl, now: t0 + ttl + 1 });
|
|
2352
|
+
console.log(JSON.stringify({
|
|
2353
|
+
first, repeat, repeat2, other, pending, reshow,
|
|
2354
|
+
shown: toasts.length,
|
|
2355
|
+
durations: toasts.map((t) => t.duration),
|
|
2356
|
+
variants: toasts.map((t) => t.variant),
|
|
2357
|
+
lastText: toasts[toasts.length - 1].message,
|
|
2358
|
+
windowMs: ADVISORY_DEDUP_MS,
|
|
2359
|
+
}));
|
|
2360
|
+
"""
|
|
2361
|
+
)
|
|
2362
|
+
self.assertEqual(res.returncode, 0, f"warnOnce failed: {res.stderr}")
|
|
2363
|
+
data = last_json_object(res.stdout)
|
|
2364
|
+
self.assertTrue(data["first"])
|
|
2365
|
+
self.assertFalse(data["repeat"])
|
|
2366
|
+
self.assertFalse(data["repeat2"])
|
|
2367
|
+
# Distinct keys are independent.
|
|
2368
|
+
self.assertTrue(data["other"])
|
|
2369
|
+
# Repeats within the window collapse with a count.
|
|
2370
|
+
self.assertEqual(data["pending"], 2)
|
|
2371
|
+
self.assertEqual(data["shown"], 3) # first + other + reshow
|
|
2372
|
+
# Expiry re-shows, annotated with the collapsed count (2 + this one).
|
|
2373
|
+
self.assertTrue(data["reshow"])
|
|
2374
|
+
self.assertIn("3×", data["lastText"])
|
|
2375
|
+
# Durations preserved (wizard 6000), variant untouched.
|
|
2376
|
+
self.assertTrue(all(d == 6000 for d in data["durations"]))
|
|
2377
|
+
self.assertTrue(all(v == "info" for v in data["variants"]))
|
|
2378
|
+
self.assertGreater(data["windowMs"], 0)
|
|
2379
|
+
|
|
2380
|
+
def test_wizard_repeats_collapse_but_errors_and_degraded_never_swallowed(self):
|
|
2381
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
2382
|
+
res = run_node(
|
|
2383
|
+
PHASE07_HARNESS
|
|
2384
|
+
+ f"""
|
|
2385
|
+
const root = {tmp!r};
|
|
2386
|
+
_resetAdvisoryDedup();
|
|
2387
|
+
const m = mockHost({{
|
|
2388
|
+
select: ["searxng_url", "searxng_url", "__iumbtems_save__"],
|
|
2389
|
+
prompt: ["http://a:1", "http://b:2"],
|
|
2390
|
+
confirm: [true],
|
|
2391
|
+
}}, {{root}});
|
|
2392
|
+
m.host.directory = root;
|
|
2393
|
+
const r = await openSettingsWizard(m.host, root, {{env: {{SEARXNG_URL: "http://env:9"}}}});
|
|
2394
|
+
const saved = JSON.parse(fs.readFileSync(path.join(root, ".research", "config.json"), "utf-8"));
|
|
2395
|
+
const text = m.toasts.map((t) => JSON.stringify(t)).join("\\n");
|
|
2396
|
+
const advisory = m.toasts.filter((t) => t.title === "Environment override").length;
|
|
2397
|
+
const degraded = m.toasts.filter((t) => JSON.stringify(t).includes("degraded path")).length;
|
|
2398
|
+
console.log(JSON.stringify({{written: r.written, saved: saved.searxng_url, advisory, degraded, text}}));
|
|
2399
|
+
"""
|
|
2400
|
+
)
|
|
2401
|
+
self.assertEqual(res.returncode, 0, f"wizard dedup failed: {res.stderr}")
|
|
2402
|
+
data = last_json_object(res.stdout)
|
|
2403
|
+
self.assertTrue(data["written"])
|
|
2404
|
+
self.assertEqual(data["saved"], "http://b:2")
|
|
2405
|
+
# Two edits of the same env-shadowed key -> one advisory toast.
|
|
2406
|
+
self.assertEqual(data["advisory"], 1)
|
|
2407
|
+
# The degraded-path toast is never deduped away.
|
|
2408
|
+
self.assertEqual(data["degraded"], 1)
|
|
2409
|
+
|
|
2410
|
+
def test_wizard_error_toasts_never_deduped(self):
|
|
2411
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
2412
|
+
res = run_node(
|
|
2413
|
+
PHASE07_HARNESS
|
|
2414
|
+
+ f"""
|
|
2415
|
+
const root = {tmp!r};
|
|
2416
|
+
_resetAdvisoryDedup();
|
|
2417
|
+
const m = mockHost({{
|
|
2418
|
+
select: ["max_iterations", "__iumbtems_save__"],
|
|
2419
|
+
prompt: ["99", "98", "3"],
|
|
2420
|
+
confirm: [true],
|
|
2421
|
+
}}, {{root}});
|
|
2422
|
+
m.host.directory = root;
|
|
2423
|
+
const r = await openSettingsWizard(m.host, root, {{env: {{}}}});
|
|
2424
|
+
const saved = JSON.parse(fs.readFileSync(path.join(root, ".research", "config.json"), "utf-8"));
|
|
2425
|
+
const invalid = m.toasts.filter((t) => t.title === "Invalid value").length;
|
|
2426
|
+
console.log(JSON.stringify({{written: r.written, depth: saved.max_iterations, invalid}}));
|
|
2427
|
+
"""
|
|
2428
|
+
)
|
|
2429
|
+
self.assertEqual(res.returncode, 0, f"error dedup failed: {res.stderr}")
|
|
2430
|
+
data = last_json_object(res.stdout)
|
|
2431
|
+
self.assertTrue(data["written"])
|
|
2432
|
+
self.assertEqual(data["depth"], 3)
|
|
2433
|
+
# Both validation errors surface: error toasts bypass the window.
|
|
2434
|
+
self.assertEqual(data["invalid"], 2)
|
|
2435
|
+
|
|
2436
|
+
|
|
2205
2437
|
if __name__ == "__main__":
|
|
2206
2438
|
unittest.main()
|