mirror of
https://github.com/Sea-Haven-Industries/afterhours-shift-manager.git
synced 2026-10-06 01:52:04 +00:00
* Expand /oncall date parser to accept more formats Users entering everyday forms like 7/3/26 hit a generic parse failure because the parser only accepted four-digit years and a bare m/d. Add two-digit-year and month-name (with optional ordinal/year) formats, treating explicit-year inputs as fixed and keeping the year-less roll-forward for bare m/d. Update the help text and per-command parse hints to match. Refs: #133 * Let drop pick a shift and flag night rows Drop now accepts an optional [day|night|holiday] qualifier and, when a date carries more than one shift the user holds, asks which to drop instead of silently releasing the holiday or weekend day shift. The static post also tags day/night rows with distinct glyphs and labels on weekdays, so the after-hours row is unmistakable. Refs: #134 * Refine night-row labeling and drop notifications Weekday rows are night-only, so the moon glyph alone marks the after-hours shift; the verbose time-range label is kept only on weekend rows where day and night shifts coexist. Channel shift-change notifications now label the shift on every day for parity. Thread the caller's user_id through the regular-drop path instead of re-reading it off the employee record, and document the user-facing changes. Refs: #134 --------- Co-authored-by: amoussa1229 <166072409+amoussa1229@users.noreply.github.com>
This commit is contained in:
parent
eb78a98de0
commit
b8ab4d6b77
6 changed files with 332 additions and 30 deletions
17
CHANGELOG.md
17
CHANGELOG.md
|
|
@ -10,6 +10,23 @@ fine and still supported.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## v1.12.0 — June 26, 2026
|
||||||
|
|
||||||
|
**Clearer shift drops and an easier-to-read schedule.** Two small quality-of-life
|
||||||
|
improvements:
|
||||||
|
|
||||||
|
- **`/oncall drop` can now say which shift to let go.** If you hold more than one
|
||||||
|
shift on the same day — say a holiday day slot *and* that night's after-hours
|
||||||
|
shift, or a weekend day *and* night shift — dropping no longer guesses for you.
|
||||||
|
The bot lists what you hold and asks you to add `day`, `night`, or `holiday`
|
||||||
|
(for example `/oncall drop saturday night`), the same way picking one up
|
||||||
|
already works. Asking to drop a shift you don't actually hold is turned down
|
||||||
|
instead of quietly dropping the wrong one.
|
||||||
|
- **Night shifts stand out on the two-week post.** Day and night rows now carry
|
||||||
|
their own icons — a sun for the daytime shift and a moon for the after-hours
|
||||||
|
(night) shift — so the on-call row is easy to spot at a glance. Weekend rows,
|
||||||
|
which have both a day and a night shift, still spell out the full time range.
|
||||||
|
|
||||||
## v1.11.1 — June 17, 2026
|
## v1.11.1 — June 17, 2026
|
||||||
|
|
||||||
**Fix holiday call routing to 3CX.** The holiday router was calling the wrong 3CX
|
**Fix holiday call routing to 3CX.** The holiday router was calling the wrong 3CX
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,14 @@ SHIFT_LABELS = {
|
||||||
"night": "Night (5pm–8am)",
|
"night": "Night (5pm–8am)",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Per-shift glyphs so day vs. night rows are distinguishable at a glance. On
|
||||||
|
# weekdays only the night (after-hours) shift exists, so the moon glyph alone
|
||||||
|
# differentiates the row; the verbose time-range label is reserved for weekends.
|
||||||
|
SHIFT_GLYPHS = {
|
||||||
|
"day": ":sunny:",
|
||||||
|
"night": ":crescent_moon:",
|
||||||
|
}
|
||||||
|
|
||||||
HOLIDAY_BADGE = ":palm_tree: *Holiday*"
|
HOLIDAY_BADGE = ":palm_tree: *Holiday*"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -86,10 +94,15 @@ def _format_shift_line(
|
||||||
source: str,
|
source: str,
|
||||||
is_today: bool,
|
is_today: bool,
|
||||||
shift_type: str = "night",
|
shift_type: str = "night",
|
||||||
|
with_label: bool = True,
|
||||||
) -> str:
|
) -> str:
|
||||||
day_label = date.strftime("%a %b %-d")
|
day_label = date.strftime("%a %b %-d")
|
||||||
if date.strftime("%A") in WEEKEND_DAYS and shift_type in SHIFT_LABELS:
|
glyph = SHIFT_GLYPHS.get(shift_type)
|
||||||
day_label += f" {SHIFT_LABELS[shift_type]}"
|
if glyph:
|
||||||
|
if with_label and shift_type in SHIFT_LABELS:
|
||||||
|
day_label += f" {glyph} {SHIFT_LABELS[shift_type]}"
|
||||||
|
else:
|
||||||
|
day_label += f" {glyph}"
|
||||||
if is_today:
|
if is_today:
|
||||||
day_label = f"*{day_label} (today)*"
|
day_label = f"*{day_label} (today)*"
|
||||||
|
|
||||||
|
|
@ -175,9 +188,21 @@ def build_week_schedule(schedule, start_date: datetime | None = None) -> list[di
|
||||||
if source == "available" and not is_past:
|
if source == "available" and not is_past:
|
||||||
open_shifts.append((date_str, "day"))
|
open_shifts.append((date_str, "day"))
|
||||||
|
|
||||||
# After-hours (night) shift
|
# After-hours (night) shift. Weekdays are night-only, so the moon glyph
|
||||||
|
# carries the meaning; only weekends (where both shifts coexist) repeat
|
||||||
|
# the full time-range label.
|
||||||
ext, name, source = schedule.resolve_shift(date_str, day_name)
|
ext, name, source = schedule.resolve_shift(date_str, day_name)
|
||||||
lines.append(_format_shift_line(date, ext, name, source, is_today))
|
lines.append(
|
||||||
|
_format_shift_line(
|
||||||
|
date,
|
||||||
|
ext,
|
||||||
|
name,
|
||||||
|
source,
|
||||||
|
is_today,
|
||||||
|
"night",
|
||||||
|
with_label=day_name in WEEKEND_DAYS,
|
||||||
|
)
|
||||||
|
)
|
||||||
if source == "available" and not is_past:
|
if source == "available" and not is_past:
|
||||||
open_shifts.append((date_str, "night"))
|
open_shifts.append((date_str, "night"))
|
||||||
|
|
||||||
|
|
@ -245,11 +270,10 @@ def build_shift_change_message(
|
||||||
"""Build a channel notification for a shift change."""
|
"""Build a channel notification for a shift change."""
|
||||||
dt = datetime.strptime(date_str, "%Y-%m-%d")
|
dt = datetime.strptime(date_str, "%Y-%m-%d")
|
||||||
day_label = dt.strftime("%A, %b %-d")
|
day_label = dt.strftime("%A, %b %-d")
|
||||||
type_label = (
|
# Label the shift on every day, not just weekends: a single-line notification
|
||||||
f" ({SHIFT_LABELS.get(shift_type, shift_type)})"
|
# has room to spell out the after-hours (night) shift the static post conveys
|
||||||
if dt.strftime("%A") in WEEKEND_DAYS
|
# with the moon glyph.
|
||||||
else ""
|
type_label = f" ({SHIFT_LABELS.get(shift_type, shift_type)})"
|
||||||
)
|
|
||||||
|
|
||||||
if action == "picked_up":
|
if action == "picked_up":
|
||||||
text = f":white_check_mark: <@{user_id}> picked up the *{day_label}*{type_label} shift (Ext {ext})"
|
text = f":white_check_mark: <@{user_id}> picked up the *{day_label}*{type_label} shift (Ext {ext})"
|
||||||
|
|
@ -430,7 +454,7 @@ def build_help_blocks(is_admin: bool = False) -> list[dict]:
|
||||||
"`/oncall` — Show the two-week schedule\n"
|
"`/oncall` — Show the two-week schedule\n"
|
||||||
"`/oncall next` — Show the following two weeks\n"
|
"`/oncall next` — Show the following two weeks\n"
|
||||||
"`/oncall pick <date>` — Pick up a shift\n"
|
"`/oncall pick <date>` — Pick up a shift\n"
|
||||||
"`/oncall drop <date>` — Drop your shift (marks it open; locked within 24h of start — swap instead)\n"
|
"`/oncall drop <date> [day|night|holiday]` — Drop your shift (marks it open; locked within 24h of start — swap instead)\n"
|
||||||
"`/oncall swap <date> @person` — Swap your shift with someone\n"
|
"`/oncall swap <date> @person` — Swap your shift with someone\n"
|
||||||
"`/oncall register <extension>` — Link your Slack account to your extension\n"
|
"`/oncall register <extension>` — Link your Slack account to your extension\n"
|
||||||
"`/oncall pay` — Show last week's pay summary\n"
|
"`/oncall pay` — Show last week's pay summary\n"
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,23 @@ fine and still supported.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## v1.12.0 — June 26, 2026
|
||||||
|
|
||||||
|
**Clearer shift drops and an easier-to-read schedule.** Two small quality-of-life
|
||||||
|
improvements:
|
||||||
|
|
||||||
|
- **`/oncall drop` can now say which shift to let go.** If you hold more than one
|
||||||
|
shift on the same day — say a holiday day slot *and* that night's after-hours
|
||||||
|
shift, or a weekend day *and* night shift — dropping no longer guesses for you.
|
||||||
|
The bot lists what you hold and asks you to add `day`, `night`, or `holiday`
|
||||||
|
(for example `/oncall drop saturday night`), the same way picking one up
|
||||||
|
already works. Asking to drop a shift you don't actually hold is turned down
|
||||||
|
instead of quietly dropping the wrong one.
|
||||||
|
- **Night shifts stand out on the two-week post.** Day and night rows now carry
|
||||||
|
their own icons — a sun for the daytime shift and a moon for the after-hours
|
||||||
|
(night) shift — so the on-call row is easy to spot at a glance. Weekend rows,
|
||||||
|
which have both a day and a night shift, still spell out the full time range.
|
||||||
|
|
||||||
## v1.11.1 — June 17, 2026
|
## v1.11.1 — June 17, 2026
|
||||||
|
|
||||||
**Fix holiday call routing to 3CX.** The holiday router was calling the wrong 3CX
|
**Fix holiday call routing to 3CX.** The holiday router was calling the wrong 3CX
|
||||||
|
|
|
||||||
|
|
@ -364,6 +364,34 @@ def _find_employee_shift(schedule, date_str, day_name, employee_ext):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
_DROP_SHIFT_LABELS = {
|
||||||
|
"holiday": "holiday",
|
||||||
|
"day": "day (8am–5pm)",
|
||||||
|
"night": "night (5pm–8am)",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _droppable_shifts(schedule, date_str, day_name, employee_ext) -> list[str]:
|
||||||
|
"""Return the shift types the employee holds on a date and could drop.
|
||||||
|
|
||||||
|
Possible values, in display priority: ``holiday``, ``day``, ``night``. A
|
||||||
|
holiday day-slot supersedes a regular weekend day shift on its date.
|
||||||
|
"""
|
||||||
|
held = []
|
||||||
|
holiday_ctx = schedule.get_shift_context(date_str, day_name, "day")
|
||||||
|
if holiday_ctx["kind"] == "holiday":
|
||||||
|
if any(a["extension"] == employee_ext for a in holiday_ctx["assignees"]):
|
||||||
|
held.append("holiday")
|
||||||
|
elif day_name in WEEKEND_DAYS:
|
||||||
|
ext, _name, _source = schedule.resolve_shift(date_str, day_name, "day")
|
||||||
|
if ext == employee_ext:
|
||||||
|
held.append("day")
|
||||||
|
ext, _name, _source = schedule.resolve_shift(date_str, day_name, "night")
|
||||||
|
if ext == employee_ext:
|
||||||
|
held.append("night")
|
||||||
|
return held
|
||||||
|
|
||||||
|
|
||||||
def _refresh_schedule_post(schedule, schedule_channel, client):
|
def _refresh_schedule_post(schedule, schedule_channel, client):
|
||||||
"""Update the pinned schedule message in-place after a shift change."""
|
"""Update the pinned schedule message in-place after a shift change."""
|
||||||
channel = schedule_channel
|
channel = schedule_channel
|
||||||
|
|
@ -920,9 +948,11 @@ def _pick_holiday(
|
||||||
def _handle_drop(
|
def _handle_drop(
|
||||||
respond, schedule, user_id, text, channel_id, client, schedule_channel
|
respond, schedule, user_id, text, channel_id, client, schedule_channel
|
||||||
):
|
):
|
||||||
parts = text.split(maxsplit=1)
|
parts = text.split()
|
||||||
if len(parts) < 2:
|
if len(parts) < 2:
|
||||||
respond(text="Usage: `/oncall drop <date>` (e.g. `/oncall drop friday`)")
|
respond(
|
||||||
|
text="Usage: `/oncall drop <date> [day|night|holiday]` (e.g. `/oncall drop friday`)"
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
employee = schedule.get_employee_by_slack_id(user_id)
|
employee = schedule.get_employee_by_slack_id(user_id)
|
||||||
|
|
@ -930,10 +960,24 @@ def _handle_drop(
|
||||||
respond(text="You're not registered. Use `/oncall register <extension>` first.")
|
respond(text="You're not registered. Use `/oncall register <extension>` first.")
|
||||||
return
|
return
|
||||||
|
|
||||||
date = parse_date(parts[1])
|
# An optional trailing qualifier picks the shift to drop; the date itself may
|
||||||
|
# be multiple words (e.g. `jul 3`), so strip the qualifier off the end first.
|
||||||
|
tokens = parts[1:]
|
||||||
|
explicit_shift = None
|
||||||
|
if tokens[-1] in ("day", "night", "holiday"):
|
||||||
|
explicit_shift = tokens[-1]
|
||||||
|
tokens = tokens[:-1]
|
||||||
|
if not tokens:
|
||||||
|
respond(
|
||||||
|
text="Usage: `/oncall drop <date> [day|night|holiday]` (e.g. `/oncall drop friday`)"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
date_text = " ".join(tokens)
|
||||||
|
date = parse_date(date_text)
|
||||||
if not date:
|
if not date:
|
||||||
respond(
|
respond(
|
||||||
text=f"Couldn't parse date: `{parts[1]}`. Try: today, tomorrow, friday, 4/5, 7/3/26, jul 3, 2026-04-05"
|
text=f"Couldn't parse date: `{date_text}`. Try: today, tomorrow, friday, 4/5, 7/3/26, jul 3, 2026-04-05"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
@ -943,13 +987,43 @@ def _handle_drop(
|
||||||
return
|
return
|
||||||
|
|
||||||
day_name = date.strftime("%A")
|
day_name = date.strftime("%A")
|
||||||
|
held = _droppable_shifts(schedule, date_str, day_name, employee["extension"])
|
||||||
|
date_label = date.strftime("%A, %b %-d")
|
||||||
|
|
||||||
# A holiday slot the employee holds is dropped (released) ahead of regular
|
if explicit_shift:
|
||||||
# day/night shifts — holidays take priority on their date.
|
if explicit_shift not in held:
|
||||||
holiday_ctx = schedule.get_shift_context(date_str, day_name, "day")
|
if held:
|
||||||
if holiday_ctx["kind"] == "holiday" and any(
|
options = ", ".join(_DROP_SHIFT_LABELS[s] for s in held)
|
||||||
a["extension"] == employee["extension"] for a in holiday_ctx["assignees"]
|
respond(
|
||||||
):
|
text=(
|
||||||
|
f"You don't hold the {_DROP_SHIFT_LABELS[explicit_shift]} "
|
||||||
|
f"shift on *{date_label}*. You hold: {options}."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
ext, name, _source = schedule.resolve_shift(date_str, day_name)
|
||||||
|
respond(
|
||||||
|
text=f"That's not your shift — it belongs to {name} (Ext {ext})."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
target = explicit_shift
|
||||||
|
elif not held:
|
||||||
|
ext, name, _source = schedule.resolve_shift(date_str, day_name)
|
||||||
|
respond(text=f"That's not your shift — it belongs to {name} (Ext {ext}).")
|
||||||
|
return
|
||||||
|
elif len(held) > 1:
|
||||||
|
options = ", ".join(_DROP_SHIFT_LABELS[s] for s in held)
|
||||||
|
respond(
|
||||||
|
text=(
|
||||||
|
f"You hold more than one shift on *{date_label}*: {options}. "
|
||||||
|
f"Tell me which to drop: `/oncall drop {date_text} [{'|'.join(held)}]`."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
target = held[0]
|
||||||
|
|
||||||
|
if target == "holiday":
|
||||||
_drop_holiday(
|
_drop_holiday(
|
||||||
respond,
|
respond,
|
||||||
schedule,
|
schedule,
|
||||||
|
|
@ -962,15 +1036,33 @@ def _handle_drop(
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
found = _find_employee_shift(schedule, date_str, day_name, employee["extension"])
|
_drop_regular(
|
||||||
|
respond,
|
||||||
|
schedule,
|
||||||
|
user_id,
|
||||||
|
date,
|
||||||
|
date_str,
|
||||||
|
day_name,
|
||||||
|
target,
|
||||||
|
channel_id,
|
||||||
|
client,
|
||||||
|
schedule_channel,
|
||||||
|
)
|
||||||
|
|
||||||
if not found:
|
|
||||||
ext, name, _source = schedule.resolve_shift(date_str, day_name)
|
|
||||||
respond(text=f"That's not your shift — it belongs to {name} (Ext {ext}).")
|
|
||||||
return
|
|
||||||
|
|
||||||
ext, name, source, shift_type = found
|
|
||||||
|
|
||||||
|
def _drop_regular(
|
||||||
|
respond,
|
||||||
|
schedule,
|
||||||
|
user_id,
|
||||||
|
date,
|
||||||
|
date_str,
|
||||||
|
day_name,
|
||||||
|
shift_type,
|
||||||
|
channel_id,
|
||||||
|
client,
|
||||||
|
schedule_channel,
|
||||||
|
):
|
||||||
|
"""Release a regular (weekend day or night) shift, honouring the 24h lock."""
|
||||||
if _within_drop_lock(date_str, shift_type):
|
if _within_drop_lock(date_str, shift_type):
|
||||||
respond(
|
respond(
|
||||||
text=(
|
text=(
|
||||||
|
|
@ -981,6 +1073,8 @@ def _handle_drop(
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
ext, name, _source = schedule.resolve_shift(date_str, day_name, shift_type)
|
||||||
|
|
||||||
# No 3CX repoint here: a same-day shift is always inside the 24h lock above,
|
# No 3CX repoint here: a same-day shift is always inside the 24h lock above,
|
||||||
# so a drop that reaches this point is never today's active shift.
|
# so a drop that reaches this point is never today's active shift.
|
||||||
schedule.mark_open(date_str, shift_type)
|
schedule.mark_open(date_str, shift_type)
|
||||||
|
|
@ -995,7 +1089,12 @@ def _handle_drop(
|
||||||
)
|
)
|
||||||
|
|
||||||
blocks = build_shift_change_message(
|
blocks = build_shift_change_message(
|
||||||
user_id, date_str, "dropped", ext, name, shift_type=shift_type
|
user_id,
|
||||||
|
date_str,
|
||||||
|
"dropped",
|
||||||
|
ext,
|
||||||
|
name,
|
||||||
|
shift_type=shift_type,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
client.chat_postMessage(
|
client.chat_postMessage(
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,37 @@ class TestBuildWeekSchedule:
|
||||||
assert "pickup_2026-06-03" not in _all_action_ids(blocks)
|
assert "pickup_2026-06-03" not in _all_action_ids(blocks)
|
||||||
assert "Alice (Ext 114)" in blocks[1]["text"]["text"]
|
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")
|
@freeze_time("2026-06-01 12:00:00")
|
||||||
def test_holiday_on_weekday_renders_badge_and_open_slots(self, schedule, seed):
|
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,
|
# A holiday can land on a weekday (here a Thursday) and shows the badge,
|
||||||
|
|
@ -108,8 +139,8 @@ class TestBuildShiftChangeMessage:
|
||||||
)
|
)
|
||||||
text = blocks[0]["text"]["text"]
|
text = blocks[0]["text"]["text"]
|
||||||
assert "<@U1>" in text and "picked up" in text and "Ext 114" in text
|
assert "<@U1>" in text and "picked up" in text and "Ext 114" in text
|
||||||
# Weekday → no (Day/Night) label
|
# Weekday is night-only → the notification still labels the after-hours shift
|
||||||
assert "(Night" not in text
|
assert "Night (5pm" in text
|
||||||
|
|
||||||
def test_dropped_shows_available(self):
|
def test_dropped_shows_available(self):
|
||||||
blocks = build_shift_change_message(
|
blocks = build_shift_change_message(
|
||||||
|
|
|
||||||
|
|
@ -140,3 +140,117 @@ def test_drop_bad_date(slackbot_app, schedule, seed, respond, client, text_of):
|
||||||
def test_drop_usage(slackbot_app, schedule, respond, client, text_of):
|
def test_drop_usage(slackbot_app, schedule, respond, client, text_of):
|
||||||
slackbot_app._handle_drop(respond, schedule, "U_ALICE", "drop", "C1", client, None)
|
slackbot_app._handle_drop(respond, schedule, "U_ALICE", "drop", "C1", client, None)
|
||||||
assert "Usage" in text_of(respond)
|
assert "Usage" in text_of(respond)
|
||||||
|
|
||||||
|
|
||||||
|
# ── shift disambiguation (#134) ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@freeze_time(MON_0800)
|
||||||
|
def test_ambiguous_holiday_and_night_prompts(
|
||||||
|
slackbot_app, schedule, seed, respond, client, text_of
|
||||||
|
):
|
||||||
|
# Thursday 2026-06-04: Alice holds both a holiday slot and the night shift.
|
||||||
|
seed.roster("114", "Alice", slack_user_id="U_ALICE")
|
||||||
|
seed.holiday(
|
||||||
|
"2026-06-04",
|
||||||
|
slots=2,
|
||||||
|
label="Test Holiday",
|
||||||
|
assignees={"114": {"name": "Alice", "claimed_at": "x"}},
|
||||||
|
)
|
||||||
|
seed.weekly("Thursday", "114", "Alice")
|
||||||
|
slackbot_app._handle_drop(
|
||||||
|
respond, schedule, "U_ALICE", "drop 2026-06-04", "C1", client, None
|
||||||
|
)
|
||||||
|
msg = text_of(respond)
|
||||||
|
assert "more than one" in msg.lower()
|
||||||
|
assert "holiday" in msg.lower() and "night" in msg.lower()
|
||||||
|
# Nothing was dropped.
|
||||||
|
assert "114" in schedule.get_holiday("2026-06-04")["assignees"]
|
||||||
|
assert schedule.get_override("2026-06-04") is None
|
||||||
|
client.chat_postMessage.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@freeze_time(MON_0800)
|
||||||
|
def test_explicit_night_drops_night_not_holiday(
|
||||||
|
slackbot_app, schedule, seed, respond, client, text_of
|
||||||
|
):
|
||||||
|
seed.roster("114", "Alice", slack_user_id="U_ALICE")
|
||||||
|
seed.holiday(
|
||||||
|
"2026-06-04",
|
||||||
|
slots=2,
|
||||||
|
label="Test Holiday",
|
||||||
|
assignees={"114": {"name": "Alice", "claimed_at": "x"}},
|
||||||
|
)
|
||||||
|
seed.weekly("Thursday", "114", "Alice")
|
||||||
|
slackbot_app._handle_drop(
|
||||||
|
respond, schedule, "U_ALICE", "drop 2026-06-04 night", "C1", client, None
|
||||||
|
)
|
||||||
|
assert "dropped" in text_of(respond)
|
||||||
|
assert schedule.get_override("2026-06-04")["extension"] == "OPEN" # night opened
|
||||||
|
assert "114" in schedule.get_holiday("2026-06-04")["assignees"] # holiday kept
|
||||||
|
|
||||||
|
|
||||||
|
@freeze_time(MON_0800)
|
||||||
|
def test_explicit_holiday_drops_holiday_not_night(
|
||||||
|
slackbot_app, schedule, seed, respond, client, text_of
|
||||||
|
):
|
||||||
|
seed.roster("114", "Alice", slack_user_id="U_ALICE")
|
||||||
|
seed.holiday(
|
||||||
|
"2026-06-04",
|
||||||
|
slots=2,
|
||||||
|
label="Test Holiday",
|
||||||
|
assignees={"114": {"name": "Alice", "claimed_at": "x"}},
|
||||||
|
)
|
||||||
|
seed.weekly("Thursday", "114", "Alice")
|
||||||
|
slackbot_app._handle_drop(
|
||||||
|
respond, schedule, "U_ALICE", "drop 2026-06-04 holiday", "C1", client, None
|
||||||
|
)
|
||||||
|
assert "dropped your slot" in text_of(respond).lower()
|
||||||
|
assert "114" not in schedule.get_holiday("2026-06-04")["assignees"]
|
||||||
|
assert schedule.get_override("2026-06-04") is None # night untouched
|
||||||
|
|
||||||
|
|
||||||
|
@freeze_time(MON_0800)
|
||||||
|
def test_ambiguous_weekend_day_and_night_prompts(
|
||||||
|
slackbot_app, schedule, seed, respond, client, text_of
|
||||||
|
):
|
||||||
|
# Saturday 2026-06-06: Alice holds both the day and night shift.
|
||||||
|
seed.roster("200", "Alice", slack_user_id="U_ALICE")
|
||||||
|
seed.weekly("Saturday", "200", "Alice", shift_type="day")
|
||||||
|
seed.weekly("Saturday", "200", "Alice", shift_type="night")
|
||||||
|
slackbot_app._handle_drop(
|
||||||
|
respond, schedule, "U_ALICE", "drop saturday", "C1", client, None
|
||||||
|
)
|
||||||
|
assert "more than one" in text_of(respond).lower()
|
||||||
|
assert schedule.get_override("2026-06-06", "day") is None
|
||||||
|
assert schedule.get_override("2026-06-06") is None
|
||||||
|
|
||||||
|
|
||||||
|
@freeze_time(MON_0800)
|
||||||
|
def test_explicit_weekend_night_drops_night(
|
||||||
|
slackbot_app, schedule, seed, respond, client, text_of
|
||||||
|
):
|
||||||
|
seed.roster("200", "Alice", slack_user_id="U_ALICE")
|
||||||
|
seed.weekly("Saturday", "200", "Alice", shift_type="day")
|
||||||
|
seed.weekly("Saturday", "200", "Alice", shift_type="night")
|
||||||
|
slackbot_app._handle_drop(
|
||||||
|
respond, schedule, "U_ALICE", "drop saturday night", "C1", client, None
|
||||||
|
)
|
||||||
|
assert "dropped" in text_of(respond)
|
||||||
|
assert schedule.get_override("2026-06-06")["extension"] == "OPEN" # night opened
|
||||||
|
assert schedule.get_override("2026-06-06", "day") is None # day untouched
|
||||||
|
|
||||||
|
|
||||||
|
@freeze_time(MON_0800)
|
||||||
|
def test_explicit_qualifier_not_held_is_rejected(
|
||||||
|
slackbot_app, schedule, seed, respond, client, text_of
|
||||||
|
):
|
||||||
|
# Alice holds only the (weekday) night shift; asking to drop the day shift
|
||||||
|
# is rejected rather than silently dropping the night shift.
|
||||||
|
seed.roster("114", "Alice", slack_user_id="U_ALICE")
|
||||||
|
seed.weekly("Tuesday", "114", "Alice")
|
||||||
|
slackbot_app._handle_drop(
|
||||||
|
respond, schedule, "U_ALICE", "drop tomorrow day", "C1", client, None
|
||||||
|
)
|
||||||
|
assert "don't hold" in text_of(respond).lower()
|
||||||
|
assert schedule.get_override("2026-06-02") is None
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue