646 lines
20 KiB
Python
646 lines
20 KiB
Python
# Split Miner - BPMN process discovery from event logs.
|
|
# Authors:
|
|
# imacat@mail.imacat.idv.tw (imacat), 2026/3/10
|
|
# AI assistance: Claude Code (Anthropic)
|
|
|
|
# Copyright (c) 2026 imacat.
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
# implied. See the License for the specific language governing
|
|
# permissions and limitations under the License.
|
|
"""Tests for the Split Miner algorithm.
|
|
|
|
Uses the running example from Section 3 of the SM 1.0 journal
|
|
paper (Augusto et al., 2018).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import unittest
|
|
from collections import deque
|
|
|
|
from split_miner import (
|
|
BPMNModel,
|
|
Gateway,
|
|
GatewayType,
|
|
Node,
|
|
Task,
|
|
split_miner,
|
|
)
|
|
from split_miner.concurrency import PrunedDFG
|
|
from split_miner.dfg import DirectlyFollowsGraph
|
|
from split_miner.filtering import FilteredDFG
|
|
|
|
|
|
def _make_tasks(
|
|
labels: str,
|
|
) -> dict[str, Task]:
|
|
"""Create a Task for each single-character label.
|
|
|
|
:param labels: The labels as a string.
|
|
:return: A dict mapping label to Task.
|
|
"""
|
|
return {ch: Task(ch, ch) for ch in labels}
|
|
|
|
|
|
def _make_paper_node_log() -> tuple[
|
|
dict[tuple[Node, ...], int], dict[str, Task]
|
|
]:
|
|
"""Build the paper example log with Node objects.
|
|
|
|
:return: The Node-based traces and the task map.
|
|
"""
|
|
t: dict[str, Task] = _make_tasks("abcdefgh")
|
|
traces: dict[tuple[Node, ...], int] = {
|
|
(t["a"], t["b"], t["c"], t["g"],
|
|
t["e"], t["h"]): 10,
|
|
(t["a"], t["b"], t["c"], t["f"],
|
|
t["g"], t["h"]): 10,
|
|
(t["a"], t["b"], t["d"], t["g"],
|
|
t["e"], t["h"]): 10,
|
|
(t["a"], t["b"], t["d"], t["e"],
|
|
t["g"], t["h"]): 10,
|
|
(t["a"], t["b"], t["e"], t["c"],
|
|
t["g"], t["h"]): 10,
|
|
(t["a"], t["b"], t["e"], t["d"],
|
|
t["g"], t["h"]): 10,
|
|
(t["a"], t["c"], t["b"], t["e"],
|
|
t["g"], t["h"]): 10,
|
|
(t["a"], t["c"], t["b"], t["f"],
|
|
t["g"], t["h"]): 10,
|
|
(t["a"], t["d"], t["b"], t["e"],
|
|
t["g"], t["h"]): 10,
|
|
(t["a"], t["d"], t["b"], t["f"],
|
|
t["g"], t["h"]): 10,
|
|
}
|
|
return traces, t
|
|
|
|
|
|
def _make_paper_str_log() -> dict[
|
|
tuple[str, ...], int
|
|
]:
|
|
"""Build the paper example log with string labels.
|
|
|
|
:return: The string-based traces.
|
|
"""
|
|
return {
|
|
("a", "b", "c", "g", "e", "h"): 10,
|
|
("a", "b", "c", "f", "g", "h"): 10,
|
|
("a", "b", "d", "g", "e", "h"): 10,
|
|
("a", "b", "d", "e", "g", "h"): 10,
|
|
("a", "b", "e", "c", "g", "h"): 10,
|
|
("a", "b", "e", "d", "g", "h"): 10,
|
|
("a", "c", "b", "e", "g", "h"): 10,
|
|
("a", "c", "b", "f", "g", "h"): 10,
|
|
("a", "d", "b", "e", "g", "h"): 10,
|
|
("a", "d", "b", "f", "g", "h"): 10,
|
|
}
|
|
|
|
|
|
class TestDFGConstruction(unittest.TestCase):
|
|
"""Tests for DFG construction (Section 3.1)."""
|
|
|
|
def setUp(self) -> None:
|
|
"""Set up the test.
|
|
|
|
:return: None.
|
|
"""
|
|
traces: dict[tuple[Node, ...], int]
|
|
traces, self.__t = _make_paper_node_log()
|
|
self.__dfg: DirectlyFollowsGraph = (
|
|
DirectlyFollowsGraph(traces)
|
|
)
|
|
|
|
def test_nodes(self) -> None:
|
|
"""The DFG has the correct set of nodes."""
|
|
self.assertEqual(
|
|
self.__dfg.nodes,
|
|
set(self.__t.values()),
|
|
)
|
|
|
|
def test_sources_and_sinks(self) -> None:
|
|
"""The DFG has correct sources and sinks."""
|
|
self.assertIn(
|
|
self.__t["a"], self.__dfg.sources
|
|
)
|
|
self.assertIn(
|
|
self.__t["h"], self.__dfg.sinks
|
|
)
|
|
|
|
def test_df_frequencies(self) -> None:
|
|
"""Selected directly-follows frequencies match."""
|
|
t: dict[str, Task] = self.__t
|
|
# a -> b: appears in 6 trace types * 10 = 60
|
|
self.assertEqual(
|
|
self.__dfg.df_frequency(
|
|
t["a"], t["b"]
|
|
), 60
|
|
)
|
|
# a -> c: 2 trace types * 10 = 20
|
|
self.assertEqual(
|
|
self.__dfg.df_frequency(
|
|
t["a"], t["c"]
|
|
), 20
|
|
)
|
|
# a -> d: 2 trace types * 10 = 20
|
|
self.assertEqual(
|
|
self.__dfg.df_frequency(
|
|
t["a"], t["d"]
|
|
), 20
|
|
)
|
|
|
|
def test_no_self_loops(self) -> None:
|
|
"""The paper example has no self-loops."""
|
|
self.assertEqual(self.__dfg.self_loops, set())
|
|
|
|
def test_no_short_loops(self) -> None:
|
|
"""The paper example has no short-loops."""
|
|
self.assertEqual(
|
|
self.__dfg.short_loops, set()
|
|
)
|
|
|
|
def test_self_loop_detection(self) -> None:
|
|
"""Self-loops are correctly detected."""
|
|
t: dict[str, Task] = _make_tasks("abc")
|
|
traces: dict[tuple[Node, ...], int] = {
|
|
(t["a"], t["b"], t["b"], t["c"]): 10,
|
|
}
|
|
dfg: DirectlyFollowsGraph = (
|
|
DirectlyFollowsGraph(traces)
|
|
)
|
|
self.assertIn(t["b"], dfg.self_loops)
|
|
self.assertNotIn(t["a"], dfg.self_loops)
|
|
|
|
def test_self_loop_edges_excluded(self) -> None:
|
|
"""Self-loop edges are excluded from edges set."""
|
|
t: dict[str, Task] = _make_tasks("abc")
|
|
traces: dict[tuple[Node, ...], int] = {
|
|
(t["a"], t["b"], t["b"], t["c"]): 10,
|
|
}
|
|
dfg: DirectlyFollowsGraph = (
|
|
DirectlyFollowsGraph(traces)
|
|
)
|
|
self.assertNotIn(
|
|
(t["b"], t["b"]), dfg.edges
|
|
)
|
|
self.assertIn(
|
|
(t["a"], t["b"]), dfg.edges
|
|
)
|
|
self.assertIn(
|
|
(t["b"], t["c"]), dfg.edges
|
|
)
|
|
|
|
def test_short_loop_detection(self) -> None:
|
|
"""Short-loops are correctly detected."""
|
|
t: dict[str, Task] = _make_tasks("abcd")
|
|
traces: dict[tuple[Node, ...], int] = {
|
|
(t["a"], t["b"], t["c"],
|
|
t["b"], t["d"]): 10,
|
|
}
|
|
dfg: DirectlyFollowsGraph = (
|
|
DirectlyFollowsGraph(traces)
|
|
)
|
|
self.assertIn(
|
|
(t["b"], t["c"]), dfg.short_loops
|
|
)
|
|
self.assertIn(
|
|
(t["c"], t["b"]), dfg.short_loops
|
|
)
|
|
|
|
|
|
class TestConcurrencyDiscovery(unittest.TestCase):
|
|
"""Tests for concurrency discovery (Section 3.2)."""
|
|
|
|
def setUp(self) -> None:
|
|
"""Set up the test.
|
|
|
|
:return: None.
|
|
"""
|
|
traces: dict[tuple[Node, ...], int]
|
|
traces, self.__t = _make_paper_node_log()
|
|
dfg: DirectlyFollowsGraph = (
|
|
DirectlyFollowsGraph(traces)
|
|
)
|
|
self.__pdfg: PrunedDFG = PrunedDFG(
|
|
dfg, epsilon=0.2
|
|
)
|
|
|
|
def test_concurrent_pairs(self) -> None:
|
|
"""Correct concurrency relations with epsilon=0.2.
|
|
|
|
The paper identifies: b||c, b||d, d||e, e||g.
|
|
"""
|
|
t: dict[str, Task] = self.__t
|
|
# Check expected concurrent pairs
|
|
self.assertTrue(
|
|
self.__pdfg.is_concurrent(t["b"], t["c"])
|
|
)
|
|
self.assertTrue(
|
|
self.__pdfg.is_concurrent(t["b"], t["d"])
|
|
)
|
|
self.assertTrue(
|
|
self.__pdfg.is_concurrent(t["d"], t["e"])
|
|
)
|
|
self.assertTrue(
|
|
self.__pdfg.is_concurrent(t["e"], t["g"])
|
|
)
|
|
# Non-concurrent pairs
|
|
self.assertFalse(
|
|
self.__pdfg.is_concurrent(t["a"], t["b"])
|
|
)
|
|
self.assertFalse(
|
|
self.__pdfg.is_concurrent(t["c"], t["d"])
|
|
)
|
|
|
|
def test_self_loop_skipped_in_concurrency(
|
|
self,
|
|
) -> None:
|
|
"""Self-loop nodes are never concurrent."""
|
|
# b has a self-loop; a and b could look
|
|
# concurrent but b should be skipped.
|
|
t: dict[str, Task] = _make_tasks("abc")
|
|
traces: dict[tuple[Node, ...], int] = {
|
|
(t["a"], t["b"], t["b"],
|
|
t["a"], t["c"]): 10,
|
|
(t["a"], t["b"],
|
|
t["a"], t["c"]): 10,
|
|
}
|
|
dfg: DirectlyFollowsGraph = (
|
|
DirectlyFollowsGraph(traces)
|
|
)
|
|
pdfg: PrunedDFG = PrunedDFG(
|
|
dfg, epsilon=1.0
|
|
)
|
|
self.assertFalse(
|
|
pdfg.is_concurrent(t["a"], t["b"])
|
|
)
|
|
|
|
def test_short_loop_not_concurrent(self) -> None:
|
|
"""Short-loop pairs are not concurrent.
|
|
|
|
Condition 4 prevents short-loop pairs from being
|
|
declared concurrent.
|
|
"""
|
|
t: dict[str, Task] = _make_tasks("abcd")
|
|
traces: dict[tuple[Node, ...], int] = {
|
|
(t["a"], t["b"], t["c"],
|
|
t["b"], t["d"]): 10,
|
|
(t["a"], t["c"],
|
|
t["b"], t["d"]): 10,
|
|
}
|
|
dfg: DirectlyFollowsGraph = (
|
|
DirectlyFollowsGraph(traces)
|
|
)
|
|
pdfg: PrunedDFG = PrunedDFG(
|
|
dfg, epsilon=1.0
|
|
)
|
|
self.assertFalse(
|
|
pdfg.is_concurrent(t["b"], t["c"])
|
|
)
|
|
|
|
|
|
class TestFiltering(unittest.TestCase):
|
|
"""Tests for edge filtering (Section 3.3)."""
|
|
|
|
def setUp(self) -> None:
|
|
"""Set up the test.
|
|
|
|
:return: None.
|
|
"""
|
|
traces: dict[tuple[Node, ...], int]
|
|
traces, self.__t = _make_paper_node_log()
|
|
dfg: DirectlyFollowsGraph = (
|
|
DirectlyFollowsGraph(traces)
|
|
)
|
|
pdfg: PrunedDFG = PrunedDFG(
|
|
dfg, epsilon=0.2
|
|
)
|
|
self.__fdfg: FilteredDFG = FilteredDFG(
|
|
pdfg, eta=1.0
|
|
)
|
|
|
|
def test_filtered_edges_retain_best(self) -> None:
|
|
"""Filtered DFG retains best incoming/outgoing
|
|
edges.
|
|
|
|
Per Table 1 in the paper, edges (e,c) and (c,f)
|
|
should be dropped.
|
|
"""
|
|
t: dict[str, Task] = self.__t
|
|
edges: set[tuple[Node, Node]] = (
|
|
self.__fdfg.edges
|
|
)
|
|
# These should be retained (best edges)
|
|
self.assertIn((t["a"], t["b"]), edges)
|
|
self.assertIn((t["b"], t["e"]), edges)
|
|
self.assertIn((t["f"], t["g"]), edges)
|
|
self.assertIn((t["g"], t["h"]), edges)
|
|
|
|
def test_sources_and_sinks_preserved(self) -> None:
|
|
"""Filtering preserves sources and sinks."""
|
|
t: dict[str, Task] = self.__t
|
|
self.assertIn(
|
|
t["a"], self.__fdfg.sources
|
|
)
|
|
self.assertIn(
|
|
t["h"], self.__fdfg.sinks
|
|
)
|
|
|
|
|
|
class TestEndToEnd(unittest.TestCase):
|
|
"""End-to-end test for Split Miner."""
|
|
|
|
def test_paper_example_basic(self) -> None:
|
|
"""Split Miner produces a valid BPMN model.
|
|
|
|
The discovered model should have start/end events,
|
|
all 8 tasks, gateways, and proper connectivity.
|
|
"""
|
|
traces: dict[tuple[str, ...], int] = (
|
|
_make_paper_str_log()
|
|
)
|
|
model: BPMNModel = split_miner(
|
|
traces, epsilon=0.2, eta=0.4
|
|
)
|
|
# Has start and end
|
|
self.assertIsNotNone(model.start)
|
|
self.assertIsNotNone(model.end)
|
|
# Has all 8 tasks
|
|
task_labels: set[str] = {
|
|
t.label for t in model.tasks.values()
|
|
if t.label is not None
|
|
}
|
|
self.assertEqual(
|
|
task_labels,
|
|
{"a", "b", "c", "d", "e", "f", "g", "h"},
|
|
)
|
|
# Has edges
|
|
self.assertGreater(len(model.edges), 0)
|
|
# Start has outgoing edge
|
|
self.assertGreater(
|
|
len(model.outgoing_edges(model.start)), 0
|
|
)
|
|
# End has incoming edge
|
|
self.assertGreater(
|
|
len(model.incoming_edges(model.end)), 0
|
|
)
|
|
|
|
def test_paper_example_has_gateways(self) -> None:
|
|
"""The paper example produces split and join
|
|
gateways.
|
|
|
|
Per Fig. 3c, the model should have both XOR and
|
|
AND gateways (or OR gateways that get minimized).
|
|
"""
|
|
traces: dict[tuple[str, ...], int] = (
|
|
_make_paper_str_log()
|
|
)
|
|
model: BPMNModel = split_miner(
|
|
traces, epsilon=0.2, eta=0.4
|
|
)
|
|
self.assertGreater(len(model.gateways), 0)
|
|
gw_types: set[GatewayType] = {
|
|
gw.gateway_type
|
|
for gw in model.gateways.values()
|
|
}
|
|
# Should have at least XOR or AND gateways
|
|
self.assertTrue(
|
|
GatewayType.XOR in gw_types
|
|
or GatewayType.AND in gw_types,
|
|
f"Expected XOR or AND gateways, "
|
|
f"got {gw_types}"
|
|
)
|
|
|
|
def test_paper_example_all_tasks_connected(
|
|
self,
|
|
) -> None:
|
|
"""Every task is reachable from start.
|
|
|
|
Verifies syntactic correctness: all tasks on a
|
|
path from start to end.
|
|
"""
|
|
traces: dict[tuple[str, ...], int] = (
|
|
_make_paper_str_log()
|
|
)
|
|
model: BPMNModel = split_miner(
|
|
traces, epsilon=0.2, eta=0.4
|
|
)
|
|
# BFS from start
|
|
reachable: set[Node] = set()
|
|
queue: deque[Node] = deque([model.start])
|
|
while queue:
|
|
node: Node = queue.popleft()
|
|
if node in reachable:
|
|
continue
|
|
reachable.add(node)
|
|
for _, succ in model.outgoing_edges(node):
|
|
queue.append(succ)
|
|
# All tasks should be reachable
|
|
for task in model.tasks.values():
|
|
self.assertIn(
|
|
task, reachable,
|
|
f"Task {task.label!r} not reachable "
|
|
f"from start"
|
|
)
|
|
# End should be reachable
|
|
self.assertIn(model.end, reachable)
|
|
|
|
def test_paper_example_all_tasks_reach_end(
|
|
self,
|
|
) -> None:
|
|
"""Every task can reach the end event.
|
|
|
|
Verifies syntactic correctness by backward BFS.
|
|
"""
|
|
traces: dict[tuple[str, ...], int] = (
|
|
_make_paper_str_log()
|
|
)
|
|
model: BPMNModel = split_miner(
|
|
traces, epsilon=0.2, eta=0.4
|
|
)
|
|
# Backward BFS from end
|
|
can_reach_end: set[Node] = set()
|
|
queue: deque[Node] = deque([model.end])
|
|
while queue:
|
|
node: Node = queue.popleft()
|
|
if node in can_reach_end:
|
|
continue
|
|
can_reach_end.add(node)
|
|
for pred, _ in model.incoming_edges(node):
|
|
queue.append(pred)
|
|
# All tasks should reach end
|
|
for task in model.tasks.values():
|
|
self.assertIn(
|
|
task, can_reach_end,
|
|
f"Task {task.label!r} cannot reach end"
|
|
)
|
|
|
|
def test_simple_sequence(self) -> None:
|
|
"""A simple sequential log produces no gateways."""
|
|
traces: dict[tuple[str, ...], int] = {
|
|
("a", "b", "c"): 10,
|
|
}
|
|
model: BPMNModel = split_miner(traces)
|
|
self.assertEqual(len(model.gateways), 0)
|
|
self.assertEqual(len(model.tasks), 3)
|
|
|
|
def test_simple_xor_choice(self) -> None:
|
|
"""A log with exclusive choice produces XOR
|
|
gateways.
|
|
|
|
Log: {<a,b,d>^10, <a,c,d>^10}
|
|
Expected: a -> XOR-split -> {b, c} ->
|
|
XOR-join -> d
|
|
"""
|
|
traces: dict[tuple[str, ...], int] = {
|
|
("a", "b", "d"): 10,
|
|
("a", "c", "d"): 10,
|
|
}
|
|
model: BPMNModel = split_miner(
|
|
traces, epsilon=0.1, eta=0.4
|
|
)
|
|
# Should have tasks a, b, c, d
|
|
task_labels: set[str] = {
|
|
t.label for t in model.tasks.values()
|
|
if t.label is not None
|
|
}
|
|
self.assertEqual(
|
|
task_labels, {"a", "b", "c", "d"}
|
|
)
|
|
# Should have gateways
|
|
self.assertGreater(len(model.gateways), 0)
|
|
# All tasks reachable from start
|
|
reachable: set[Node] = set()
|
|
queue: deque[Node] = deque([model.start])
|
|
while queue:
|
|
node: Node = queue.popleft()
|
|
if node in reachable:
|
|
continue
|
|
reachable.add(node)
|
|
for _, s in model.outgoing_edges(node):
|
|
queue.append(s)
|
|
for task in model.tasks.values():
|
|
self.assertIn(task, reachable)
|
|
|
|
def test_simple_concurrency(self) -> None:
|
|
"""A log with concurrency produces AND gateways.
|
|
|
|
Log: {<a,b,c,d>^10, <a,c,b,d>^10}
|
|
b and c are concurrent.
|
|
"""
|
|
traces: dict[tuple[str, ...], int] = {
|
|
("a", "b", "c", "d"): 10,
|
|
("a", "c", "b", "d"): 10,
|
|
}
|
|
model: BPMNModel = split_miner(
|
|
traces, epsilon=1.0, eta=0.4
|
|
)
|
|
task_labels: set[str] = {
|
|
t.label for t in model.tasks.values()
|
|
if t.label is not None
|
|
}
|
|
self.assertEqual(
|
|
task_labels, {"a", "b", "c", "d"}
|
|
)
|
|
# Should have AND gateways for b||c
|
|
and_gws: list[Gateway] = [
|
|
gw for gw in model.gateways.values()
|
|
if gw.gateway_type == GatewayType.AND
|
|
]
|
|
self.assertGreater(
|
|
len(and_gws), 0,
|
|
"Expected AND gateways for concurrent "
|
|
"b and c"
|
|
)
|
|
|
|
|
|
class TestSelfLoopHandling(unittest.TestCase):
|
|
"""Tests for self-loop handling."""
|
|
|
|
def test_self_loop_restored(self) -> None:
|
|
"""Self-loops are restored in the final BPMN model.
|
|
|
|
A self-loop on task b should produce XOR-join and
|
|
XOR-split gateways around b with a back-edge.
|
|
"""
|
|
traces: dict[tuple[str, ...], int] = {
|
|
("a", "b", "c"): 10,
|
|
("a", "b", "b", "c"): 10,
|
|
("a", "b", "b", "b", "c"): 10,
|
|
}
|
|
model: BPMNModel = split_miner(traces)
|
|
# Task b should have a gateway predecessor
|
|
# and a gateway successor (the self-loop
|
|
# XOR-join and XOR-split)
|
|
b: Node = model.get_task("b")
|
|
b_preds: set[Node] = model.predecessors(b)
|
|
b_succs: set[Node] = model.successors(b)
|
|
# b should have exactly 1 predecessor (XOR-join)
|
|
# and 1 successor (XOR-split)
|
|
self.assertEqual(len(b_preds), 1)
|
|
self.assertEqual(len(b_succs), 1)
|
|
join_node: Node = next(iter(b_preds))
|
|
split_node: Node = next(iter(b_succs))
|
|
self.assertIsInstance(join_node, Gateway)
|
|
self.assertIsInstance(split_node, Gateway)
|
|
assert isinstance(join_node, Gateway)
|
|
assert isinstance(split_node, Gateway)
|
|
self.assertEqual(
|
|
join_node.gateway_type, GatewayType.XOR
|
|
)
|
|
self.assertEqual(
|
|
split_node.gateway_type, GatewayType.XOR
|
|
)
|
|
# Back-edge: split -> join
|
|
self.assertIn(
|
|
split_node,
|
|
model.predecessors(join_node)
|
|
)
|
|
|
|
|
|
class TestShortLoopHandling(unittest.TestCase):
|
|
"""Tests for short-loop handling."""
|
|
|
|
def test_short_loop_not_concurrent(self) -> None:
|
|
"""Short-loop pairs are excluded from concurrency.
|
|
|
|
If a and b form a short-loop, they must not be
|
|
declared concurrent even if they appear in both
|
|
orders.
|
|
"""
|
|
# a,b,a pattern = short-loop
|
|
t: dict[str, Task] = _make_tasks("abxy")
|
|
traces: dict[tuple[Node, ...], int] = {
|
|
(t["x"], t["a"], t["b"],
|
|
t["a"], t["y"]): 10,
|
|
(t["x"], t["b"], t["a"],
|
|
t["b"], t["y"]): 10,
|
|
(t["x"], t["a"], t["y"]): 10,
|
|
(t["x"], t["b"], t["y"]): 10,
|
|
}
|
|
dfg: DirectlyFollowsGraph = (
|
|
DirectlyFollowsGraph(traces)
|
|
)
|
|
# Should detect short-loop
|
|
self.assertIn(
|
|
(t["a"], t["b"]), dfg.short_loops
|
|
)
|
|
# Should NOT be concurrent
|
|
pdfg: PrunedDFG = PrunedDFG(
|
|
dfg, epsilon=1.0
|
|
)
|
|
self.assertFalse(
|
|
pdfg.is_concurrent(t["a"], t["b"])
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|