Files
codex-app-server/cloud/tests/test_chv_cover100.py

622 lines
26 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""CHV 覆盖收尾轨(feat-chv-cover100)新增测试。
只补本轨实测基线(coverage --source=cloud/chv,跑
test_chv*.py + test_cloud_hypervisor.py:1925 行、缺 80 行、
96%)里仍未覆盖的行,生产代码零改动。每项都是行为断言:
- api_client:无冒号头行跳过、分片到达的体部、不可解析
的状态行、带 payload 的 unix socket 调用、normalize 的
dict/对象两分支(此前只覆 tuple 分支);
- compat215:commonpath 跨盘符 ValueError 回 None;
- config:磁盘 GB 非数字、快照数非数字、ready_interval 为 0
回落 5 秒(旧语义);
- disk:目录统计里文件消失、配额早退、disk_usage 失败与
宿主余量不足、已存在磁盘不可读、拷贝失败、剪枝删文件
失败、快照总量统计的文件大小回落与失败、file:// 快照
文件不存在、总量帽删旧文件失败;
- hardening:state_dir stat 失败、chmod 后仍宽松、socket
死活的空/缺失/异常三态、空 net 配置、非 dict 条目、
egress 空值与不可解析 URL;
- hostkeys:无 runner 时 genisoimage/keygen 走 subprocess
的失败路径、seed/key 私钥 chmod 失败、无 runcmd 时密钥
环境条目仍追加(不丢);
- experimental_pool(单独计分,非生产路径):registry
坏条目计数、非 dict task 落盘、reconcile 跳过 warm/
shutdown 失败/_emit 失败、prewarm 重名跳过与唤醒失败、
acquire _emit 失败、release shutdown 失败。
全部用假 socket/假 runner/假执行器,绝不连真实网络、
不起真实 VM、不改生产行为。
"""
import json
import os
import shutil
import socket
import tempfile
import unittest
from types import SimpleNamespace
from unittest import mock
from cloud.chv import api_client
from cloud.chv import compat215
from cloud.chv import config as chv_config
from cloud.chv import disk as chv_disk
from cloud.chv import experimental_pool
from cloud.chv import hardening
from cloud.chv import hostkeys
from cloud.chv.errors import ChvFailure
from cloud.chv.experimental_pool import WarmPool
class _FakeApiSocket:
"""_unix_socket_api 用的假 socket:按 recv 序列供数据。"""
def __init__(self, chunks):
self._chunks = list(chunks)
self.sent = b""
self.closed = False
def settimeout(self, _timeout):
pass
def connect(self, _path):
pass
def sendall(self, data):
self.sent += data
def recv(self, _size):
if not self._chunks:
return b""
return self._chunks.pop(0)
def close(self):
self.closed = True
class ApiClientCover100Test(unittest.TestCase):
def test_header_line_without_colon_skipped(self):
self.assertEqual(
api_client._parse_content_length(
"HTTP/1.1 200 OK\r\nGARBAGE-LINE\r\nContent-Length: 2"),
2)
def test_unix_api_with_payload_and_chunked_body(self):
# 体部分两片到达:首片随头一起到(体部前缀),次片
# 才补满 Content-Length——_read_body 的追加行必须跑到。
fake = _FakeApiSocket([
b"HTTP/1.1 200 OK\r\nContent-Length: 4\r\n\r\nab",
b"cd",
])
with tempfile.TemporaryDirectory() as tmp:
sock_path = os.path.join(tmp, "chv.sock")
with open(sock_path, "w") as fh:
fh.write("x") # 仅让 exists() 为真,不真建 socket
with mock.patch.object(api_client.socket, "socket",
lambda *a, **k: fake):
status, body = api_client._unix_socket_api(
sock_path, "PUT", "/vm.create",
payload={"name": "vm1"})
self.assertEqual(status, 200)
self.assertEqual(body, "abcd")
self.assertIn(b'"name": "vm1"', fake.sent)
self.assertTrue(fake.closed)
def test_parse_status_non_numeric_is_zero(self):
status, _body = api_client._parse_response(
"HTTP/1.1 OK\r\n\r\n", b"{}")
self.assertEqual(status, 0)
def test_parse_status_missing_token_is_zero(self):
status, _body = api_client._parse_response("HTTP/\r\n\r\n", b"{}")
self.assertEqual(status, 0)
def test_normalize_dict_branches(self):
self.assertEqual(
api_client.normalize_api_result(
{"status": 201, "body": {"a": 1}}),
(201, {"a": 1}))
self.assertEqual(
api_client.normalize_api_result({"status_code": 202}),
(202, {"status_code": 202}))
self.assertEqual(
api_client.normalize_api_result({"body": "x"}),
(0, "x"))
def test_normalize_object_branches(self):
obj = SimpleNamespace(status=404)
status, body = api_client.normalize_api_result(obj)
self.assertEqual((status, body), (404, obj))
obj2 = SimpleNamespace(status_code=503)
status, _body = api_client.normalize_api_result(obj2)
self.assertEqual(status, 503)
obj3 = SimpleNamespace()
status, _body = api_client.normalize_api_result(obj3)
self.assertEqual(status, 0)
class Compat215Cover100Test(unittest.TestCase):
def test_commonpath_value_error_is_none(self):
# 跨盘符(Windows)下 commonpath 抛 ValueError 时
# 目录门必须回 None,而不是把异常漏给调用方。
with tempfile.TemporaryDirectory() as tmp:
with mock.patch.object(
os.path, "commonpath",
side_effect=ValueError("mixing drives")):
self.assertIsNone(compat215.resolve_inside(tmp, "sub"))
class ConfigCover100Test(unittest.TestCase):
def test_disk_gb_non_numeric_rejected(self):
with self.assertRaises(ValueError) as ctx:
chv_config.build_chv_config({}, {"CHV_DISK_GB": "abc"})
self.assertIn("CHV_DISK_GB 必须是数字", str(ctx.exception))
def test_snapshot_count_non_numeric_rejected(self):
with self.assertRaises(ValueError) as ctx:
chv_config.build_chv_config(
{}, {"CHV_SNAPSHOT_MAX_COUNT": "x"})
self.assertIn("CHV_SNAPSHOT_* 必须是数字", str(ctx.exception))
def test_ready_interval_zero_falls_back_to_five(self):
cfg = chv_config.build_chv_config({"ready_interval": 0}, {})
self.assertEqual(cfg.ready_interval, 5)
class DiskCover100Test(unittest.TestCase):
def _write(self, path, data):
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "wb") as fh:
fh.write(data)
def test_dir_total_ignores_vanished_file(self):
with tempfile.TemporaryDirectory() as tmp:
rootfs = os.path.join(tmp, "base.raw")
self._write(rootfs, b"1234")
state_dir = os.path.join(tmp, "state")
victim = os.path.join(state_dir, "gone.raw")
self._write(victim, b"zz")
real_getsize = os.path.getsize
def getsize(path):
if path == victim:
raise OSError("walk 后文件消失")
return real_getsize(path)
with mock.patch.object(os.path, "getsize",
side_effect=getsize):
# 不抛错:消失文件按 0 计,不卡住配额校验。
self.assertIsNone(
chv_disk.check_disk_quota(rootfs, 50.0, state_dir))
def test_quota_skipped_without_disk_gb_or_rootfs(self):
self.assertIsNone(chv_disk.check_disk_quota("", None))
self.assertIsNone(chv_disk.check_disk_quota("anything", None))
self.assertIsNone(chv_disk.check_disk_quota("", 50.0))
def test_quota_disk_usage_failure_is_not_fatal(self):
with tempfile.TemporaryDirectory() as tmp:
rootfs = os.path.join(tmp, "base.raw")
self._write(rootfs, b"1234")
with mock.patch.object(chv_disk.shutil, "disk_usage",
side_effect=OSError("statfs 失败")):
# 宿主余量读不到时不按“不足”误杀,只按已用量
# 与镜像大小校验,本例两者都远小于配额。
self.assertIsNone(
chv_disk.check_disk_quota(rootfs, 50.0, tmp))
def test_quota_host_free_space_shortfall_rejected(self):
with tempfile.TemporaryDirectory() as tmp:
rootfs = os.path.join(tmp, "base.raw")
self._write(rootfs, b"1234")
usage = SimpleNamespace(total=100, used=100, free=0)
with mock.patch.object(chv_disk.shutil, "disk_usage",
return_value=usage):
with self.assertRaises(ChvFailure) as ctx:
chv_disk.check_disk_quota(rootfs, 50.0, tmp)
self.assertEqual(ctx.exception.kind, "disk_quota_exceeded")
def test_provision_existing_disk_unreadable_rejected(self):
with tempfile.TemporaryDirectory() as tmp:
rootfs = os.path.join(tmp, "base.raw")
self._write(rootfs, b"1234")
task_disk = os.path.join(tmp, "vm1", "disk.raw")
self._write(task_disk, b"1234")
with mock.patch.object(os.path, "getsize",
side_effect=OSError("不可读")):
with self.assertRaises(ChvFailure) as ctx:
chv_disk.provision_task_disk(tmp, "vm1", rootfs,
"copy")
self.assertEqual(ctx.exception.kind, "disk_quota_unverified")
def test_provision_copy_failure_rejected(self):
with tempfile.TemporaryDirectory() as tmp:
rootfs = os.path.join(tmp, "base.raw")
self._write(rootfs, b"1234")
with mock.patch.object(chv_disk.shutil, "copyfile",
side_effect=OSError("磁盘满")):
with self.assertRaises(ChvFailure) as ctx:
chv_disk.provision_task_disk(tmp, "vm1", rootfs,
"copy")
self.assertEqual(ctx.exception.kind, "disk_quota_unverified")
def test_prune_delete_failure_is_best_effort(self):
with tempfile.TemporaryDirectory() as tmp:
snap = os.path.join(tmp, "vm1", "old.snap")
self._write(snap, b"old")
chv_disk._save_snapshots(tmp, "vm1", [{
"url": "file://" + snap, "path": snap, "size": 3,
"created_at": 0}])
with mock.patch.object(os, "remove",
side_effect=OSError("删不掉")):
kept, pruned = chv_disk.prune_snapshots(
tmp, "vm1", retention_days=30, now=10 ** 9)
self.assertEqual(kept, [])
self.assertEqual(len(pruned), 1)
self.assertTrue(os.path.exists(snap))
def test_snapshot_limits_uses_file_size_when_size_missing(self):
with tempfile.TemporaryDirectory() as tmp:
snap = os.path.join(tmp, "vm1", "a.snap")
self._write(snap, b"12345")
chv_disk._save_snapshots(tmp, "vm1", [{
"url": "file://" + snap, "path": snap,
"created_at": 0}])
kept = chv_disk.check_snapshot_limits(
tmp, "vm1", max_count=10, max_total_gb=50.0,
retention_days=None)
self.assertEqual(len(kept), 1)
def test_snapshot_limits_ignores_unreadable_file_size(self):
with tempfile.TemporaryDirectory() as tmp:
snap = os.path.join(tmp, "vm1", "a.snap")
self._write(snap, b"12345")
chv_disk._save_snapshots(tmp, "vm1", [{
"url": "file://" + snap, "path": snap,
"created_at": 0}])
with mock.patch.object(os.path, "getsize",
side_effect=OSError("不可读")):
kept = chv_disk.check_snapshot_limits(
tmp, "vm1", max_count=10, max_total_gb=50.0,
retention_days=None)
self.assertEqual(len(kept), 1)
def test_snapshot_limits_total_cap_via_file_size_rejected(self):
# 总量统计走文件大小回落时同样触发总量帽:无 size
# 字段的条目按实际文件大小计入,达到上限即拒绝。
with tempfile.TemporaryDirectory() as tmp:
snap = os.path.join(tmp, "vm1", "a.snap")
self._write(snap, b"12345")
chv_disk._save_snapshots(tmp, "vm1", [{
"url": "file://" + snap, "path": snap,
"created_at": 0}])
with self.assertRaises(ChvFailure) as ctx:
chv_disk.check_snapshot_limits(
tmp, "vm1", max_count=10, max_total_gb=0.0,
retention_days=None)
self.assertEqual(ctx.exception.kind, "snapshot_quota_exceeded")
def test_record_file_snapshot_missing_file_size_none(self):
with tempfile.TemporaryDirectory() as tmp:
entries, _removed = chv_disk.record_snapshot(
tmp, "vm1", "file:///nonexistent-chv-snap", 50.0)
self.assertEqual(len(entries), 1)
self.assertIsNone(entries[0]["size"])
self.assertEqual(entries[0]["path"], "/nonexistent-chv-snap")
def test_record_cap_removal_delete_failure_is_best_effort(self):
with tempfile.TemporaryDirectory() as tmp:
old = os.path.join(tmp, "vm1", "old.snap")
self._write(old, b"old")
chv_disk._save_snapshots(tmp, "vm1", [{
"url": "file://" + old, "path": old, "size": 10 ** 6,
"created_at": 0}])
real_remove = os.remove
def remove(path):
if path == old:
raise OSError("删不掉")
return real_remove(path)
with mock.patch.object(os, "remove", side_effect=remove):
entries, removed = chv_disk.record_snapshot(
tmp, "vm1", "https://example.invalid/new", 0.0)
self.assertEqual(len(removed), 1)
self.assertEqual(removed[0]["path"], old)
self.assertEqual(len(entries), 1)
self.assertTrue(os.path.exists(old))
class HardeningCover100Test(unittest.TestCase):
def test_state_dir_stat_failure_rejected(self):
with tempfile.TemporaryDirectory() as tmp:
state_dir = os.path.join(tmp, "state")
real_stat = os.stat
def stat(path, *args, **kwargs):
if path == state_dir:
raise OSError("stat 失败")
return real_stat(path, *args, **kwargs)
with mock.patch.object(os, "stat", side_effect=stat):
with self.assertRaises(ChvFailure) as ctx:
hardening.ensure_secure_state_dir(state_dir)
self.assertEqual(ctx.exception.kind, "state_dir_insecure")
def test_state_dir_still_loose_after_chmod_rejected(self):
with tempfile.TemporaryDirectory() as tmp:
state_dir = os.path.join(tmp, "state")
os.makedirs(state_dir)
os.chmod(state_dir, 0o755)
with mock.patch.object(os, "chmod",
lambda *a, **k: None):
with self.assertRaises(ChvFailure) as ctx:
hardening.ensure_secure_state_dir(state_dir)
self.assertEqual(ctx.exception.kind, "state_dir_insecure")
def test_socket_is_live_empty_and_missing_is_dead(self):
self.assertFalse(hardening.socket_is_live(""))
self.assertFalse(hardening.socket_is_live(None))
self.assertFalse(
hardening.socket_is_live("/tmp/no-such-chv.sock"))
def test_socket_is_live_socket_error_is_dead(self):
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "dead.sock")
with open(path, "w") as fh:
fh.write("x")
with mock.patch.object(hardening.socket, "socket",
side_effect=OSError("socket 失败")):
self.assertFalse(hardening.socket_is_live(path))
def test_parse_net_entries_empty_is_empty(self):
self.assertEqual(hardening.parse_net_entries(""), [])
self.assertEqual(hardening.parse_net_entries(None), [])
def test_parse_net_entries_non_dict_rejected(self):
with self.assertRaises(ChvFailure) as ctx:
hardening.parse_net_entries('[1, 2]')
self.assertEqual(ctx.exception.kind, "net_config_invalid")
def test_egress_proxy_empty_is_none(self):
self.assertIsNone(hardening.validate_egress_proxy(""))
self.assertIsNone(hardening.validate_egress_proxy(None))
def test_egress_proxy_unparsable_rejected(self):
with mock.patch("urllib.parse.urlparse",
side_effect=ValueError("bad url")):
with self.assertRaises(ChvFailure) as ctx:
hardening.validate_egress_proxy("http://[::1")
self.assertEqual(ctx.exception.kind, "config_invalid")
class HostkeysCover100Test(unittest.TestCase):
def test_build_seed_disk_subprocess_failure(self):
with tempfile.TemporaryDirectory() as tmp:
out = os.path.join(tmp, "seed.iso")
result = SimpleNamespace(returncode=3, stderr="boom")
with mock.patch("subprocess.run", return_value=result):
with self.assertRaises(RuntimeError) as ctx:
hostkeys.build_seed_disk(tmp, out, runner=None)
self.assertIn("genisoimage 失败", str(ctx.exception))
def test_build_seed_disk_chmod_failure(self):
with tempfile.TemporaryDirectory() as tmp:
out = os.path.join(tmp, "seed.iso")
def runner(cmd):
with open(out, "wb") as fh:
fh.write(b"ISO")
return SimpleNamespace(returncode=0)
with mock.patch.object(os, "chmod",
side_effect=OSError("chmod 失败")):
with self.assertRaises(RuntimeError) as ctx:
hostkeys.build_seed_disk(tmp, out, runner=runner)
self.assertIn("chmod 0600 失败", str(ctx.exception))
def test_write_seed_without_runcmd_still_appends_secret(self):
# 防御分支:若 user-data 模板将来没有 runcmd 段,
# 密钥环境条目必须追加而不是丢弃。
with tempfile.TemporaryDirectory() as tmp:
seed_dir = os.path.join(tmp, "seed")
with mock.patch.object(
hostkeys, "build_seed_user_data",
return_value="#cloud-config\nwrite_files:\n"):
hostkeys.write_seed(seed_dir, "vm1", "PRIV", "PUB",
secret_env={"MY_KEY": "a b"})
with open(os.path.join(seed_dir, "user-data")) as fh:
content = fh.read()
self.assertIn(hostkeys.GUEST_ENV_PATH, content)
self.assertIn("MY_KEY=", content)
def test_keygen_subprocess_failure(self):
with tempfile.TemporaryDirectory() as tmp:
result = SimpleNamespace(returncode=1, stderr="boom")
with mock.patch("subprocess.run", return_value=result):
with self.assertRaises(RuntimeError) as ctx:
hostkeys.generate_host_keypair(tmp, "vm1",
runner=None)
self.assertIn("ssh-keygen 失败", str(ctx.exception))
def test_keygen_private_chmod_failure(self):
with tempfile.TemporaryDirectory() as tmp:
paths = hostkeys.host_key_paths(tmp, "vm1")
def runner(cmd):
os.makedirs(os.path.dirname(paths["private"]),
exist_ok=True)
for key in ("private", "public"):
with open(paths[key], "w") as fh:
fh.write("x")
return SimpleNamespace(returncode=0)
with mock.patch.object(os, "chmod",
side_effect=OSError("chmod 失败")):
with self.assertRaises(RuntimeError) as ctx:
hostkeys.generate_host_keypair(tmp, "vm1",
runner=runner)
self.assertIn("chmod 0600 失败", str(ctx.exception))
class _PoolStub:
"""WarmPool 用的假执行器:行为由属性开关控制。"""
def __init__(self, registry_path=None):
self.state_dir = None
self.warm_pool_size = 0
self.snapshot_url = "file:///snap/base"
self.registry_path = registry_path
self.shutdown_calls = []
self.shutdown_raises = False
self.emit_raises = False
self.woken = []
self.wake_raises = False
def vm_name(self, task):
raw = task.get("id") if isinstance(task, dict) else str(task)
return raw if raw.startswith("chv-") else "chv-" + raw
def wake_vm(self, task):
if self.wake_raises:
raise RuntimeError("wake 失败")
self.woken.append(task["id"])
return {"vm": self.vm_name(task)}
def pause_vm(self, task):
return True
def resume_vm(self, task):
return True
def shutdown_vm(self, task_or_name):
self.shutdown_calls.append(task_or_name)
if self.shutdown_raises:
raise RuntimeError("shutdown 失败")
return True
def _emit(self, event, task, detail="", kind=None):
if self.emit_raises:
raise RuntimeError("audit 失败")
class ExperimentalPoolCover100Test(unittest.TestCase):
"""实验池覆盖(单独计分,不计入生产覆盖)。"""
def test_load_registry_invalid_entry_counted(self):
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "warm-pool.json")
with open(path, "w") as fh:
json.dump({
"bad": "not-a-dict",
"good": {"vm": "vm-good", "state": "warm",
"task_id": "t1",
"snapshot_url": None},
}, fh)
pool = WarmPool(_PoolStub(), registry_path=path)
self.assertEqual(pool.registry_corrupt_entries, 1)
self.assertEqual(pool.orphan_count(), 1)
self.assertEqual(pool.warm_count(), 0)
def test_save_registry_skips_non_warm_entries(self):
# orphan 是上一世待清理条目,落盘时不写回:重启后
# 孤儿改由 socket 扫描兜底,不借 registry 复活。
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "warm-pool.json")
pool = WarmPool(_PoolStub(), registry_path=path)
pool._entries["w"] = {"state": "warm", "vm": "w",
"task": {"id": "w"},
"snapshot_url": None}
pool._entries["old"] = {"state": "orphan", "vm": "old",
"task": {"id": "old"},
"snapshot_url": None}
pool._save_registry()
with open(path) as fh:
data = json.load(fh)
self.assertEqual(list(data), ["w"])
def test_save_registry_non_dict_task_id_none(self):
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "warm-pool.json")
pool = WarmPool(_PoolStub(), registry_path=path)
pool._entries["w"] = {"state": "warm", "vm": "w",
"task": "not-a-dict",
"snapshot_url": None}
pool._save_registry()
with open(path) as fh:
data = json.load(fh)
self.assertIsNone(data["w"]["task_id"])
def test_reconcile_skips_warm_entries(self):
pool = WarmPool(_PoolStub(), registry_path=None)
pool._entries["warm-1"] = {"state": "warm", "vm": "vm-warm",
"task": {"id": "warm-1"}}
pool._entries["old-1"] = {"state": "orphan", "vm": "vm-old",
"task": {"id": "old-1"}}
cleaned = pool.reconcile_orphans()
self.assertEqual(cleaned, ["vm-old"])
self.assertIn("warm-1", pool._entries)
self.assertNotIn("old-1", pool._entries)
def test_reconcile_shutdown_failure_not_cleaned(self):
# feat-chv-p1fix(P1-01 同款语义,实验池):shutdown
# 失败的条目不进 cleaned、不从内存条目里 pop,
# 下次 reconcile 可重试。
stub = _PoolStub()
stub.shutdown_raises = True
pool = WarmPool(stub, registry_path=None)
pool._entries["old-1"] = {"state": "orphan", "vm": "vm-old",
"task": {"id": "old-1"}}
self.assertEqual(pool.reconcile_orphans(), [])
self.assertIn("old-1", pool._entries)
def test_reconcile_emit_failure_is_best_effort(self):
stub = _PoolStub()
stub.emit_raises = True
pool = WarmPool(stub, registry_path=None)
pool._entries["old-1"] = {"state": "orphan", "vm": "vm-old",
"task": {"id": "old-1"}}
self.assertEqual(pool.reconcile_orphans(), ["vm-old"])
def test_prewarm_skips_colliding_name(self):
stub = _PoolStub()
pool = WarmPool(stub, size=2, registry_path=None)
pool._entries["warm-1"] = {"state": "warm", "vm": "vm-1",
"task": {"id": "warm-1"},
"snapshot_url": pool.snapshot_url}
added = pool.prewarm()
self.assertEqual(added, ["warm-2"])
self.assertEqual(stub.woken, ["warm-2"])
self.assertEqual(pool.warm_count(), 2)
def test_prewarm_wake_failure_drops_entry_and_raises(self):
stub = _PoolStub()
stub.wake_raises = True
pool = WarmPool(stub, size=1, registry_path=None)
with self.assertRaises(RuntimeError):
pool.prewarm()
self.assertEqual(pool._entries, {})
self.assertEqual(pool.warm_count(), 0)
def test_acquire_emit_failure_is_best_effort(self):
stub = _PoolStub()
stub.emit_raises = True
pool = WarmPool(stub, size=1, registry_path=None)
pool.prewarm()
got = pool.acquire({"id": "t1"})
self.assertEqual(got["mode"], "warm")
def test_release_shutdown_failure_still_reports_destroyed(self):
stub = _PoolStub()
stub.shutdown_raises = True
pool = WarmPool(stub, size=1, registry_path=None)
pool._entries["warm-1"] = {"state": "assigned", "vm": "vm-1",
"task": {"id": "warm-1"}}
self.assertTrue(pool.release({"id": "t1"}, pool_vm="warm-1"))
self.assertNotIn("warm-1", pool._entries)
if __name__ == "__main__":
unittest.main()