|
| 1 | +"""Tests for the VoteStorage adapter.""" |
| 2 | + |
| 3 | +from datetime import datetime |
| 4 | +from datetime import timedelta |
| 5 | +from datetime import timezone |
| 6 | +from experimental.doodle.adapters.vote_storage import ANNOTATION_KEY |
| 7 | +from experimental.doodle.adapters.vote_storage import VoteStorage |
| 8 | +from experimental.doodle.interfaces import IVoteStorage |
| 9 | +from plone import api |
| 10 | +from plone.app.testing import setRoles |
| 11 | +from plone.app.testing import TEST_USER_ID |
| 12 | +from zope.annotation.interfaces import IAnnotations |
| 13 | + |
| 14 | +import pytest |
| 15 | + |
| 16 | + |
| 17 | +@pytest.fixture |
| 18 | +def three_slots(): |
| 19 | + """Three timezone-aware datetimes in the future.""" |
| 20 | + base = datetime.now(tz=timezone.utc) + timedelta(days=1) |
| 21 | + return [base, base + timedelta(hours=1), base + timedelta(hours=2)] |
| 22 | + |
| 23 | + |
| 24 | +@pytest.fixture |
| 25 | +def poll(integration, three_slots): |
| 26 | + """A Poll with three options inside the test portal.""" |
| 27 | + portal = integration["portal"] |
| 28 | + setRoles(portal, TEST_USER_ID, ["Manager"]) |
| 29 | + return api.content.create( |
| 30 | + container=portal, |
| 31 | + type="Poll", |
| 32 | + title="Team lunch", |
| 33 | + options=three_slots, |
| 34 | + ) |
| 35 | + |
| 36 | + |
| 37 | +class TestAdapterLookup: |
| 38 | + """``IVoteStorage`` resolves to ``VoteStorage`` for a Poll.""" |
| 39 | + |
| 40 | + def test_adapter_returns_vote_storage(self, poll): |
| 41 | + storage = IVoteStorage(poll) |
| 42 | + assert isinstance(storage, VoteStorage) |
| 43 | + |
| 44 | + def test_adapter_uses_annotation_key(self, poll): |
| 45 | + IVoteStorage(poll) # triggers annotation initialization |
| 46 | + annotations = IAnnotations(poll) |
| 47 | + assert ANNOTATION_KEY in annotations |
| 48 | + |
| 49 | + |
| 50 | +class TestCastVote: |
| 51 | + """Recording votes.""" |
| 52 | + |
| 53 | + def test_cast_vote_stores_choices(self, poll): |
| 54 | + storage = IVoteStorage(poll) |
| 55 | + storage.cast_vote("Alice", [True, False, True]) |
| 56 | + assert storage.get_vote("Alice") == [True, False, True] |
| 57 | + |
| 58 | + def test_cast_vote_is_idempotent_overwrite(self, poll): |
| 59 | + storage = IVoteStorage(poll) |
| 60 | + storage.cast_vote("Alice", [True, False, True]) |
| 61 | + storage.cast_vote("Alice", [False, False, False]) |
| 62 | + assert storage.get_vote("Alice") == [False, False, False] |
| 63 | + assert storage.participants() == ["Alice"] |
| 64 | + |
| 65 | + def test_cast_vote_strips_whitespace_preserves_case(self, poll): |
| 66 | + storage = IVoteStorage(poll) |
| 67 | + storage.cast_vote(" Alice ", [True, True, True]) |
| 68 | + assert storage.get_vote("Alice") == [True, True, True] |
| 69 | + assert "Alice" in storage.participants() |
| 70 | + |
| 71 | + def test_cast_vote_coerces_to_bool(self, poll): |
| 72 | + storage = IVoteStorage(poll) |
| 73 | + storage.cast_vote("Alice", [1, 0, "yes"]) |
| 74 | + assert storage.get_vote("Alice") == [True, False, True] |
| 75 | + |
| 76 | + |
| 77 | +class TestCastVoteErrors: |
| 78 | + """``cast_vote`` rejects invalid input.""" |
| 79 | + |
| 80 | + @pytest.mark.parametrize("bad_name", ["", " ", "\t\n"]) |
| 81 | + def test_empty_name_raises(self, poll, bad_name): |
| 82 | + storage = IVoteStorage(poll) |
| 83 | + with pytest.raises(ValueError): |
| 84 | + storage.cast_vote(bad_name, [True, False, True]) |
| 85 | + |
| 86 | + def test_non_string_name_raises(self, poll): |
| 87 | + storage = IVoteStorage(poll) |
| 88 | + with pytest.raises(ValueError): |
| 89 | + storage.cast_vote(None, [True, False, True]) |
| 90 | + |
| 91 | + def test_wrong_length_raises(self, poll): |
| 92 | + storage = IVoteStorage(poll) |
| 93 | + with pytest.raises(ValueError): |
| 94 | + storage.cast_vote("Alice", [True, False]) # poll has 3 options |
| 95 | + |
| 96 | + |
| 97 | +class TestReading: |
| 98 | + """Reading back stored votes.""" |
| 99 | + |
| 100 | + def test_get_vote_returns_none_for_unknown(self, poll): |
| 101 | + storage = IVoteStorage(poll) |
| 102 | + assert storage.get_vote("Nobody") is None |
| 103 | + |
| 104 | + def test_get_votes_returns_plain_dict_snapshot(self, poll): |
| 105 | + storage = IVoteStorage(poll) |
| 106 | + storage.cast_vote("Alice", [True, False, True]) |
| 107 | + |
| 108 | + snapshot = storage.get_votes() |
| 109 | + assert snapshot == {"Alice": [True, False, True]} |
| 110 | + |
| 111 | + # Mutating the snapshot must not affect the stored state. |
| 112 | + snapshot["Alice"][0] = False |
| 113 | + snapshot["Mallory"] = [True, True, True] |
| 114 | + assert storage.get_vote("Alice") == [True, False, True] |
| 115 | + assert "Mallory" not in storage.participants() |
| 116 | + |
| 117 | + def test_participants_sorted(self, poll): |
| 118 | + storage = IVoteStorage(poll) |
| 119 | + storage.cast_vote("Charlie", [True, True, True]) |
| 120 | + storage.cast_vote("Alice", [False, False, False]) |
| 121 | + storage.cast_vote("Bob", [True, False, True]) |
| 122 | + |
| 123 | + assert storage.participants() == ["Alice", "Bob", "Charlie"] |
| 124 | + |
| 125 | + |
| 126 | +class TestRemoveVote: |
| 127 | + """Removing votes.""" |
| 128 | + |
| 129 | + def test_remove_vote_existing_returns_true(self, poll): |
| 130 | + storage = IVoteStorage(poll) |
| 131 | + storage.cast_vote("Alice", [True, False, True]) |
| 132 | + |
| 133 | + assert storage.remove_vote("Alice") is True |
| 134 | + assert storage.get_vote("Alice") is None |
| 135 | + |
| 136 | + def test_remove_vote_unknown_returns_false(self, poll): |
| 137 | + storage = IVoteStorage(poll) |
| 138 | + assert storage.remove_vote("Nobody") is False |
| 139 | + |
| 140 | + |
| 141 | +class TestTally: |
| 142 | + """Aggregating yes-counts per option.""" |
| 143 | + |
| 144 | + def test_tally_empty_poll(self, poll): |
| 145 | + storage = IVoteStorage(poll) |
| 146 | + assert storage.tally() == [0, 0, 0] |
| 147 | + |
| 148 | + def test_tally_counts_yes_per_option(self, poll): |
| 149 | + storage = IVoteStorage(poll) |
| 150 | + storage.cast_vote("Alice", [True, False, True]) |
| 151 | + storage.cast_vote("Bob", [True, True, False]) |
| 152 | + storage.cast_vote("Charlie", [False, True, True]) |
| 153 | + |
| 154 | + # Option 0: Alice + Bob = 2 yes |
| 155 | + # Option 1: Bob + Charlie = 2 yes |
| 156 | + # Option 2: Alice + Charlie = 2 yes |
| 157 | + assert storage.tally() == [2, 2, 2] |
| 158 | + |
| 159 | + def test_tally_ignores_extra_entries_when_options_shrink(self, poll): |
| 160 | + """If the Poll loses an option after a vote, tally truncates.""" |
| 161 | + storage = IVoteStorage(poll) |
| 162 | + storage.cast_vote("Alice", [True, True, True]) |
| 163 | + |
| 164 | + # Simulate the Poll losing one option. |
| 165 | + poll.options = poll.options[:2] |
| 166 | + |
| 167 | + assert storage.tally() == [1, 1] |
0 commit comments