From 769a5f4286e05581ffb58e6c08ae6bd9e6a0449d Mon Sep 17 00:00:00 2001 From: olegbrok Date: Fri, 7 Aug 2026 09:13:18 -0700 Subject: [PATCH] Add deterministic staffing gather tool --- scripts/fixtures-543/bts_board_sample.json | 1 + scripts/fixtures-543/btsprobe_sample.json | 239 +++++++++ scripts/staffing_gather.py | 555 +++++++++++++++++++++ tests/test_staffing_gather.py | 319 ++++++++++++ 4 files changed, 1114 insertions(+) create mode 100644 scripts/fixtures-543/bts_board_sample.json create mode 100644 scripts/fixtures-543/btsprobe_sample.json create mode 100755 scripts/staffing_gather.py create mode 100644 tests/test_staffing_gather.py diff --git a/scripts/fixtures-543/bts_board_sample.json b/scripts/fixtures-543/bts_board_sample.json new file mode 100644 index 00000000..04676b9a --- /dev/null +++ b/scripts/fixtures-543/bts_board_sample.json @@ -0,0 +1 @@ +{"generated":"2026-08-06T22:10:47-07:00","source":"BUILD TEAM group calendar (active = any future series meeting)","members":[{"key":"aldo","name":"Aldo","weight":1.0,"load":2,"provisional":0},{"key":"daniel","name":"Daniel Martinez","weight":1.0,"load":7,"provisional":0},{"key":"danielson","name":"Danielson","weight":1.3,"load":5,"provisional":0},{"key":"julie","name":"Julie","weight":1.3,"load":7,"provisional":0}],"projects":[{"site":"(21 rows, {site,member,meetings:3} each — omitted in fixture)"}],"provisional":[]} diff --git a/scripts/fixtures-543/btsprobe_sample.json b/scripts/fixtures-543/btsprobe_sample.json new file mode 100644 index 00000000..1168f35b --- /dev/null +++ b/scripts/fixtures-543/btsprobe_sample.json @@ -0,0 +1,239 @@ +{ + "asUser": "", + "from": "2026-08-07", + "to": "2026-08-10", + "access": { + "danielson": true, + "daniel": true, + "aldo": true, + "julie": true, + "group": true + }, + "api": { + "danielson": { + "n": 3, + "sample": [ + { + "summary": "", + "start": "2026-08-07T10:00:00-07:00", + "transparency": null, + "eventType": "default" + }, + { + "summary": "", + "start": "2026-08-07T13:00:00-07:00", + "transparency": "transparent", + "eventType": "default" + }, + { + "summary": "", + "start": "2026-08-07T15:00:00-07:00", + "transparency": null, + "eventType": "default" + } + ] + }, + "daniel": { + "n": 3, + "sample": [ + { + "summary": "", + "start": "2026-08-07T13:00:00-07:00", + "transparency": "transparent", + "eventType": "default" + }, + { + "summary": "", + "start": "2026-08-07T11:00:00-07:00", + "transparency": null, + "eventType": "default" + }, + { + "summary": "", + "start": "2026-08-07T09:00:00-07:00", + "transparency": null, + "eventType": "default" + } + ] + }, + "aldo": { + "n": 1, + "sample": [ + { + "summary": "", + "start": "2026-08-07T08:30:00-07:00", + "transparency": "transparent", + "eventType": "default" + } + ] + }, + "julie": { + "n": 4, + "sample": [ + { + "summary": "", + "start": "2026-08-07T08:30:00-07:00", + "transparency": "transparent", + "eventType": "default" + }, + { + "summary": "", + "start": "2026-08-07T16:00:00-07:00", + "transparency": null, + "eventType": "default" + }, + { + "summary": "", + "start": "2026-08-07T09:30:00-07:00", + "transparency": null, + "eventType": "default" + } + ] + }, + "group": { + "n": 5, + "sample": [ + { + "summary": "", + "start": "2026-08-07T16:00:00-07:00", + "transparency": null, + "eventType": "default" + }, + { + "summary": "", + "start": "2026-08-07T09:30:00-07:00", + "transparency": null, + "eventType": "default" + }, + { + "summary": "", + "start": "2026-08-07T09:00:00-07:00", + "transparency": null, + "eventType": "default" + } + ] + } + }, + "freebusy": { + "aldo@posspecialists.com": "0 busy blocks", + "daniel@posspecialists.com": "2 busy blocks", + "julie@posspecialists.com": "3 busy blocks", + "c_a9f78bede90bb320f7110b1ece4dc19fab768ed7c47a848abaa87e3d1c34a9c0@group.calendar.google.com": "3 busy blocks", + "dmartinez@posspecialists.com": "2 busy blocks" + }, + "scan": { + "members": { + "danielson": { + "2026-08-07": [ + { + "type": "appt", + "h0": 10, + "h1": 12, + "rsvp": "" + }, + { + "type": "queue", + "h0": 13, + "h1": 17, + "rsvp": "accepted" + }, + { + "type": "build", + "h0": 15, + "h1": 16, + "rsvp": "needsAction" + } + ] + }, + "daniel": { + "2026-08-07": [ + { + "type": "queue", + "h0": 13, + "h1": 17, + "rsvp": "accepted" + }, + { + "type": "appt", + "h0": 11, + "h1": 12, + "rsvp": "accepted" + }, + { + "type": "build", + "h0": 9, + "h1": 10, + "rsvp": "accepted" + } + ] + }, + "aldo": { + "2026-08-07": [ + { + "type": "queue", + "h0": 8, + "h1": 13, + "rsvp": "accepted" + } + ] + }, + "julie": { + "2026-08-07": [ + { + "type": "queue", + "h0": 8, + "h1": 13, + "rsvp": "accepted" + }, + { + "type": "build", + "h0": 16, + "h1": 17, + "rsvp": "accepted" + }, + { + "type": "build", + "h0": 9, + "h1": 11, + "rsvp": "accepted" + }, + { + "type": "build", + "h0": 11, + "h1": 12, + "rsvp": "accepted" + } + ] + } + }, + "group": { + "2026-08-07": [ + { + "title": "", + "h0": 16, + "h1": 17 + }, + { + "title": "", + "h0": 9, + "h1": 11 + }, + { + "title": "", + "h0": 9, + "h1": 10 + }, + { + "title": "", + "h0": 11, + "h1": 12 + }, + { + "title": "", + "h0": 15, + "h1": 16 + } + ] + } + } +} diff --git a/scripts/staffing_gather.py b/scripts/staffing_gather.py new file mode 100755 index 00000000..546f42a4 --- /dev/null +++ b/scripts/staffing_gather.py @@ -0,0 +1,555 @@ +#!/usr/bin/env python3 +"""Build a deterministic staffing-availability matrix from existing sources. + +The script is intentionally standalone and uses only the Python standard +library. It consumes the scheduler-owned ``btsprobe`` classification output, +joins the board-owned build loads, and optionally joins Desk open-ticket +counts. It never calls an LLM and never reclassifies Calendar events. + +Examples: + + python3 scripts/staffing_gather.py \ + --btsprobe-file btsprobe.json \ + --board-file bts_board.json \ + --desk-file desk_counts.json + + python3 scripts/staffing_gather.py \ + --btsprobe-url 'https://example.invalid/exec?btsprobe=1&from=...&to=...' \ + --board-file bts_board.json + +JSON is always emitted on stdout. Any required-source, access, validation, or +join error is represented in ``_err`` and makes the process exit nonzero. +""" + +from __future__ import annotations + +import argparse +import json +import math +import os +import sys +from datetime import date, timedelta +from pathlib import Path +from typing import Any +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + +DESK_AGENT_IDS = { + "danielson": "637734000093213111", + "daniel": "637734000000355035", + "aldo": "637734000081777021", + "julie": "637734000000257004", +} +MEMBER_KEYS = tuple(DESK_AGENT_IDS) +SCAN_TYPES = ("appt", "build", "queue", "ooo") +WORKDAY_START = 8 +WORKDAY_END = 18 + + +def _source_state(mode: str) -> dict[str, Any]: + return {"mode": mode, "_err": []} + + +def _append_unique(items: list[str], message: str) -> None: + if message not in items: + items.append(message) + + +def _source_error(result: dict[str, Any], source: str, message: str) -> None: + _append_unique(result["sources"][source]["_err"], message) + + +def _member_error(result: dict[str, Any], member: str, message: str) -> None: + _append_unique(result["members"][member]["_err"], message) + + +def _is_number(value: object) -> bool: + return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value) + + +def _is_hour(value: object) -> bool: + return isinstance(value, int) and not isinstance(value, bool) and 0 <= value <= 24 + + +def _read_json_file(path: Path) -> tuple[Any | None, str | None]: + try: + with path.open(encoding="utf-8") as handle: + return json.load(handle), None + except OSError as exc: + return None, f"cannot read {path}: {exc}" + except (json.JSONDecodeError, UnicodeDecodeError) as exc: + return None, f"invalid JSON in {path}: {exc}" + + +def _read_json_url( + url: str, timeout: float, bearer_token: str | None = None +) -> tuple[Any | None, str | None]: + headers = {"Accept": "application/json", "User-Agent": "pinky-staffing-gather/1"} + if bearer_token: + headers["Authorization"] = f"Bearer {bearer_token}" + try: + request = Request( + url, + headers=headers, + ) + with urlopen(request, timeout=timeout) as response: # noqa: S310 - operator-supplied URL + return json.load(response), None + except (json.JSONDecodeError, UnicodeDecodeError) as exc: + return None, f"btsprobe URL returned invalid JSON: {exc}" + except (HTTPError, URLError, TimeoutError, OSError, ValueError) as exc: + return None, f"cannot fetch btsprobe URL: {exc}" + + +def _valid_day(value: object) -> bool: + if not isinstance(value, str): + return False + try: + date.fromisoformat(value) + except ValueError: + return False + return True + + +def _range_days(payload: dict[str, Any]) -> tuple[list[str], str | None]: + from_value = payload.get("from") + to_value = payload.get("to") + if not _valid_day(from_value) or not _valid_day(to_value): + return [], "from/to must be ISO dates" + start = date.fromisoformat(from_value) + end = date.fromisoformat(to_value) + if start >= end: + return [], "from must be earlier than exclusive to" + return [ + (start + timedelta(days=offset)).isoformat() for offset in range((end - start).days) + ], None + + +def _typed_day(blocks: list[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]: + typed = {block_type: [] for block_type in SCAN_TYPES} + typed["free"] = [] + for block in blocks: + typed[block["type"]].append(block) + + occupied = sorted( + (max(WORKDAY_START, block["h0"]), min(WORKDAY_END, block["h1"])) + for block in blocks + if block["h1"] > WORKDAY_START and block["h0"] < WORKDAY_END + ) + cursor = WORKDAY_START + for start, end in occupied: + if start > cursor: + typed["free"].append({"type": "free", "h0": cursor, "h1": start}) + cursor = max(cursor, end) + if cursor < WORKDAY_END: + typed["free"].append({"type": "free", "h0": cursor, "h1": WORKDAY_END}) + return typed + + +def _copy_member_days( + result: dict[str, Any], member: str, days: object, range_days: list[str] +) -> dict[str, dict[str, list[dict[str, Any]]]]: + copied: dict[str, dict[str, list[dict[str, Any]]]] = {} + if not isinstance(days, dict): + message = "calendar scan must be an object keyed by ISO date" + _member_error(result, member, message) + _source_error(result, "btsprobe", f"{member}: {message}") + return copied + + unexpected_days = sorted(set(days) - set(range_days)) + if unexpected_days: + message = f"calendar scan has days outside requested range: {', '.join(unexpected_days)}" + _member_error(result, member, message) + _source_error(result, "btsprobe", f"{member}: {message}") + + for day_key in range_days: + raw_blocks = days.get(day_key, []) + if not _valid_day(day_key): + message = f"calendar scan has invalid day key {day_key!r}" + _member_error(result, member, message) + _source_error(result, "btsprobe", f"{member}: {message}") + continue + if not isinstance(raw_blocks, list): + message = f"calendar day {day_key} must contain a block list" + _member_error(result, member, message) + _source_error(result, "btsprobe", f"{member}: {message}") + continue + + blocks: list[dict[str, Any]] = [] + day_is_valid = True + for index, raw_block in enumerate(raw_blocks): + if not isinstance(raw_block, dict): + message = f"calendar day {day_key} block {index} must be an object" + _member_error(result, member, message) + _source_error(result, "btsprobe", f"{member}: {message}") + day_is_valid = False + continue + block_type = raw_block.get("type") + h0 = raw_block.get("h0") + h1 = raw_block.get("h1") + if block_type not in SCAN_TYPES: + message = f"calendar day {day_key} block {index} has unknown type {block_type!r}" + _member_error(result, member, message) + _source_error(result, "btsprobe", f"{member}: {message}") + day_is_valid = False + continue + if not _is_hour(h0) or not _is_hour(h1) or h0 >= h1: + message = f"calendar day {day_key} block {index} has invalid h0/h1" + _member_error(result, member, message) + _source_error(result, "btsprobe", f"{member}: {message}") + day_is_valid = False + continue + + # Copy values exactly. In particular, h0/h1 already carry the + # scheduler's whole-hour rounding and must never be recomputed. + blocks.append(dict(raw_block)) + + # An empty input list is a successful read with no matches. A malformed + # non-empty list is omitted so invalid data can never manufacture free + # time from the blocks that happened to validate. + if day_is_valid: + copied[day_key] = _typed_day(blocks) + return copied + + +def _copy_group_days(result: dict[str, Any], days: object, range_days: list[str]) -> dict[str, Any]: + copied: dict[str, Any] = {} + if not isinstance(days, dict): + _source_error(result, "btsprobe", "group scan must be an object keyed by ISO date") + _append_unique(result["group"]["_err"], "group scan is unreadable") + return copied + + unexpected_days = sorted(set(days) - set(range_days)) + if unexpected_days: + message = f"group scan has days outside requested range: {', '.join(unexpected_days)}" + _source_error(result, "btsprobe", message) + _append_unique(result["group"]["_err"], message) + + for day_key in range_days: + raw_blocks = days.get(day_key, []) + if not isinstance(raw_blocks, list): + message = f"group scan day {day_key!r} must contain a block list" + _source_error(result, "btsprobe", message) + _append_unique(result["group"]["_err"], message) + continue + blocks: list[dict[str, Any]] = [] + day_is_valid = True + for index, raw_block in enumerate(raw_blocks): + if not isinstance(raw_block, dict): + message = f"group day {day_key} block {index} must be an object" + _source_error(result, "btsprobe", message) + _append_unique(result["group"]["_err"], message) + day_is_valid = False + continue + h0 = raw_block.get("h0") + h1 = raw_block.get("h1") + if not _is_hour(h0) or not _is_hour(h1) or h0 >= h1: + message = f"group day {day_key} block {index} has invalid h0/h1" + _source_error(result, "btsprobe", message) + _append_unique(result["group"]["_err"], message) + day_is_valid = False + continue + blocks.append(dict(raw_block)) + if day_is_valid: + copied[day_key] = {"blocks": blocks} + return copied + + +def _join_btsprobe(result: dict[str, Any], payload: object | None) -> None: + if payload is None: + _source_error(result, "btsprobe", "source payload is unavailable") + for member in MEMBER_KEYS: + _member_error(result, member, "calendar unavailable because btsprobe failed") + _append_unique(result["group"]["_err"], "group calendar unavailable") + return + if not isinstance(payload, dict): + _source_error(result, "btsprobe", "root value must be an object") + for member in MEMBER_KEYS: + _member_error(result, member, "calendar unavailable because btsprobe is invalid") + _append_unique(result["group"]["_err"], "group calendar unavailable") + return + + range_days, range_error = _range_days(payload) + result["range"] = {"from": payload.get("from"), "to": payload.get("to")} + if range_error: + _source_error(result, "btsprobe", range_error) + access = payload.get("access") + scan = payload.get("scan") + if not isinstance(access, dict): + _source_error(result, "btsprobe", "access diagnostics must be an object") + access = {} + if not isinstance(scan, dict): + _source_error(result, "btsprobe", "scan must be an object") + scan = {} + raw_members = scan.get("members") + if not isinstance(raw_members, dict): + _source_error(result, "btsprobe", "scan.members must be an object") + raw_members = {} + + unknown_members = sorted(set(raw_members) - set(MEMBER_KEYS)) + if unknown_members: + _source_error( + result, + "btsprobe", + f"unknown member keys in scan: {', '.join(unknown_members)}", + ) + + for member in MEMBER_KEYS: + if access.get(member) is not True: + message = "calendar access is not true; refusing to infer availability" + _member_error(result, member, message) + _source_error(result, "btsprobe", f"{member}: {message}") + continue + if member not in raw_members: + message = "calendar is absent from scan.members" + _member_error(result, member, message) + _source_error(result, "btsprobe", f"{member}: {message}") + continue + result["members"][member]["days"] = _copy_member_days( + result, member, raw_members[member], range_days + ) + + if access.get("group") is not True: + message = "group calendar access is not true" + _source_error(result, "btsprobe", message) + _append_unique(result["group"]["_err"], message) + elif "group" not in scan: + message = "group calendar is absent from scan" + _source_error(result, "btsprobe", message) + _append_unique(result["group"]["_err"], message) + else: + result["group"]["days"] = _copy_group_days(result, scan["group"], range_days) + + +def _join_board(result: dict[str, Any], payload: object | None) -> None: + if payload is None: + _source_error(result, "board", "source payload is unavailable") + for member in MEMBER_KEYS: + message = "build load unavailable because board source failed" + result["members"][member]["loads"]["build"] = { + "status": "error", + "_err": [message], + } + _member_error(result, member, message) + return + if not isinstance(payload, dict) or not isinstance(payload.get("members"), list): + _source_error(result, "board", "members must be a list") + _join_board(result, None) + return + + rows: dict[str, dict[str, Any]] = {} + for index, raw_row in enumerate(payload["members"]): + if not isinstance(raw_row, dict) or not isinstance(raw_row.get("key"), str): + _source_error(result, "board", f"members[{index}] has no string key") + continue + key = raw_row["key"] + if key not in MEMBER_KEYS: + _source_error(result, "board", f"unknown member key {key!r}") + continue + if key in rows: + _source_error(result, "board", f"duplicate member key {key!r}") + continue + rows[key] = raw_row + + for member in MEMBER_KEYS: + row = rows.get(member) + if row is None: + message = "member is absent from board" + _source_error(result, "board", f"{member}: {message}") + _member_error(result, member, message) + result["members"][member]["loads"]["build"] = { + "status": "error", + "_err": [message], + } + continue + load = row.get("load") + provisional = row.get("provisional") + weight = row.get("weight") + if ( + not _is_number(load) + or load < 0 + or not _is_number(provisional) + or provisional < 0 + or not _is_number(weight) + or weight <= 0 + ): + message = "load, provisional, and positive weight must be finite numbers" + _source_error(result, "board", f"{member}: {message}") + _member_error(result, member, message) + result["members"][member]["loads"]["build"] = { + "status": "error", + "_err": [message], + } + continue + result["members"][member]["loads"]["build"] = { + "status": "present", + "load": load, + "provisional": provisional, + "effective": load + provisional, + "weight": weight, + "weighted_rank": load / weight, + } + + +def _join_desk(result: dict[str, Any], payload: object | None, *, supplied: bool) -> None: + if not supplied: + result["sources"]["desk"]["status"] = "absent" + for member in MEMBER_KEYS: + result["members"][member]["loads"]["desk"] = {"status": "absent"} + return + + result["sources"]["desk"]["status"] = "present" + if payload is None: + _source_error(result, "desk", "supplied source payload is unavailable") + for member in MEMBER_KEYS: + message = "Desk load unavailable because supplied source failed" + result["members"][member]["loads"]["desk"] = { + "status": "error", + "_err": [message], + } + _member_error(result, member, message) + return + if not isinstance(payload, dict): + _source_error(result, "desk", "root value must be an object keyed by member key") + _join_desk(result, None, supplied=True) + return + + unknown_keys = sorted(set(payload) - set(MEMBER_KEYS)) + if unknown_keys: + _source_error(result, "desk", f"unknown member keys: {', '.join(unknown_keys)}") + + for member in MEMBER_KEYS: + if member not in payload: + message = "member is absent from supplied Desk counts" + _source_error(result, "desk", f"{member}: {message}") + _member_error(result, member, message) + result["members"][member]["loads"]["desk"] = { + "status": "error", + "_err": [message], + } + continue + count = payload[member] + if not isinstance(count, int) or isinstance(count, bool) or count < 0: + message = "open count must be a non-negative integer" + _source_error(result, "desk", f"{member}: {message}") + _member_error(result, member, message) + result["members"][member]["loads"]["desk"] = { + "status": "error", + "_err": [message], + } + continue + result["members"][member]["loads"]["desk"] = { + "status": "present", + "open_count": count, + } + + +def build_matrix( + btsprobe: object | None, + board: object | None, + desk: object | None = None, + *, + desk_supplied: bool = False, + source_errors: dict[str, list[str]] | None = None, + source_modes: dict[str, str] | None = None, +) -> dict[str, Any]: + """Validate and join already-decoded source values.""" + source_errors = source_errors or {} + source_modes = source_modes or {} + result: dict[str, Any] = { + "ok": False, + "range": {"from": None, "to": None}, + "members": {member: {"days": {}, "loads": {}, "_err": []} for member in MEMBER_KEYS}, + "group": {"days": {}, "_err": []}, + "sources": { + "btsprobe": _source_state(source_modes.get("btsprobe", "value")), + "board": _source_state(source_modes.get("board", "value")), + "desk": _source_state(source_modes.get("desk", "absent")), + }, + "_err": [], + } + for source, messages in source_errors.items(): + for message in messages: + _source_error(result, source, message) + + _join_btsprobe(result, btsprobe) + _join_board(result, board) + _join_desk(result, desk, supplied=desk_supplied) + + for source in ("btsprobe", "board", "desk"): + for message in result["sources"][source]["_err"]: + result["_err"].append(f"{source}: {message}") + result["ok"] = not result["_err"] + return result + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + btsprobe_source = parser.add_mutually_exclusive_group(required=True) + btsprobe_source.add_argument( + "--btsprobe-file", type=Path, help="Read previously fetched btsprobe JSON" + ) + btsprobe_source.add_argument( + "--btsprobe-url", help="Fetch btsprobe JSON from this complete URL" + ) + parser.add_argument("--board-file", type=Path, required=True) + parser.add_argument( + "--desk-file", + type=Path, + help="Optional JSON object mapping exact member keys to open counts", + ) + parser.add_argument("--timeout", type=float, default=30.0, help="URL timeout in seconds") + parser.add_argument( + "--btsprobe-token-env", + default="BTSPROBE_TOKEN", + help="Environment variable containing the optional btsprobe Bearer token", + ) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + source_errors: dict[str, list[str]] = {"btsprobe": [], "board": [], "desk": []} + + if args.btsprobe_file is not None: + btsprobe, error = _read_json_file(args.btsprobe_file) + btsprobe_mode = "file" + else: + btsprobe, error = _read_json_url( + args.btsprobe_url, + args.timeout, + os.environ.get(args.btsprobe_token_env), + ) + btsprobe_mode = "url" + if error: + source_errors["btsprobe"].append(error) + + board, error = _read_json_file(args.board_file) + if error: + source_errors["board"].append(error) + + desk = None + desk_supplied = args.desk_file is not None + if desk_supplied: + desk, error = _read_json_file(args.desk_file) + if error: + source_errors["desk"].append(error) + + result = build_matrix( + btsprobe, + board, + desk, + desk_supplied=desk_supplied, + source_errors=source_errors, + source_modes={ + "btsprobe": btsprobe_mode, + "board": "file", + "desk": "file" if desk_supplied else "absent", + }, + ) + json.dump(result, sys.stdout, indent=2, sort_keys=True) + sys.stdout.write("\n") + return 0 if result["ok"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_staffing_gather.py b/tests/test_staffing_gather.py new file mode 100644 index 00000000..9654cd6a --- /dev/null +++ b/tests/test_staffing_gather.py @@ -0,0 +1,319 @@ +import importlib.util +import io +import json +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).parents[1] +SCRIPT = ROOT / "scripts" / "staffing_gather.py" +FIXTURES = ROOT / "scripts" / "fixtures-543" +BTSPROBE = FIXTURES / "btsprobe_sample.json" +BOARD = FIXTURES / "bts_board_sample.json" + +SPEC = importlib.util.spec_from_file_location("staffing_gather", SCRIPT) +assert SPEC is not None and SPEC.loader is not None +staffing_gather = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(staffing_gather) + + +def _run(*args: object) -> tuple[subprocess.CompletedProcess[str], dict]: + completed = subprocess.run( + [sys.executable, str(SCRIPT), *(str(arg) for arg in args)], + cwd=ROOT, + text=True, + capture_output=True, + check=False, + ) + return completed, json.loads(completed.stdout) + + +def _base_args() -> tuple[object, ...]: + return ("--btsprobe-file", BTSPROBE, "--board-file", BOARD) + + +def _write_json(path: Path, value: object) -> Path: + path.write_text(json.dumps(value)) + return path + + +def test_fixture_join_preserves_btsprobe_blocks_and_board_loads(): + completed, result = _run(*_base_args()) + + assert completed.returncode == 0 + assert result["ok"] is True + day = result["members"]["danielson"]["days"]["2026-08-07"] + assert {"type": "build", "h0": 15, "h1": 16, "rsvp": "needsAction"} in day["build"] + assert set(day) == {"appt", "build", "queue", "ooo", "free"} + assert day["free"] == [ + {"type": "free", "h0": 8, "h1": 10}, + {"type": "free", "h0": 12, "h1": 13}, + {"type": "free", "h0": 17, "h1": 18}, + ] + + build_load = result["members"]["danielson"]["loads"]["build"] + assert build_load == { + "status": "present", + "load": 5, + "provisional": 0, + "effective": 5, + "weight": 1.3, + "weighted_rank": 5 / 1.3, + } + + +def test_effective_load_includes_provisional_but_weighted_rank_uses_load(tmp_path): + board = json.loads(BOARD.read_text()) + daniel = next(row for row in board["members"] if row["key"] == "daniel") + daniel["load"] = 7 + daniel["provisional"] = 3 + daniel["weight"] = 2 + board_path = _write_json(tmp_path / "board.json", board) + + completed, result = _run("--btsprobe-file", BTSPROBE, "--board-file", board_path) + + assert completed.returncode == 0 + build_load = result["members"]["daniel"]["loads"]["build"] + assert build_load["effective"] == 10 + assert build_load["weighted_rank"] == 3.5 + + +def test_desk_absence_is_marked_absent_instead_of_zero(): + completed, result = _run(*_base_args()) + + assert completed.returncode == 0 + assert result["sources"]["desk"] == { + "mode": "absent", + "status": "absent", + "_err": [], + } + for member in staffing_gather.MEMBER_KEYS: + assert result["members"][member]["loads"]["desk"] == {"status": "absent"} + + +def test_present_zero_desk_count_remains_distinct_from_absence(tmp_path): + desk = {"danielson": 0, "daniel": 4, "aldo": 2, "julie": 1} + desk_path = _write_json(tmp_path / "desk.json", desk) + + completed, result = _run(*_base_args(), "--desk-file", desk_path) + + assert completed.returncode == 0 + assert result["members"]["danielson"]["loads"]["desk"] == { + "status": "present", + "open_count": 0, + } + + +def test_access_false_fails_closed_for_only_that_member(tmp_path): + payload = json.loads(BTSPROBE.read_text()) + payload["access"]["julie"] = False + btsprobe_path = _write_json(tmp_path / "btsprobe.json", payload) + + completed, result = _run("--btsprobe-file", btsprobe_path, "--board-file", BOARD) + + assert completed.returncode != 0 + assert result["ok"] is False + assert result["members"]["julie"]["days"] == {} + assert any("access is not true" in error for error in result["members"]["julie"]["_err"]) + assert result["members"]["aldo"]["days"]["2026-08-07"]["queue"] + + +def test_unknown_desk_agent_id_does_not_cross_the_two_daniels(tmp_path): + desk = { + "danielson": 3, + "daniel": 8, + "aldo": 2, + "julie": 1, + staffing_gather.DESK_AGENT_IDS["daniel"]: 99, + } + desk_path = _write_json(tmp_path / "desk.json", desk) + + completed, result = _run(*_base_args(), "--desk-file", desk_path) + + assert completed.returncode != 0 + assert result["members"]["daniel"]["loads"]["desk"]["open_count"] == 8 + assert result["members"]["danielson"]["loads"]["desk"]["open_count"] == 3 + assert any("unknown member keys" in error for error in result["sources"]["desk"]["_err"]) + + +def test_unreadable_btsprobe_file_is_nonzero_and_marks_every_calendar(tmp_path): + missing = tmp_path / "missing-btsprobe.json" + + completed, result = _run("--btsprobe-file", missing, "--board-file", BOARD) + + assert completed.returncode != 0 + assert any("cannot read" in error for error in result["sources"]["btsprobe"]["_err"]) + for member in staffing_gather.MEMBER_KEYS: + assert result["members"][member]["days"] == {} + assert any("calendar unavailable" in error for error in result["members"][member]["_err"]) + + +def test_malformed_btsprobe_json_is_nonzero_and_marks_every_calendar(tmp_path): + malformed = tmp_path / "malformed-btsprobe.json" + malformed.write_text('{"scan":') + + completed, result = _run("--btsprobe-file", malformed, "--board-file", BOARD) + + assert completed.returncode != 0 + assert any("invalid JSON" in error for error in result["sources"]["btsprobe"]["_err"]) + for member in staffing_gather.MEMBER_KEYS: + assert result["members"][member]["days"] == {} + assert any("calendar unavailable" in error for error in result["members"][member]["_err"]) + + +def test_unreadable_board_is_nonzero_and_marks_every_build_load(tmp_path): + missing = tmp_path / "missing-board.json" + + completed, result = _run("--btsprobe-file", BTSPROBE, "--board-file", missing) + + assert completed.returncode != 0 + assert any("cannot read" in error for error in result["sources"]["board"]["_err"]) + for member in staffing_gather.MEMBER_KEYS: + assert result["members"][member]["loads"]["build"]["status"] == "error" + + +def test_json_null_sources_fail_loud_instead_of_looking_absent(tmp_path): + null_source = _write_json(tmp_path / "null.json", None) + + bts_completed, bts_result = _run("--btsprobe-file", null_source, "--board-file", BOARD) + board_completed, board_result = _run("--btsprobe-file", BTSPROBE, "--board-file", null_source) + desk_completed, desk_result = _run(*_base_args(), "--desk-file", null_source) + + assert bts_completed.returncode != 0 + assert "source payload is unavailable" in bts_result["sources"]["btsprobe"]["_err"] + assert board_completed.returncode != 0 + assert "source payload is unavailable" in board_result["sources"]["board"]["_err"] + assert desk_completed.returncode != 0 + assert "supplied source payload is unavailable" in desk_result["sources"]["desk"]["_err"] + + +def test_empty_matches_day_is_preserved_without_error(tmp_path): + payload = json.loads(BTSPROBE.read_text()) + payload["scan"]["members"]["aldo"]["2026-08-08"] = [] + btsprobe_path = _write_json(tmp_path / "btsprobe.json", payload) + + completed, result = _run("--btsprobe-file", btsprobe_path, "--board-file", BOARD) + + assert completed.returncode == 0 + assert result["members"]["aldo"]["days"]["2026-08-08"] == { + "appt": [], + "build": [], + "queue": [], + "ooo": [], + "free": [{"type": "free", "h0": 8, "h1": 18}], + } + assert result["members"]["aldo"]["_err"] == [] + + +def test_supplied_desk_file_requires_every_exact_member_key(tmp_path): + desk_path = _write_json(tmp_path / "desk.json", {"danielson": 3, "aldo": 2, "julie": 1}) + + completed, result = _run(*_base_args(), "--desk-file", desk_path) + + assert completed.returncode != 0 + assert result["members"]["daniel"]["loads"]["desk"]["status"] == "error" + assert any("absent" in error for error in result["members"]["daniel"]["_err"]) + + +def test_range_is_from_inclusive_and_to_exclusive(): + completed, result = _run(*_base_args()) + + assert completed.returncode == 0 + assert list(result["members"]["aldo"]["days"]) == [ + "2026-08-07", + "2026-08-08", + "2026-08-09", + ] + assert "2026-08-10" not in result["members"]["aldo"]["days"] + + +def test_event_starting_at_17_occupies_the_final_hour(tmp_path): + payload = json.loads(BTSPROBE.read_text()) + payload["scan"]["members"]["aldo"]["2026-08-07"] = [ + {"type": "appt", "h0": 17, "h1": 18, "rsvp": "accepted"} + ] + btsprobe_path = _write_json(tmp_path / "btsprobe.json", payload) + + completed, result = _run("--btsprobe-file", btsprobe_path, "--board-file", BOARD) + + assert completed.returncode == 0 + day = result["members"]["aldo"]["days"]["2026-08-07"] + assert day["appt"] == [{"type": "appt", "h0": 17, "h1": 18, "rsvp": "accepted"}] + assert day["free"] == [{"type": "free", "h0": 8, "h1": 17}] + + +def test_ooo_day_has_no_free_time(tmp_path): + payload = json.loads(BTSPROBE.read_text()) + payload["scan"]["members"]["julie"]["2026-08-07"] = [ + {"type": "ooo", "h0": 0, "h1": 24, "rsvp": "accepted"} + ] + btsprobe_path = _write_json(tmp_path / "btsprobe.json", payload) + + completed, result = _run("--btsprobe-file", btsprobe_path, "--board-file", BOARD) + + assert completed.returncode == 0 + day = result["members"]["julie"]["days"]["2026-08-07"] + assert day["ooo"] == [{"type": "ooo", "h0": 0, "h1": 24, "rsvp": "accepted"}] + assert day["free"] == [] + + +def test_non_whole_hour_block_fails_closed_instead_of_manufacturing_free(tmp_path): + payload = json.loads(BTSPROBE.read_text()) + payload["scan"]["members"]["aldo"]["2026-08-07"] = [ + {"type": "appt", "h0": 10.5, "h1": 12, "rsvp": "accepted"} + ] + btsprobe_path = _write_json(tmp_path / "btsprobe.json", payload) + + completed, result = _run("--btsprobe-file", btsprobe_path, "--board-file", BOARD) + + assert completed.returncode != 0 + assert "2026-08-07" not in result["members"]["aldo"]["days"] + assert any("invalid h0/h1" in error for error in result["members"]["aldo"]["_err"]) + + +def test_unknown_scan_type_fails_closed_instead_of_manufacturing_free(tmp_path): + payload = json.loads(BTSPROBE.read_text()) + payload["scan"]["members"]["aldo"]["2026-08-07"] = [ + {"type": "free", "h0": 8, "h1": 18, "rsvp": "accepted"} + ] + btsprobe_path = _write_json(tmp_path / "btsprobe.json", payload) + + completed, result = _run("--btsprobe-file", btsprobe_path, "--board-file", BOARD) + + assert completed.returncode != 0 + assert "2026-08-07" not in result["members"]["aldo"]["days"] + assert any("unknown type" in error for error in result["members"]["aldo"]["_err"]) + + +def test_group_calendar_remains_a_separate_stream(): + completed, result = _run(*_base_args()) + + assert completed.returncode == 0 + assert result["group"]["days"]["2026-08-07"]["blocks"] + assert "group" not in result["members"]["julie"]["days"]["2026-08-07"] + + +def test_url_fetch_uses_optional_bearer_token(monkeypatch): + seen = {} + + class Response(io.BytesIO): + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): + self.close() + + def fake_urlopen(request, *, timeout): + seen["authorization"] = request.get_header("Authorization") + seen["timeout"] = timeout + return Response(b'{"scan": {}}') + + monkeypatch.setattr(staffing_gather, "urlopen", fake_urlopen) + + payload, error = staffing_gather._read_json_url( + "https://example.invalid/exec?btsprobe=1", 12.0, "secret-token" + ) + + assert error is None + assert payload == {"scan": {}} + assert seen == {"authorization": "Bearer secret-token", "timeout": 12.0}