mirror of
https://github.com/Sea-Haven-Industries/afterhours-shift-manager.git
synced 2026-09-30 09:03:11 +00:00
Prod is already a human GitHub Release. Remove the SAM tagging path so CHANGELOG.md stays App Home copy and leftover notifier IAM is destroyed on the next apply.
487 lines
18 KiB
Python
487 lines
18 KiB
Python
"""Tests for shared.blocks — Block Kit builders."""
|
|
|
|
from freezegun import freeze_time
|
|
|
|
from shared.blocks import (
|
|
build_admin_overview,
|
|
build_help_blocks,
|
|
build_holiday_add_modal,
|
|
build_holiday_added_blocks,
|
|
build_override_modal,
|
|
build_pay_summary_blocks,
|
|
build_pickup_request_blocks,
|
|
build_pickup_resolved_blocks,
|
|
build_roster_blocks,
|
|
build_shift_change_message,
|
|
build_swap_request_blocks,
|
|
build_swap_resolved_blocks,
|
|
build_week_schedule,
|
|
markdown_to_mrkdwn,
|
|
)
|
|
|
|
|
|
def _all_action_ids(blocks):
|
|
ids = []
|
|
for b in blocks:
|
|
if b.get("type") == "actions":
|
|
ids.extend(e["action_id"] for e in b["elements"])
|
|
return ids
|
|
|
|
|
|
class TestBuildWeekSchedule:
|
|
@freeze_time("2026-06-01 12:00:00") # Monday
|
|
def test_header_and_section_present(self, schedule):
|
|
blocks = build_week_schedule(schedule)
|
|
assert blocks[0]["type"] == "header"
|
|
assert "After-Hours Schedule" in blocks[0]["text"]["text"]
|
|
assert blocks[1]["type"] == "section"
|
|
|
|
@freeze_time("2026-06-01 12:00:00")
|
|
def test_all_available_produces_pickup_buttons(self, schedule):
|
|
# Empty schedule → every shift is available → pickup buttons exist,
|
|
# including a weekend day button with the _day suffix.
|
|
blocks = build_week_schedule(schedule)
|
|
action_ids = _all_action_ids(blocks)
|
|
assert "pickup_2026-06-03" in action_ids # Wednesday night
|
|
assert "pickup_2026-06-06_day" in action_ids # Saturday day shift
|
|
assert "pickup_2026-06-06" in action_ids # Saturday night shift
|
|
|
|
@freeze_time("2026-06-01 12:00:00")
|
|
def test_assigned_shift_has_no_pickup_button(self, schedule, seed):
|
|
seed.weekly("Wednesday", "114", "Alice")
|
|
blocks = build_week_schedule(schedule)
|
|
assert "pickup_2026-06-03" not in _all_action_ids(blocks)
|
|
assert "Alice (Ext 114)" in blocks[1]["text"]["text"]
|
|
|
|
@freeze_time("2026-06-01 12:00:00")
|
|
def test_weekday_night_row_shows_glyph_without_time_label(self, schedule, seed):
|
|
# Weekdays are night-only, so the moon glyph alone differentiates the row;
|
|
# the verbose time-range label is reserved for weekends where both shifts
|
|
# coexist (and would otherwise repeat on ~10 weekday rows).
|
|
seed.weekly("Wednesday", "114", "Alice")
|
|
text = build_week_schedule(schedule)[1]["text"]["text"]
|
|
wednesday_line = next(
|
|
line for line in text.splitlines() if "Alice (Ext 114)" in line
|
|
)
|
|
assert ":crescent_moon:" in wednesday_line
|
|
assert "Night (5pm" not in wednesday_line
|
|
|
|
@freeze_time("2026-06-01 12:00:00")
|
|
def test_weekend_night_row_keeps_glyph_and_time_label(self, schedule, seed):
|
|
# Weekends carry both shifts, so the night row keeps the full label.
|
|
seed.weekly("Saturday", "200", "Alice")
|
|
text = build_week_schedule(schedule)[1]["text"]["text"]
|
|
saturday_line = next(
|
|
line for line in text.splitlines() if "Alice (Ext 200)" in line
|
|
)
|
|
assert ":crescent_moon:" in saturday_line
|
|
assert "Night (5pm" in saturday_line
|
|
|
|
@freeze_time("2026-06-01 12:00:00")
|
|
def test_weekend_day_row_shows_day_glyph_and_label(self, schedule, seed):
|
|
seed.weekly("Saturday", "200", "Alice", shift_type="day")
|
|
text = build_week_schedule(schedule)[1]["text"]["text"]
|
|
assert ":sunny:" in text
|
|
assert "Day (8am" in text
|
|
|
|
@freeze_time("2026-06-01 12:00:00")
|
|
def test_holiday_on_weekday_renders_badge_and_open_slots(self, schedule, seed):
|
|
# A holiday can land on a weekday (here a Thursday) and shows the badge,
|
|
# the multiplier, and a day-shift pickup button for its open slots.
|
|
seed.holiday("2026-06-04", slots=2, label="Test Holiday")
|
|
blocks = build_week_schedule(schedule)
|
|
text = blocks[1]["text"]["text"]
|
|
assert "Holiday" in text
|
|
assert "Test Holiday" in text
|
|
assert "1.5x" in text
|
|
assert "2 open slots" in text
|
|
assert "pickup_2026-06-04_day" in _all_action_ids(blocks)
|
|
|
|
@freeze_time("2026-06-01 12:00:00")
|
|
def test_holiday_lists_assignees_and_remaining_slots(self, schedule, seed):
|
|
seed.holiday(
|
|
"2026-06-04",
|
|
slots=2,
|
|
label="Test Holiday",
|
|
assignees={"114": {"name": "Alice", "claimed_at": "x"}},
|
|
)
|
|
blocks = build_week_schedule(schedule)
|
|
text = blocks[1]["text"]["text"]
|
|
assert "Alice (Ext 114)" in text
|
|
assert "1 open slot" in text # one of two slots filled
|
|
|
|
@freeze_time("2026-06-01 12:00:00")
|
|
def test_full_holiday_has_no_pickup_button(self, schedule, seed):
|
|
seed.holiday(
|
|
"2026-06-04",
|
|
slots=1,
|
|
label="Test Holiday",
|
|
assignees={"114": {"name": "Alice", "claimed_at": "x"}},
|
|
)
|
|
blocks = build_week_schedule(schedule)
|
|
assert "pickup_2026-06-04_day" not in _all_action_ids(blocks)
|
|
|
|
@freeze_time("2026-06-01 12:00:00")
|
|
def test_holiday_pickup_button_label_says_holiday(self, schedule, seed):
|
|
seed.holiday("2026-06-04", slots=2, label="Test Holiday")
|
|
blocks = build_week_schedule(schedule)
|
|
labels = [
|
|
e["text"]["text"]
|
|
for b in blocks
|
|
if b.get("type") == "actions"
|
|
for e in b["elements"]
|
|
]
|
|
assert any("Holiday" in lbl for lbl in labels)
|
|
|
|
|
|
class TestBuildShiftChangeMessage:
|
|
def test_picked_up_weekday(self):
|
|
blocks = build_shift_change_message(
|
|
"U1", "2026-06-03", "picked_up", "114", "Alice"
|
|
)
|
|
text = blocks[0]["text"]["text"]
|
|
assert "<@U1>" in text and "picked up" in text and "Ext 114" in text
|
|
# Weekday is night-only → the notification still labels the after-hours shift
|
|
assert "Night (5pm" in text
|
|
|
|
def test_dropped_shows_available(self):
|
|
blocks = build_shift_change_message(
|
|
"U1", "2026-06-03", "dropped", "114", "Alice"
|
|
)
|
|
assert "Available" in blocks[0]["text"]["text"]
|
|
|
|
def test_swapped_text(self):
|
|
blocks = build_shift_change_message("U2", "2026-06-03", "swapped", "115", "Bob")
|
|
assert "swapped" in blocks[0]["text"]["text"]
|
|
|
|
def test_weekend_includes_shift_label(self):
|
|
blocks = build_shift_change_message(
|
|
"U1", "2026-06-06", "picked_up", "200", "Alice", shift_type="day"
|
|
)
|
|
assert "Day (8am" in blocks[0]["text"]["text"]
|
|
|
|
|
|
class TestBuildPaySummaryBlocks:
|
|
def test_renders_breakdown_and_totals(self):
|
|
breakdown = [
|
|
{
|
|
"day": "Mon",
|
|
"date_label": "Jun 1",
|
|
"name": "Alice",
|
|
"extension": "114",
|
|
"rate": 50.0,
|
|
}
|
|
]
|
|
totals = {
|
|
"Alice": {"shifts": 1, "total": 50.0, "extension": "114", "rate": 50.0}
|
|
}
|
|
blocks = build_pay_summary_blocks("Jun 1 to Jun 7", breakdown, totals)
|
|
assert blocks[0]["type"] == "header"
|
|
assert "Jun 1 to Jun 7" in blocks[0]["text"]["text"]
|
|
text = blocks[1]["text"]["text"]
|
|
assert "Alice" in text and "$50.00" in text and "1 shift" in text
|
|
|
|
def test_holiday_row_shows_effective_rate_and_note(self):
|
|
breakdown = [
|
|
{
|
|
"day": "Thu",
|
|
"date_label": "Jul 4",
|
|
"name": "Alice",
|
|
"extension": "114",
|
|
"rate": 75.0, # effective (1.5x of 50)
|
|
"is_holiday": True,
|
|
"multiplier": 1.5,
|
|
}
|
|
]
|
|
totals = {
|
|
"Alice": {
|
|
"shifts": 1,
|
|
"total": 75.0,
|
|
"extension": "114",
|
|
"rate": 50.0,
|
|
"holiday_shifts": 1,
|
|
}
|
|
}
|
|
blocks = build_pay_summary_blocks("Jun 29 to Jul 5", breakdown, totals)
|
|
text = blocks[1]["text"]["text"]
|
|
assert "$75.00" in text # effective rate on the breakdown line
|
|
assert "1.5x" in text
|
|
assert "Holiday" in text
|
|
assert "incl. 1 holiday" in text # totals note
|
|
|
|
def test_regular_row_has_no_holiday_decorations(self):
|
|
breakdown = [
|
|
{
|
|
"day": "Mon",
|
|
"date_label": "Jun 1",
|
|
"name": "Alice",
|
|
"extension": "114",
|
|
"rate": 50.0,
|
|
}
|
|
]
|
|
totals = {
|
|
"Alice": {"shifts": 1, "total": 50.0, "extension": "114", "rate": 50.0}
|
|
}
|
|
text = build_pay_summary_blocks("Jun 1 to Jun 7", breakdown, totals)[1]["text"][
|
|
"text"
|
|
]
|
|
assert "Holiday" not in text
|
|
assert "incl." not in text
|
|
|
|
|
|
class TestBuildSwapRequestBlocks:
|
|
def _action_ids(self, blocks):
|
|
return [
|
|
e["action_id"]
|
|
for b in blocks
|
|
if b["type"] == "actions"
|
|
for e in b["elements"]
|
|
]
|
|
|
|
def test_weekday_request_has_accept_decline(self):
|
|
blocks = build_swap_request_blocks("U_REQ", "2026-06-03", "night")
|
|
assert "<@U_REQ>" in blocks[0]["text"]["text"]
|
|
ids = self._action_ids(blocks)
|
|
assert ids == ["swap_accept_2026-06-03", "swap_decline_2026-06-03"]
|
|
|
|
def test_weekend_day_request_uses_day_suffix_and_label(self):
|
|
blocks = build_swap_request_blocks("U_REQ", "2026-06-06", "day")
|
|
assert "Day (8am" in blocks[0]["text"]["text"]
|
|
ids = self._action_ids(blocks)
|
|
assert ids == ["swap_accept_2026-06-06_day", "swap_decline_2026-06-06_day"]
|
|
|
|
def test_button_styles(self):
|
|
elements = build_swap_request_blocks("U_REQ", "2026-06-03", "night")[1][
|
|
"elements"
|
|
]
|
|
assert elements[0]["style"] == "primary" # Accept
|
|
assert elements[1]["style"] == "danger" # Decline
|
|
|
|
|
|
class TestBuildSwapResolvedBlocks:
|
|
def test_renders_text_no_buttons(self):
|
|
blocks = build_swap_resolved_blocks("All done.")
|
|
assert blocks == [
|
|
{"type": "section", "text": {"type": "mrkdwn", "text": "All done."}}
|
|
]
|
|
|
|
|
|
class TestBuildHolidayAddedBlocks:
|
|
def test_header_context_and_section(self):
|
|
blocks = build_holiday_added_blocks("2026-07-04", "Independence Day", 2, 1.5)
|
|
assert blocks[0]["type"] == "header"
|
|
assert "Independence Day" in blocks[0]["text"]["text"]
|
|
assert blocks[1]["type"] == "context"
|
|
section = blocks[-1]["text"]["text"]
|
|
assert "2 slots" in section
|
|
assert "1.5x" in section
|
|
assert "Holiday" in section
|
|
|
|
def test_single_slot_is_singular(self):
|
|
section = build_holiday_added_blocks("2026-07-04", "Indep", 1, 2)[-1]["text"][
|
|
"text"
|
|
]
|
|
assert "1 slot" in section and "1 slots" not in section
|
|
assert "2x" in section # whole-number multiplier drops the .0
|
|
|
|
def test_date_label_override(self):
|
|
blocks = build_holiday_added_blocks(
|
|
"2026-07-04", "Indep", 1, 1.5, date_label="July 4th"
|
|
)
|
|
assert blocks[1]["elements"][0]["text"] == "July 4th"
|
|
|
|
|
|
class TestBuildPickupRequestBlocks:
|
|
def _action_ids(self, blocks):
|
|
return [
|
|
e["action_id"]
|
|
for b in blocks
|
|
if b["type"] == "actions"
|
|
for e in b["elements"]
|
|
]
|
|
|
|
def test_weekday_request_encodes_date_and_ext(self):
|
|
blocks = build_pickup_request_blocks(
|
|
"U_REQ", "Alice", "2026-06-03", "night", "114"
|
|
)
|
|
text = blocks[0]["text"]["text"]
|
|
assert "<@U_REQ>" in text and "Ext 114" in text
|
|
assert "already started" in text
|
|
ids = self._action_ids(blocks)
|
|
assert ids == ["pickup_approve_2026-06-03_114", "pickup_deny_2026-06-03_114"]
|
|
|
|
def test_weekend_day_request_uses_day_suffix(self):
|
|
blocks = build_pickup_request_blocks(
|
|
"U_REQ", "Alice", "2026-06-06", "day", "114"
|
|
)
|
|
assert "Day (8am" in blocks[0]["text"]["text"]
|
|
ids = self._action_ids(blocks)
|
|
assert ids == [
|
|
"pickup_approve_2026-06-06_day_114",
|
|
"pickup_deny_2026-06-06_day_114",
|
|
]
|
|
|
|
def test_holiday_request_shows_badge_and_day_suffix(self):
|
|
blocks = build_pickup_request_blocks(
|
|
"U_REQ", "Alice", "2026-07-04", "day", "114", is_holiday=True
|
|
)
|
|
assert "Holiday" in blocks[0]["text"]["text"]
|
|
ids = self._action_ids(blocks)
|
|
assert ids == [
|
|
"pickup_approve_2026-07-04_day_114",
|
|
"pickup_deny_2026-07-04_day_114",
|
|
]
|
|
|
|
def test_button_styles(self):
|
|
elements = build_pickup_request_blocks(
|
|
"U_REQ", "Alice", "2026-06-03", "night", "114"
|
|
)[1]["elements"]
|
|
assert elements[0]["style"] == "primary" # Approve
|
|
assert elements[1]["style"] == "danger" # Deny
|
|
|
|
|
|
class TestBuildPickupResolvedBlocks:
|
|
def test_renders_text_no_buttons(self):
|
|
blocks = build_pickup_resolved_blocks("Approved.")
|
|
assert blocks == [
|
|
{"type": "section", "text": {"type": "mrkdwn", "text": "Approved."}}
|
|
]
|
|
|
|
|
|
class TestBuildHelpBlocks:
|
|
def test_non_admin_excludes_admin_section(self):
|
|
text = build_help_blocks(is_admin=False)[0]["text"]["text"]
|
|
assert "Admin Commands" not in text
|
|
|
|
def test_admin_includes_admin_section(self):
|
|
text = build_help_blocks(is_admin=True)[0]["text"]["text"]
|
|
assert "Admin Commands" in text
|
|
|
|
def test_admin_includes_holiday_commands(self):
|
|
text = build_help_blocks(is_admin=True)[0]["text"]["text"]
|
|
assert "holiday add" in text
|
|
assert "holiday remove" in text
|
|
assert "holiday list" in text
|
|
|
|
def test_non_admin_excludes_holiday_commands(self):
|
|
text = build_help_blocks(is_admin=False)[0]["text"]["text"]
|
|
assert "holiday add" not in text
|
|
|
|
|
|
class TestBuildRosterBlocks:
|
|
def test_empty_roster(self):
|
|
text = build_roster_blocks([])[0]["text"]["text"]
|
|
assert "No employees" in text
|
|
|
|
def test_linked_and_unlinked(self):
|
|
roster = [
|
|
{"SK": "115", "name": "Bob", "slack_user_id": ""},
|
|
{"SK": "114", "name": "Alice", "slack_user_id": "U_ALICE"},
|
|
]
|
|
text = build_roster_blocks(roster)[0]["text"]["text"]
|
|
# Sorted by extension → Alice (114) appears before Bob (115)
|
|
assert text.index("Alice") < text.index("Bob")
|
|
assert "<@U_ALICE>" in text
|
|
assert "_not linked_" in text
|
|
|
|
|
|
def _block_ids(view):
|
|
return {b["block_id"] for b in view["blocks"] if b.get("block_id")}
|
|
|
|
|
|
class TestBuildOverrideModal:
|
|
def test_callback_and_input_blocks(self):
|
|
view = build_override_modal([{"extension": "114", "name": "Alice"}])
|
|
assert view["type"] == "modal"
|
|
assert view["callback_id"] == "admin_override_submit"
|
|
assert _block_ids(view) == {"date", "extension", "shift_type"}
|
|
|
|
def test_extension_dropdown_built_from_roster(self):
|
|
view = build_override_modal(
|
|
[{"extension": "114", "name": "Alice"}, {"extension": "115", "name": "Bob"}]
|
|
)
|
|
ext_block = next(b for b in view["blocks"] if b["block_id"] == "extension")
|
|
values = [o["value"] for o in ext_block["element"]["options"]]
|
|
assert values == ["114", "115"]
|
|
|
|
def test_empty_roster_omits_options(self):
|
|
view = build_override_modal([])
|
|
ext_block = next(b for b in view["blocks"] if b["block_id"] == "extension")
|
|
assert "options" not in ext_block["element"]
|
|
|
|
|
|
class TestBuildHolidayAddModal:
|
|
def test_callback_and_input_blocks(self):
|
|
view = build_holiday_add_modal()
|
|
assert view["callback_id"] == "admin_holiday_add_submit"
|
|
assert _block_ids(view) == {"date", "slots", "multiplier", "label"}
|
|
|
|
def test_multiplier_is_optional(self):
|
|
view = build_holiday_add_modal()
|
|
mult = next(b for b in view["blocks"] if b["block_id"] == "multiplier")
|
|
assert mult["optional"] is True
|
|
|
|
|
|
class TestBuildAdminOverview:
|
|
def test_empty_overview(self):
|
|
text = build_admin_overview([], [])["text"]["text"]
|
|
assert "Nothing scheduled" in text
|
|
|
|
def test_lists_overrides_and_holidays(self):
|
|
overrides = [
|
|
{
|
|
"date": "2026-12-25",
|
|
"shift_type": "night",
|
|
"extension": "114",
|
|
"name": "Alice",
|
|
},
|
|
{
|
|
"date": "2026-12-26",
|
|
"shift_type": "day",
|
|
"extension": "OPEN",
|
|
"name": "Open",
|
|
},
|
|
]
|
|
holidays = [
|
|
{
|
|
"SK": "2026-12-25",
|
|
"slots": 2,
|
|
"assignees": {},
|
|
"multiplier": 1.5,
|
|
"label": "Christmas",
|
|
}
|
|
]
|
|
text = build_admin_overview(overrides, holidays)["text"]["text"]
|
|
assert "Alice (Ext 114)" in text
|
|
assert "(Day) — Open" in text
|
|
assert "Christmas" in text
|
|
assert "0/2 filled" in text
|
|
|
|
|
|
class TestMarkdownToMrkdwn:
|
|
def test_markdown_bold_becomes_slack_bold(self):
|
|
assert markdown_to_mrkdwn("**Big news.** text") == "*Big news.* text"
|
|
|
|
def test_markdown_italic_becomes_underscore(self):
|
|
# Single asterisks are italic in Markdown; Slack uses underscores.
|
|
assert markdown_to_mrkdwn("a *note* here") == "a _note_ here"
|
|
|
|
def test_bold_and_italic_together(self):
|
|
out = markdown_to_mrkdwn("**Bold.** Then *(an aside)*")
|
|
assert out == "*Bold.* Then _(an aside)_"
|
|
|
|
def test_links_and_bullets(self):
|
|
out = markdown_to_mrkdwn("- see [docs](http://x)\n- next")
|
|
assert "• see <http://x|docs>" in out
|
|
assert "• next" in out
|
|
|
|
def test_adversarial_input_runs_in_linear_time(self):
|
|
# py/polynomial-redos regression: possessive quantifiers must keep these
|
|
# patterns from catastrophic backtracking. Pathological inputs that would
|
|
# hang a backtracking engine complete effectively instantly here.
|
|
import time
|
|
|
|
for evil in ("*" + "*a" * 4000, "[" + "[\\(" * 4000, "[" + "](" * 4000):
|
|
start = time.perf_counter()
|
|
markdown_to_mrkdwn(evil)
|
|
assert time.perf_counter() - start < 1.0
|