[Pather] add route completion callback
This commit is contained in:
parent
e7f7cae8b9
commit
42d291b1b6
3 changed files with 235 additions and 9 deletions
|
|
@ -57,6 +57,7 @@ for custom Tools.
|
||||||
from .pather import (
|
from .pather import (
|
||||||
Pather as Pather,
|
Pather as Pather,
|
||||||
PortPather as PortPather,
|
PortPather as PortPather,
|
||||||
|
RouteCompletionCallback as RouteCompletionCallback,
|
||||||
)
|
)
|
||||||
from .error import (
|
from .error import (
|
||||||
ToolContractError as ToolContractError,
|
ToolContractError as ToolContractError,
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ Rendering is not transactional across Pattern and Library mutations. A render
|
||||||
exception is terminal for that Pather: callers may catch it for reporting or
|
exception is terminal for that Pather: callers may catch it for reporting or
|
||||||
cleanup, but must not retry rendering or continue routing with the same object.
|
cleanup, but must not retry rendering or continue routing with the same object.
|
||||||
"""
|
"""
|
||||||
from typing import Self, Any, Literal, overload
|
from typing import Self, Any, Literal, Protocol, overload
|
||||||
from collections.abc import Iterator, Iterable, Mapping, MutableMapping, Sequence
|
from collections.abc import Iterator, Iterable, Mapping, MutableMapping, Sequence
|
||||||
import copy
|
import copy
|
||||||
import logging
|
import logging
|
||||||
|
|
@ -56,6 +56,7 @@ from pprint import pformat
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from itertools import chain
|
from itertools import chain
|
||||||
from types import TracebackType
|
from types import TracebackType
|
||||||
|
from types import MappingProxyType
|
||||||
|
|
||||||
import numpy
|
import numpy
|
||||||
from numpy import pi
|
from numpy import pi
|
||||||
|
|
@ -92,6 +93,19 @@ RENDER_POLICIES: tuple[RenderPolicy, ...] = ('auto', 'immediate', 'deferred', 'w
|
||||||
RESERVED_TOOL_OPTION_KEYS = frozenset(('kind', 'in_ptype', 'out_ptype', 'ccw'))
|
RESERVED_TOOL_OPTION_KEYS = frozenset(('kind', 'in_ptype', 'out_ptype', 'ccw'))
|
||||||
|
|
||||||
|
|
||||||
|
class RouteCompletionCallback(Protocol):
|
||||||
|
"""Callback invoked after one routing operation updates live Pather state."""
|
||||||
|
|
||||||
|
def __call__(
|
||||||
|
self,
|
||||||
|
pather: 'Pather',
|
||||||
|
endpoints: Mapping[str, Port],
|
||||||
|
/,
|
||||||
|
) -> None:
|
||||||
|
"""Handle copied final endpoints keyed by their original routed names."""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
def _present_route_args(**kwargs: Any) -> dict[str, Any]:
|
def _present_route_args(**kwargs: Any) -> dict[str, Any]:
|
||||||
"""Return explicitly supplied route arguments for planner validation."""
|
"""Return explicitly supplied route arguments for planner validation."""
|
||||||
return {key: value for key, value in kwargs.items() if value is not None}
|
return {key: value for key, value in kwargs.items() if value is not None}
|
||||||
|
|
@ -144,10 +158,25 @@ class Pather(PortList):
|
||||||
- `pather.trace('my_port', ccw=True, length=100)` plans a 100-unit bend
|
- `pather.trace('my_port', ccw=True, length=100)` plans a 100-unit bend
|
||||||
starting at 'my_port'. If the `Pather` is used as a context manager,
|
starting at 'my_port'. If the `Pather` is used as a context manager,
|
||||||
geometry is generated on clean context exit.
|
geometry is generated on clean context exit.
|
||||||
|
|
||||||
|
Examples: Route completion
|
||||||
|
==========================
|
||||||
|
Route completion callbacks can add labels or other endpoint annotations
|
||||||
|
without wrapping the individual routing methods::
|
||||||
|
|
||||||
|
def label_route_endpoints(pather, endpoints):
|
||||||
|
for name, port in endpoints.items():
|
||||||
|
pather.label('PORT_LABELS', string=name, offset=port.offset)
|
||||||
|
|
||||||
|
pather = Pather(
|
||||||
|
library,
|
||||||
|
tools=my_tool,
|
||||||
|
on_route_complete=label_route_endpoints,
|
||||||
|
)
|
||||||
"""
|
"""
|
||||||
__slots__ = (
|
__slots__ = (
|
||||||
'pattern', 'library', 'tools', 'planner', '_paths',
|
'pattern', 'library', 'tools', 'planner', '_paths',
|
||||||
'_dead', '_logger', '_render_policy', '_render_append', '_context_depth'
|
'on_route_complete', '_dead', '_logger', '_render_policy', '_render_append', '_context_depth'
|
||||||
)
|
)
|
||||||
|
|
||||||
pattern: Pattern
|
pattern: Pattern
|
||||||
|
|
@ -173,6 +202,9 @@ class Pather(PortList):
|
||||||
than on the planner instance.
|
than on the planner instance.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
on_route_complete: RouteCompletionCallback | None
|
||||||
|
"""Optional callback run once after each routing operation updates live state."""
|
||||||
|
|
||||||
_dead: bool
|
_dead: bool
|
||||||
""" If True, geometry generation is skipped (for debugging) """
|
""" If True, geometry generation is skipped (for debugging) """
|
||||||
|
|
||||||
|
|
@ -238,6 +270,7 @@ class Pather(PortList):
|
||||||
render: RenderPolicy = 'auto',
|
render: RenderPolicy = 'auto',
|
||||||
render_append: bool = True,
|
render_append: bool = True,
|
||||||
planner: RoutingPlanner | None = None,
|
planner: RoutingPlanner | None = None,
|
||||||
|
on_route_complete: RouteCompletionCallback | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Args:
|
Args:
|
||||||
|
|
@ -259,6 +292,11 @@ class Pather(PortList):
|
||||||
whether to append geometry or add a reference.
|
whether to append geometry or add a reference.
|
||||||
planner: Optional stateless route-selection planner. If omitted,
|
planner: Optional stateless route-selection planner. If omitted,
|
||||||
a new `RoutingPlanner` is used.
|
a new `RoutingPlanner` is used.
|
||||||
|
on_route_complete: Optional callback invoked once per routing
|
||||||
|
operation after port updates, plugs, and renames, but before
|
||||||
|
automatic rendering. It receives this Pather and a read-only,
|
||||||
|
request-ordered mapping from original routed names to copied
|
||||||
|
final Ports. Callback exceptions propagate without rollback.
|
||||||
"""
|
"""
|
||||||
if render not in RENDER_POLICIES:
|
if render not in RENDER_POLICIES:
|
||||||
raise BuildError(f'Invalid render policy {render!r}; expected one of {RENDER_POLICIES}')
|
raise BuildError(f'Invalid render policy {render!r}; expected one of {RENDER_POLICIES}')
|
||||||
|
|
@ -271,6 +309,7 @@ class Pather(PortList):
|
||||||
self.library = library
|
self.library = library
|
||||||
self.pattern = pattern if pattern is not None else Pattern()
|
self.pattern = pattern if pattern is not None else Pattern()
|
||||||
self.planner = RoutingPlanner() if planner is None else planner
|
self.planner = RoutingPlanner() if planner is None else planner
|
||||||
|
self.on_route_complete = on_route_complete
|
||||||
self._paths = defaultdict(list)
|
self._paths = defaultdict(list)
|
||||||
|
|
||||||
if ports is not None:
|
if ports is not None:
|
||||||
|
|
@ -500,6 +539,14 @@ class Pather(PortList):
|
||||||
#
|
#
|
||||||
# Routing Logic (Deferred / Incremental)
|
# Routing Logic (Deferred / Incremental)
|
||||||
#
|
#
|
||||||
|
def _notify_route_complete(self, endpoints: Iterable[tuple[str, Port]]) -> None:
|
||||||
|
"""Invoke the configured route callback with isolated endpoint snapshots."""
|
||||||
|
callback = self.on_route_complete
|
||||||
|
if callback is None:
|
||||||
|
return
|
||||||
|
snapshots = MappingProxyType({name: port.copy() for name, port in endpoints})
|
||||||
|
callback(self, snapshots)
|
||||||
|
|
||||||
def _apply_route_result(self, result: PreparedRouteResult) -> None:
|
def _apply_route_result(self, result: PreparedRouteResult) -> None:
|
||||||
"""
|
"""
|
||||||
Apply every action and deferred rename in a prepared route result.
|
Apply every action and deferred rename in a prepared route result.
|
||||||
|
|
@ -521,6 +568,9 @@ class Pather(PortList):
|
||||||
self.plugged({action.portspec: action.plug_into})
|
self.plugged({action.portspec: action.plug_into})
|
||||||
for old_name, new_name in result.renames:
|
for old_name, new_name in result.renames:
|
||||||
self.rename_ports({old_name: new_name})
|
self.rename_ports({old_name: new_name})
|
||||||
|
self._notify_route_complete(
|
||||||
|
(action.portspec, action.final_port) for action in result.actions
|
||||||
|
)
|
||||||
render_immediately = (
|
render_immediately = (
|
||||||
self._render_policy == 'immediate'
|
self._render_policy == 'immediate'
|
||||||
or (self._render_policy == 'auto' and self._context_depth == 0)
|
or (self._render_policy == 'auto' and self._context_depth == 0)
|
||||||
|
|
@ -539,7 +589,7 @@ class Pather(PortList):
|
||||||
*,
|
*,
|
||||||
out_rot: float | None = None,
|
out_rot: float | None = None,
|
||||||
out_ptype: str | None = None,
|
out_ptype: str | None = None,
|
||||||
) -> None:
|
) -> Port:
|
||||||
"""
|
"""
|
||||||
Move a dead Pather port without generating geometry.
|
Move a dead Pather port without generating geometry.
|
||||||
|
|
||||||
|
|
@ -564,6 +614,7 @@ class Pather(PortList):
|
||||||
self.pattern.ports[portspec] = out_port
|
self.pattern.ports[portspec] = out_port
|
||||||
if plug_into is not None:
|
if plug_into is not None:
|
||||||
self.plugged({portspec: plug_into})
|
self.plugged({portspec: plug_into})
|
||||||
|
return out_port.copy()
|
||||||
|
|
||||||
#
|
#
|
||||||
# High-level Routing Methods
|
# High-level Routing Methods
|
||||||
|
|
@ -639,7 +690,7 @@ class Pather(PortList):
|
||||||
raise
|
raise
|
||||||
if length is not None and len(contexts) == 1:
|
if length is not None and len(contexts) == 1:
|
||||||
context = contexts[0]
|
context = contexts[0]
|
||||||
self._apply_dead_fallback(
|
endpoint = self._apply_dead_fallback(
|
||||||
context.portspec,
|
context.portspec,
|
||||||
length,
|
length,
|
||||||
0,
|
0,
|
||||||
|
|
@ -647,11 +698,13 @@ class Pather(PortList):
|
||||||
context.port.ptype,
|
context.port.ptype,
|
||||||
out_ptype = out_ptype,
|
out_ptype = out_ptype,
|
||||||
)
|
)
|
||||||
|
self._notify_route_complete(((context.portspec, endpoint),))
|
||||||
return self
|
return self
|
||||||
if bounds.get('each') is not None:
|
if bounds.get('each') is not None:
|
||||||
each = bounds['each']
|
each = bounds['each']
|
||||||
|
endpoints: list[tuple[str, Port]] = []
|
||||||
for context in contexts:
|
for context in contexts:
|
||||||
self._apply_dead_fallback(
|
endpoint = self._apply_dead_fallback(
|
||||||
context.portspec,
|
context.portspec,
|
||||||
each,
|
each,
|
||||||
0,
|
0,
|
||||||
|
|
@ -659,6 +712,8 @@ class Pather(PortList):
|
||||||
context.port.ptype,
|
context.port.ptype,
|
||||||
out_ptype = out_ptype,
|
out_ptype = out_ptype,
|
||||||
)
|
)
|
||||||
|
endpoints.append((context.portspec, endpoint))
|
||||||
|
self._notify_route_complete(endpoints)
|
||||||
return self
|
return self
|
||||||
raise
|
raise
|
||||||
self._apply_route_result(result)
|
self._apply_route_result(result)
|
||||||
|
|
@ -755,7 +810,7 @@ class Pather(PortList):
|
||||||
raise
|
raise
|
||||||
_key, _value, length = resolved
|
_key, _value, length = resolved
|
||||||
context = contexts[0]
|
context = contexts[0]
|
||||||
self._apply_dead_fallback(
|
endpoint = self._apply_dead_fallback(
|
||||||
context.portspec,
|
context.portspec,
|
||||||
length,
|
length,
|
||||||
0,
|
0,
|
||||||
|
|
@ -763,6 +818,7 @@ class Pather(PortList):
|
||||||
context.port.ptype,
|
context.port.ptype,
|
||||||
out_ptype = out_ptype,
|
out_ptype = out_ptype,
|
||||||
)
|
)
|
||||||
|
self._notify_route_complete(((context.portspec, endpoint),))
|
||||||
return self
|
return self
|
||||||
self._apply_route_result(result)
|
self._apply_route_result(result)
|
||||||
return self
|
return self
|
||||||
|
|
@ -962,7 +1018,7 @@ class Pather(PortList):
|
||||||
if length is None:
|
if length is None:
|
||||||
raise
|
raise
|
||||||
context = contexts[0]
|
context = contexts[0]
|
||||||
self._apply_dead_fallback(
|
endpoint = self._apply_dead_fallback(
|
||||||
context.portspec,
|
context.portspec,
|
||||||
length,
|
length,
|
||||||
0,
|
0,
|
||||||
|
|
@ -970,10 +1026,11 @@ class Pather(PortList):
|
||||||
context.port.ptype,
|
context.port.ptype,
|
||||||
out_ptype = out_ptype,
|
out_ptype = out_ptype,
|
||||||
)
|
)
|
||||||
|
self._notify_route_complete(((context.portspec, endpoint),))
|
||||||
return self
|
return self
|
||||||
fallback_length = length if length is not None else 0
|
fallback_length = length if length is not None else 0
|
||||||
context = contexts[0]
|
context = contexts[0]
|
||||||
self._apply_dead_fallback(
|
endpoint = self._apply_dead_fallback(
|
||||||
context.portspec,
|
context.portspec,
|
||||||
fallback_length,
|
fallback_length,
|
||||||
offset,
|
offset,
|
||||||
|
|
@ -982,6 +1039,7 @@ class Pather(PortList):
|
||||||
out_rot = pi,
|
out_rot = pi,
|
||||||
out_ptype = out_ptype,
|
out_ptype = out_ptype,
|
||||||
)
|
)
|
||||||
|
self._notify_route_complete(((context.portspec, endpoint),))
|
||||||
return self
|
return self
|
||||||
self._apply_route_result(result)
|
self._apply_route_result(result)
|
||||||
return self
|
return self
|
||||||
|
|
@ -1035,7 +1093,7 @@ class Pather(PortList):
|
||||||
):
|
):
|
||||||
raise
|
raise
|
||||||
context = contexts[0]
|
context = contexts[0]
|
||||||
self._apply_dead_fallback(
|
endpoint = self._apply_dead_fallback(
|
||||||
context.portspec,
|
context.portspec,
|
||||||
length,
|
length,
|
||||||
offset,
|
offset,
|
||||||
|
|
@ -1044,6 +1102,7 @@ class Pather(PortList):
|
||||||
out_rot = 0.0,
|
out_rot = 0.0,
|
||||||
out_ptype = out_ptype,
|
out_ptype = out_ptype,
|
||||||
)
|
)
|
||||||
|
self._notify_route_complete(((context.portspec, endpoint),))
|
||||||
return self
|
return self
|
||||||
self._apply_route_result(result)
|
self._apply_route_result(result)
|
||||||
return self
|
return self
|
||||||
|
|
|
||||||
166
masque/test/test_pather_route_completion.py
Normal file
166
masque/test/test_pather_route_completion.py
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
import numpy
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from masque import Library, Pather, Port
|
||||||
|
from masque.builder import PathTool, RenderStep, RouteCompletionCallback, Tool
|
||||||
|
from masque.error import BuildError
|
||||||
|
|
||||||
|
|
||||||
|
def test_route_completion_can_label_before_automatic_render() -> None:
|
||||||
|
calls = 0
|
||||||
|
|
||||||
|
def label_endpoints(pather: Pather, endpoints: Mapping[str, Port]) -> None:
|
||||||
|
nonlocal calls
|
||||||
|
calls += 1
|
||||||
|
assert not pather.pattern.has_shapes()
|
||||||
|
assert numpy.allclose(pather.ports['A'].offset, endpoints['A'].offset)
|
||||||
|
for name, port in endpoints.items():
|
||||||
|
pather.label('LABELS', string=name, offset=port.offset)
|
||||||
|
|
||||||
|
callback: RouteCompletionCallback = label_endpoints
|
||||||
|
p = Pather(
|
||||||
|
Library(),
|
||||||
|
tools=PathTool(layer='M1', width=1, ptype='wire'),
|
||||||
|
on_route_complete=callback,
|
||||||
|
)
|
||||||
|
p.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||||
|
|
||||||
|
p.at('A').straight(5)
|
||||||
|
|
||||||
|
assert calls == 1
|
||||||
|
assert p.pattern.has_shapes()
|
||||||
|
assert len(p.pattern.labels['LABELS']) == 1
|
||||||
|
assert p.pattern.labels['LABELS'][0].string == 'A'
|
||||||
|
assert numpy.allclose(p.pattern.labels['LABELS'][0].offset, p.ports['A'].offset)
|
||||||
|
|
||||||
|
|
||||||
|
def test_bundle_route_completion_is_ordered_read_only_and_isolated() -> None:
|
||||||
|
calls: list[Mapping[str, Port]] = []
|
||||||
|
|
||||||
|
def record(pather: Pather, endpoints: Mapping[str, Port]) -> None:
|
||||||
|
_ = pather
|
||||||
|
calls.append(endpoints)
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
endpoints['extra'] = Port((0, 0)) # type: ignore[index]
|
||||||
|
endpoints['B'].translate((100, 100))
|
||||||
|
|
||||||
|
p = Pather(
|
||||||
|
Library(),
|
||||||
|
tools=PathTool(layer='M1', width=1, ptype='wire'),
|
||||||
|
render='deferred',
|
||||||
|
on_route_complete=record,
|
||||||
|
)
|
||||||
|
p.ports['B'] = Port((0, 2), rotation=0, ptype='wire')
|
||||||
|
p.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||||
|
|
||||||
|
p.trace(['A', 'B'], None, each=5)
|
||||||
|
|
||||||
|
assert len(calls) == 1
|
||||||
|
assert list(calls[0]) == ['A', 'B']
|
||||||
|
assert numpy.allclose(p.ports['A'].offset, (-5, 0))
|
||||||
|
assert numpy.allclose(p.ports['B'].offset, (-5, 2))
|
||||||
|
|
||||||
|
|
||||||
|
def test_trace_into_completion_retains_consumed_source_endpoint() -> None:
|
||||||
|
calls: list[Mapping[str, Port]] = []
|
||||||
|
|
||||||
|
def record(pather: Pather, endpoints: Mapping[str, Port]) -> None:
|
||||||
|
assert set(pather.ports) == {'src'}
|
||||||
|
assert numpy.allclose(pather.ports['src'].offset, (20, 0))
|
||||||
|
calls.append(endpoints)
|
||||||
|
|
||||||
|
p = Pather(
|
||||||
|
Library(),
|
||||||
|
tools=PathTool(layer='M1', width=1, ptype='wire'),
|
||||||
|
render='deferred',
|
||||||
|
on_route_complete=record,
|
||||||
|
)
|
||||||
|
p.ports['src'] = Port((0, 0), rotation=0, ptype='wire')
|
||||||
|
p.ports['dst'] = Port((-10, 0), rotation=numpy.pi, ptype='wire')
|
||||||
|
p.ports['thru'] = Port((20, 0), rotation=0, ptype='wire')
|
||||||
|
|
||||||
|
p.trace_into('src', 'dst', thru='thru')
|
||||||
|
|
||||||
|
assert len(calls) == 1
|
||||||
|
assert list(calls[0]) == ['src']
|
||||||
|
assert numpy.allclose(calls[0]['src'].offset, (-10, 0))
|
||||||
|
|
||||||
|
|
||||||
|
def test_route_completion_exception_propagates_before_render() -> None:
|
||||||
|
def fail(pather: Pather, endpoints: Mapping[str, Port]) -> None:
|
||||||
|
_ = pather, endpoints
|
||||||
|
raise RuntimeError('completion failed')
|
||||||
|
|
||||||
|
p = Pather(
|
||||||
|
Library(),
|
||||||
|
tools=PathTool(layer='M1', width=1, ptype='wire'),
|
||||||
|
on_route_complete=fail,
|
||||||
|
)
|
||||||
|
p.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match='completion failed'):
|
||||||
|
p.straight('A', 5)
|
||||||
|
|
||||||
|
assert numpy.allclose(p.ports['A'].offset, (-5, 0))
|
||||||
|
assert p._paths['A']
|
||||||
|
assert not p.pattern.has_shapes()
|
||||||
|
|
||||||
|
|
||||||
|
class NoRouteTool(Tool):
|
||||||
|
def primitive_offers(
|
||||||
|
self,
|
||||||
|
kind: Literal['straight', 'bend', 's', 'u'],
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> tuple[()]:
|
||||||
|
_ = kind, kwargs
|
||||||
|
return ()
|
||||||
|
|
||||||
|
def render(
|
||||||
|
self,
|
||||||
|
batch: Sequence[RenderStep],
|
||||||
|
*,
|
||||||
|
port_names: tuple[str, str] = ('A', 'B'),
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> Library:
|
||||||
|
_ = batch, port_names, kwargs
|
||||||
|
return Library()
|
||||||
|
|
||||||
|
|
||||||
|
def test_dead_bundle_fallback_invokes_route_completion_once() -> None:
|
||||||
|
calls: list[Mapping[str, Port]] = []
|
||||||
|
p = Pather(
|
||||||
|
Library(),
|
||||||
|
tools=NoRouteTool(),
|
||||||
|
on_route_complete=lambda _pather, endpoints: calls.append(endpoints),
|
||||||
|
)
|
||||||
|
p.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||||
|
p.ports['B'] = Port((0, 2), rotation=0, ptype='wire')
|
||||||
|
p.set_dead()
|
||||||
|
|
||||||
|
p.trace(['B', 'A'], None, each=5)
|
||||||
|
|
||||||
|
assert len(calls) == 1
|
||||||
|
assert list(calls[0]) == ['B', 'A']
|
||||||
|
assert numpy.allclose(calls[0]['B'].offset, (-5, 2))
|
||||||
|
assert numpy.allclose(calls[0]['A'].offset, (-5, 0))
|
||||||
|
assert not p.pattern.has_shapes()
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_route_does_not_invoke_route_completion() -> None:
|
||||||
|
calls = 0
|
||||||
|
|
||||||
|
def record(pather: Pather, endpoints: Mapping[str, Port]) -> None:
|
||||||
|
nonlocal calls
|
||||||
|
_ = pather, endpoints
|
||||||
|
calls += 1
|
||||||
|
|
||||||
|
p = Pather(Library(), tools=NoRouteTool(), on_route_complete=record)
|
||||||
|
p.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
|
||||||
|
|
||||||
|
with pytest.raises(BuildError):
|
||||||
|
p.straight('A', 5)
|
||||||
|
|
||||||
|
assert calls == 0
|
||||||
Loading…
Add table
Add a link
Reference in a new issue