from __future__ import annotations from collections.abc import Mapping from collections.abc import MutableMapping from collections.abc import MutableSequence from collections.abc import MutableSet from typing import Any import pytest from hooked_containers import events as e from hooked_containers.mapping import HookedMapping from hooked_containers.mixins import ListMixin from hooked_containers.mixins import MutableMappingMixin from hooked_containers.mixins import MutableSetMixin from hooked_containers.sequence import HookedList from hooked_containers.set import HookedSet class TestHookedList: class TestConstruction: def test_empty(self): lst = HookedList([]) assert list(lst) == [] def test_with_items(self): lst = HookedList([1, 2, 3]) assert list(lst) == [1, 2, 3] def test_recreation(self): initial = [1, 2, 3] lst = HookedList(initial) assert lst._storage is not initial lst2 = HookedList(lst) assert lst2._storage is not lst._storage assert lst2 == initial def test_recreation_does_not_duplicate_hooks(self): original_changes = [] changes = [] original = HookedList([1], original_changes.append) lst = HookedList(original, changes.append) lst.append(2) assert lst._storage is not original._storage assert original._storage == [1] assert original_changes == [] assert len(changes) == 1 assert changes == [e.Change("add", 1, 2)] class TestSeqOps: def test_len(self): lst = HookedList([1, 2, 3]) assert len(lst) == 3 def test_getitem(self): lst = HookedList([1, 2, 3]) assert lst[0] == 1 assert lst[1] == 2 assert lst[-1] == 3 def test_contains(self): lst = HookedList([1, 2, 3]) assert 2 in lst assert 4 not in lst def test_iter(self): lst = HookedList([1, 2, 3]) for i, item in enumerate(lst, start=1): assert item == i class TestMutableOps: def test_setitem(self): added = [] lst = HookedList[Any]( [1, 2, [4, 5, [6, 7]]], lambda event: added.append(event.value), ) lst[0] = 10 lst.append(20) assert added == [10, 20] lst[2][-1].append(8) assert added == [10, 20] def test_delitem(self): lst = HookedList([1, 2, 3]) del lst[1] assert list(lst) == [1, 3] def test_insert(self): lst = HookedList([1, 3]) lst.insert(1, 2) assert list(lst) == [1, 2, 3] def test_inherited_mutation_helpers(self): changes = [] lst = HookedList([1], changes.append) lst.extend([2, 3]) assert lst.pop() == 3 lst.remove(1) assert list(lst) == [2] assert changes == [ e.Change("add", 1, 2), e.Change("add", 2, 3), e.Change("remove", 2, 3), e.Change("remove", 0, 1), ] def test_slice_mutations(self): changes = [] lst = HookedList([1, 2, 3], changes.append) lst[1:3] = iter([4, 5]) assert list(lst) == [1, 4, 5] del lst[::2] assert list(lst) == [4] assert changes == [ e.Change("set", slice(1, 3), [4, 5]), e.Change("remove", slice(None, None, 2), [1, 5]), ] def test_failed_mutations_do_not_notify(self): changes = [] lst = HookedList([1, 2, 3], changes.append) with pytest.raises(ValueError): lst[::2] = [4] with pytest.raises(IndexError): del lst[10] assert list(lst) == [1, 2, 3] assert changes == [] def test_nested_values_are_not_observed(self): changes = [] lst = HookedList([[1]], changes.append) nested = lst[0] nested[0] = 2 nested.insert(1, 3) del nested[0] assert lst._storage == [[3]] assert changes == [] def test_native_comparisons_and_non_mutating_results(self): changes = [] items = HookedList([1, 2], changes.append) assert items == [1, 2] native = [1, 2] assert native == items assert items != (1, 2) assert items < [1, 3] assert items <= HookedList([1, 2]) assert items > [0] assert items >= [1, 2] with pytest.raises(TypeError): assert items < (1, 3) with pytest.raises(TypeError): _ = items + (3,) for result in (items[:], items.copy(), items + [3], [0] + items, items * 2, 2 * items): assert type(result) is list result.append(99) assert items == [1, 2] assert changes == [] def test_index_protocol_and_normalized_indices(self): class Index: def __index__(self): return -1 changes = [] items = HookedList([1, 2], changes.append) assert items[Index()] == 2 items[Index()] = 3 items.insert(-100, 0) items.insert(100, 4) del items[Index()] assert items == [0, 1, 3] assert changes == [ e.Change("set", 1, 3), e.Change("add", 0, 0), e.Change("add", 3, 4), e.Change("remove", 3, 4), ] def test_self_extension_and_in_place_repetition(self): changes = [] items = HookedList([1, 2], changes.append) original = items items += items assert items is original assert items == [1, 2, 1, 2] assert changes == [e.Change("add", 2, 1), e.Change("add", 3, 2)] changes.clear() items *= 2 assert items is original assert items == [1, 2] * 4 assert changes == [e.Change("set", slice(None), [1, 2] * 4)] changes.clear() items *= 1 assert changes == [] items *= 0 assert items == [] assert changes == [e.Change("set", slice(None), [])] def test_reverse_sort_and_empty_writes(self): changes = [] items = HookedList([3, 1, 2], changes.append) items.reverse() assert items == [2, 1, 3] assert changes == [e.Change("set", 0, 2), e.Change("set", 2, 3)] changes.clear() items.sort(reverse=True) assert items == [3, 2, 1] assert changes == [e.Change("set", slice(None), [3, 2, 1])] changes.clear() del items[0:0] items[0:0] = [] items.extend([]) assert changes == [] items.clear() assert [change.value for change in changes] == [1, 2, 3] changes.clear() items.clear() items.reverse() items.sort() assert changes == [] def test_partial_extend_and_sort_failure(self): def values(): yield 2 raise RuntimeError("iteration failed") changes = [] items = HookedList([1], changes.append) with pytest.raises(RuntimeError, match="iteration failed"): items.extend(values()) assert items == [1, 2] assert changes == [e.Change("add", 1, 2)] changes.clear() def fail(_value): raise ValueError("key failed") with pytest.raises(ValueError, match="key failed"): items.sort(key=fail) assert items == [1, 2] assert changes == [] @pytest.mark.parametrize( ("mixin", "factory", "mutate", "expected"), [ (MutableMappingMixin, dict, lambda container: container.update(a=1), e.Change("add", "a", 1)), (ListMixin, list, lambda container: container.append(1), e.Change("add", 0, 1)), (MutableSetMixin, set, lambda container: container.add(1), e.Change("add", None, 1)), ], ) def test_mixins_work_without_concrete_wrappers(mixin, factory, mutate, expected): class Custom(mixin): def __init__(self): self._storage = factory() changes = [] container = Custom() container.hook = changes.append mutate(container) assert changes == [expected] def test_helpers_are_inherited_from_standard_abcs(): assert HookedList.append is MutableSequence.append assert HookedList.extend is MutableSequence.extend assert HookedList.reverse is MutableSequence.reverse assert HookedList.__iadd__ is MutableSequence.__iadd__ assert HookedMapping.get is Mapping.get assert HookedMapping.keys is Mapping.keys assert HookedMapping.update is MutableMapping.update assert HookedSet.pop is MutableSet.pop assert HookedSet.remove is MutableSet.remove assert HookedSet.__ior__ is MutableSet.__ior__ assert HookedSet.__iand__ is MutableSet.__iand__ assert HookedSet.__ixor__ is MutableSet.__ixor__ assert HookedSet.__isub__ is MutableSet.__isub__