diff --git a/duty_teller/handlers/group_duty_pin.py b/duty_teller/handlers/group_duty_pin.py index 74805e5..3750e09 100644 --- a/duty_teller/handlers/group_duty_pin.py +++ b/duty_teller/handlers/group_duty_pin.py @@ -246,7 +246,34 @@ async def pin_duty_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No loop = asyncio.get_running_loop() message_id = await loop.run_in_executor(None, _sync_get_message_id, chat_id) if message_id is None: - await update.message.reply_text(t(lang, "pin_duty.no_message")) + text = await loop.run_in_executor( + None, lambda: _get_duty_message_text_sync(lang) + ) + try: + msg = await context.bot.send_message(chat_id=chat_id, text=text) + except (BadRequest, Forbidden) as e: + logger.warning( + "Failed to send duty message for pin_duty chat_id=%s: %s", chat_id, e + ) + await update.message.reply_text(t(lang, "pin_duty.failed")) + return + pinned = False + try: + await context.bot.pin_chat_message( + chat_id=chat_id, + message_id=msg.message_id, + disable_notification=True, + ) + pinned = True + except (BadRequest, Forbidden) as e: + logger.warning("Failed to pin message for pin_duty chat_id=%s: %s", chat_id, e) + await loop.run_in_executor(None, _sync_save_pin, chat_id, msg.message_id) + next_end = await loop.run_in_executor(None, _get_next_shift_end_sync) + await _schedule_next_update(context.application, chat_id, next_end) + if pinned: + await update.message.reply_text(t(lang, "pin_duty.pinned")) + else: + await update.message.reply_text(t(lang, "pin_duty.could_not_pin_make_admin")) return try: await context.bot.pin_chat_message( diff --git a/tests/test_handlers_group_duty_pin.py b/tests/test_handlers_group_duty_pin.py index d93d14f..9ba63d8 100644 --- a/tests/test_handlers_group_duty_pin.py +++ b/tests/test_handlers_group_duty_pin.py @@ -306,8 +306,8 @@ async def test_pin_duty_cmd_group_pins_and_replies_pinned(): @pytest.mark.asyncio -async def test_pin_duty_cmd_no_message_id_replies_no_message(): - """pin_duty_cmd: no pin record (_sync_get_message_id -> None) -> reply pin_duty.no_message.""" +async def test_pin_duty_cmd_no_message_id_creates_sends_pins_saves_schedules_replies_pinned(): + """pin_duty_cmd: no pin record -> send_message, pin, save_pin, schedule, reply pinned.""" update = MagicMock() update.message = MagicMock() update.message.reply_text = AsyncMock() @@ -316,14 +316,97 @@ async def test_pin_duty_cmd_no_message_id_replies_no_message(): update.effective_chat.id = 100 update.effective_user = MagicMock() context = MagicMock() + context.bot = MagicMock() + new_msg = MagicMock() + new_msg.message_id = 42 + context.bot.send_message = AsyncMock(return_value=new_msg) + context.bot.pin_chat_message = AsyncMock() + context.application = MagicMock() + context.application.job_queue = MagicMock() + context.application.job_queue.get_jobs_by_name = MagicMock(return_value=[]) + context.application.job_queue.run_once = MagicMock() with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"): with patch.object(mod, "_sync_get_message_id", return_value=None): - with patch("duty_teller.handlers.group_duty_pin.t") as mock_t: - mock_t.return_value = "No message to pin" - await mod.pin_duty_cmd(update, context) - update.message.reply_text.assert_called_once_with("No message to pin") - mock_t.assert_called_with("en", "pin_duty.no_message") + with patch.object(mod, "_get_duty_message_text_sync", return_value="Duty text"): + with patch.object(mod, "_sync_save_pin") as mock_save: + with patch.object(mod, "_get_next_shift_end_sync", return_value=None): + with patch.object(mod, "_schedule_next_update", AsyncMock()): + with patch("duty_teller.handlers.group_duty_pin.t") as mock_t: + mock_t.return_value = "Pinned" + await mod.pin_duty_cmd(update, context) + context.bot.send_message.assert_called_once_with(chat_id=100, text="Duty text") + context.bot.pin_chat_message.assert_called_once_with( + chat_id=100, message_id=42, disable_notification=True + ) + mock_save.assert_called_once_with(100, 42) + update.message.reply_text.assert_called_once_with("Pinned") + mock_t.assert_called_with("en", "pin_duty.pinned") + + +@pytest.mark.asyncio +async def test_pin_duty_cmd_no_message_id_send_message_raises_replies_failed(): + """pin_duty_cmd: no pin record, send_message raises BadRequest -> reply pin_duty.failed.""" + update = MagicMock() + update.message = MagicMock() + update.message.reply_text = AsyncMock() + update.effective_chat = MagicMock() + update.effective_chat.type = "group" + update.effective_chat.id = 100 + update.effective_user = MagicMock() + context = MagicMock() + context.bot = MagicMock() + context.bot.send_message = AsyncMock(side_effect=BadRequest("Chat not found")) + context.application = MagicMock() + + with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"): + with patch.object(mod, "_sync_get_message_id", return_value=None): + with patch.object(mod, "_get_duty_message_text_sync", return_value="Duty"): + with patch.object(mod, "_sync_save_pin") as mock_save: + with patch.object(mod, "_schedule_next_update", AsyncMock()) as mock_schedule: + with patch("duty_teller.handlers.group_duty_pin.t") as mock_t: + mock_t.return_value = "Failed" + await mod.pin_duty_cmd(update, context) + update.message.reply_text.assert_called_once_with("Failed") + mock_t.assert_called_with("en", "pin_duty.failed") + mock_save.assert_not_called() + mock_schedule.assert_not_called() + + +@pytest.mark.asyncio +async def test_pin_duty_cmd_no_message_id_pin_raises_saves_and_replies_could_not_pin(): + """pin_duty_cmd: no pin record, pin_chat_message raises -> save pin, reply could_not_pin_make_admin.""" + update = MagicMock() + update.message = MagicMock() + update.message.reply_text = AsyncMock() + update.effective_chat = MagicMock() + update.effective_chat.type = "group" + update.effective_chat.id = 100 + update.effective_user = MagicMock() + context = MagicMock() + context.bot = MagicMock() + new_msg = MagicMock() + new_msg.message_id = 43 + context.bot.send_message = AsyncMock(return_value=new_msg) + context.bot.pin_chat_message = AsyncMock(side_effect=Forbidden("Not enough rights")) + context.application = MagicMock() + context.application.job_queue = MagicMock() + context.application.job_queue.get_jobs_by_name = MagicMock(return_value=[]) + context.application.job_queue.run_once = MagicMock() + + with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"): + with patch.object(mod, "_sync_get_message_id", return_value=None): + with patch.object(mod, "_get_duty_message_text_sync", return_value="Duty"): + with patch.object(mod, "_sync_save_pin") as mock_save: + with patch.object(mod, "_get_next_shift_end_sync", return_value=None): + with patch.object(mod, "_schedule_next_update", AsyncMock()): + with patch("duty_teller.handlers.group_duty_pin.t") as mock_t: + mock_t.return_value = "Make me admin to pin" + await mod.pin_duty_cmd(update, context) + context.bot.send_message.assert_called_once_with(chat_id=100, text="Duty") + mock_save.assert_called_once_with(100, 43) + update.message.reply_text.assert_called_once_with("Make me admin to pin") + mock_t.assert_called_with("en", "pin_duty.could_not_pin_make_admin") @pytest.mark.asyncio diff --git a/webapp/js/dayDetail.js b/webapp/js/dayDetail.js index 1ab3915..e439407 100644 --- a/webapp/js/dayDetail.js +++ b/webapp/js/dayDetail.js @@ -52,7 +52,9 @@ export function buildDayDetailContent(dateKey, duties, eventSummaries) { ? t(lang, "duty.today") + ", " + ddmm : ddmm; - const dutyList = (duties || []).filter((d) => d.event_type === "duty"); + const dutyList = (duties || []) + .filter((d) => d.event_type === "duty") + .sort((a, b) => new Date(a.start_at || 0) - new Date(b.start_at || 0)); const unavailableList = (duties || []).filter((d) => d.event_type === "unavailable"); const vacationList = (duties || []).filter((d) => d.event_type === "vacation"); const summaries = eventSummaries || []; diff --git a/webapp/js/dayDetail.test.js b/webapp/js/dayDetail.test.js new file mode 100644 index 0000000..d6b5870 --- /dev/null +++ b/webapp/js/dayDetail.test.js @@ -0,0 +1,41 @@ +/** + * Unit tests for buildDayDetailContent. + * Verifies dutyList is sorted by start_at before display. + */ + +import { describe, it, expect, beforeAll } from "vitest"; +import { buildDayDetailContent } from "./dayDetail.js"; + +describe("buildDayDetailContent", () => { + beforeAll(() => { + document.body.innerHTML = + '
' + + '
' + + '
' + + ''; + }); + + it("sorts duty list by start_at when input order is wrong", () => { + const dateKey = "2025-02-25"; + const duties = [ + { + event_type: "duty", + full_name: "Петров", + start_at: "2025-02-25T14:00:00", + end_at: "2025-02-25T18:00:00", + }, + { + event_type: "duty", + full_name: "Иванов", + start_at: "2025-02-25T09:00:00", + end_at: "2025-02-25T14:00:00", + }, + ]; + const html = buildDayDetailContent(dateKey, duties, []); + expect(html).toContain("Иванов"); + expect(html).toContain("Петров"); + const ivanovPos = html.indexOf("Иванов"); + const petrovPos = html.indexOf("Петров"); + expect(ivanovPos).toBeLessThan(petrovPos); + }); +}); diff --git a/webapp/js/hints.js b/webapp/js/hints.js index 8cfec74..ce6f98f 100644 --- a/webapp/js/hints.js +++ b/webapp/js/hints.js @@ -115,7 +115,14 @@ function buildDutyItemTimePrefix(item, idx, total, hintDay, sep, fromLabel, toLa if (endSameDay && endHHMM && endHHMM !== startHHMM) { timePrefix += " " + toLabel + sep + endHHMM; } + } else if (startSameDay && startHHMM) { + /* First of multiple, but starts today — show full range */ + timePrefix = fromLabel + sep + startHHMM; + if (endSameDay && endHHMM && endHHMM !== startHHMM) { + timePrefix += " " + toLabel + sep + endHHMM; + } } else if (endHHMM) { + /* Continuation from previous day — only end time */ timePrefix = toLabel + sep + endHHMM; } } else if (idx > 0) { diff --git a/webapp/js/hints.test.js b/webapp/js/hints.test.js new file mode 100644 index 0000000..744afb1 --- /dev/null +++ b/webapp/js/hints.test.js @@ -0,0 +1,99 @@ +/** + * Unit tests for getDutyMarkerRows and buildDutyItemTimePrefix logic. + * Covers: sorting order preservation, idx=0 with total>1 and startSameDay. + */ + +import { describe, it, expect, beforeAll } from "vitest"; +import { getDutyMarkerRows } from "./hints.js"; + +const FROM = "from"; +const TO = "until"; +const SEP = "\u00a0"; + +describe("getDutyMarkerRows", () => { + beforeAll(() => { + document.body.innerHTML = '
'; + }); + + it("preserves input order (caller must sort by start_at before passing)", () => { + const hintDay = "2025-02-25"; + const duties = [ + { + full_name: "Иванов", + start_at: "2025-02-25T14:00:00", + end_at: "2025-02-25T18:00:00", + }, + { + full_name: "Петров", + start_at: "2025-02-25T09:00:00", + end_at: "2025-02-25T14:00:00", + }, + ]; + const rows = getDutyMarkerRows(duties, hintDay, SEP, FROM, TO); + expect(rows).toHaveLength(2); + expect(rows[0].fullName).toBe("Иванов"); + expect(rows[1].fullName).toBe("Петров"); + }); + + it("first of multiple with startSameDay shows full range (from HH:MM to HH:MM)", () => { + const hintDay = "2025-02-25"; + const duties = [ + { + full_name: "Иванов", + start_at: "2025-02-25T09:00:00", + end_at: "2025-02-25T14:00:00", + }, + { + full_name: "Петров", + start_at: "2025-02-25T14:00:00", + end_at: "2025-02-25T18:00:00", + }, + ].sort((a, b) => new Date(a.start_at) - new Date(b.start_at)); + + const rows = getDutyMarkerRows(duties, hintDay, SEP, FROM, TO); + expect(rows).toHaveLength(2); + expect(rows[0].fullName).toBe("Иванов"); + expect(rows[0].timePrefix).toContain("09:00"); + expect(rows[0].timePrefix).toContain("14:00"); + expect(rows[0].timePrefix).toContain(FROM); + expect(rows[0].timePrefix).toContain(TO); + }); + + it("first of multiple continuation from previous day shows only end time", () => { + const hintDay = "2025-02-25"; + const duties = [ + { + full_name: "Иванов", + start_at: "2025-02-24T22:00:00", + end_at: "2025-02-25T06:00:00", + }, + { + full_name: "Петров", + start_at: "2025-02-25T09:00:00", + end_at: "2025-02-25T14:00:00", + }, + ].sort((a, b) => new Date(a.start_at) - new Date(b.start_at)); + + const rows = getDutyMarkerRows(duties, hintDay, SEP, FROM, TO); + expect(rows).toHaveLength(2); + expect(rows[0].fullName).toBe("Иванов"); + expect(rows[0].timePrefix).not.toContain(FROM); + expect(rows[0].timePrefix).toContain(TO); + expect(rows[0].timePrefix).toContain("06:00"); + }); + + it("multiple duties in one day — correct order when input is pre-sorted", () => { + const hintDay = "2025-02-25"; + const duties = [ + { full_name: "A", start_at: "2025-02-25T09:00:00", end_at: "2025-02-25T12:00:00" }, + { full_name: "B", start_at: "2025-02-25T12:00:00", end_at: "2025-02-25T15:00:00" }, + { full_name: "C", start_at: "2025-02-25T15:00:00", end_at: "2025-02-25T18:00:00" }, + ].sort((a, b) => new Date(a.start_at) - new Date(b.start_at)); + + const rows = getDutyMarkerRows(duties, hintDay, SEP, FROM, TO); + expect(rows.map((r) => r.fullName)).toEqual(["A", "B", "C"]); + expect(rows[0].timePrefix).toContain("09:00"); + expect(rows[1].timePrefix).toContain("12:00"); + expect(rows[2].timePrefix).toContain("15:00"); + }); +}); diff --git a/webapp/package.json b/webapp/package.json new file mode 100644 index 0000000..ba2d76c --- /dev/null +++ b/webapp/package.json @@ -0,0 +1,12 @@ +{ + "name": "duty-teller-webapp", + "type": "module", + "scripts": { + "test": "vitest run", + "test:watch": "vitest" + }, + "devDependencies": { + "happy-dom": "^15.0.0", + "vitest": "^2.0.0" + } +} diff --git a/webapp/vitest.config.js b/webapp/vitest.config.js new file mode 100644 index 0000000..021fc7a --- /dev/null +++ b/webapp/vitest.config.js @@ -0,0 +1,6 @@ +export default { + test: { + environment: "happy-dom", + include: ["js/**/*.test.js"], + }, +};