From 5693cd1c1ecf17be334675ac679fddfb5f60d27e Mon Sep 17 00:00:00 2001
From: Darren <3921919+pendingintent@users.noreply.github.com>
Date: Fri, 6 Feb 2026 11:23:33 -0500
Subject: [PATCH 1/6] Ordered timing fields in logical way; added hover help
text
---
src/soa_builder/web/templates/timings.html | 59 +++++++++++-----------
1 file changed, 30 insertions(+), 29 deletions(-)
diff --git a/src/soa_builder/web/templates/timings.html b/src/soa_builder/web/templates/timings.html
index c4c015b..483d7ea 100644
--- a/src/soa_builder/web/templates/timings.html
+++ b/src/soa_builder/web/templates/timings.html
@@ -1,6 +1,6 @@
{% extends 'base.html' %}
{% block content %}
-
Timings for SoA {{ soa_id }}
+Timings for SoA {{ soa_id }}
-
-
@@ -40,17 +40,17 @@ Timings for SoA {{ soa_id }}
-
-
+
+
- {% for sv in relative_to_from_options or [] %}
+ {% for sv in timing_type_options or [] %}
{% endfor %}
-
-
+
+
{% for name, instance_uid in (instance_options or {}).items() %}
@@ -58,11 +58,11 @@ Timings for SoA {{ soa_id }}
-
-
-
- {% for name, instance_uid in (instance_options or {}).items() %}
-
+
+
+
+ {% for sv in relative_to_from_options or [] %}
+
{% endfor %}
@@ -97,12 +97,12 @@ Timings for SoA {{ soa_id }}
Name |
Label |
Description |
- Type |
+ Rel From Instance |
Value |
Value Label |
- Rel To/From |
- Rel From Instance |
+ Type |
Rel To Instance |
+ Rel To/From |
Window Label |
Window Upper |
Window Lower |
@@ -117,6 +117,16 @@ Timings for SoA {{ soa_id }}
|
|
|
+
+
+
+ {% for name, instance_uid in (instance_options or {}).items() %}
+
+ {% endfor %}
+
+ |
+ |
+ |
@@ -125,8 +135,6 @@ Timings for SoA {{ soa_id }}
{% endfor %}
|
- |
- |
@@ -135,14 +143,7 @@ Timings for SoA {{ soa_id }}
{% endfor %}
|
-
-
-
- {% for name, instance_uid in (instance_options or {}).items() %}
-
- {% endfor %}
-
- |
+
From a84ff8d718068697a268776b20980b125831d4bf Mon Sep 17 00:00:00 2001
From: Darren <3921919+pendingintent@users.noreply.github.com>
Date: Fri, 6 Feb 2026 11:28:52 -0500
Subject: [PATCH 2/6] Changed Default Condition header
---
src/soa_builder/web/templates/instances.html | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/soa_builder/web/templates/instances.html b/src/soa_builder/web/templates/instances.html
index 0d376f2..93b529e 100644
--- a/src/soa_builder/web/templates/instances.html
+++ b/src/soa_builder/web/templates/instances.html
@@ -24,7 +24,7 @@ Scheduled Activity Instances for SoA {{ soa_id }}
-
+
{% for name, instance_uid in (instance_options or {}).items() %}
@@ -78,7 +78,7 @@ Scheduled Activity Instances for SoA {{ soa_id }}
Name |
Label |
Description |
- Default Condition |
+ Next Instance (default condition) |
Epoch |
Timeline ID |
Timeline Exit ID |
From 18e6102f2e32c5cad70521750afdef0e3e1142de Mon Sep 17 00:00:00 2001
From: Darren <3921919+pendingintent@users.noreply.github.com>
Date: Fri, 6 Feb 2026 11:36:13 -0500
Subject: [PATCH 3/6] Rearranged headers to make timings more intuitive
---
src/soa_builder/web/templates/timings.html | 53 +++++++++++-----------
1 file changed, 26 insertions(+), 27 deletions(-)
diff --git a/src/soa_builder/web/templates/timings.html b/src/soa_builder/web/templates/timings.html
index 483d7ea..378c04c 100644
--- a/src/soa_builder/web/templates/timings.html
+++ b/src/soa_builder/web/templates/timings.html
@@ -58,25 +58,16 @@
-
-
-
- {% for sv in relative_to_from_options or [] %}
-
- {% endfor %}
-
-
-
-
-
+
+
-
-
+
+
@@ -87,6 +78,15 @@
+
+
+
+ {% for sv in relative_to_from_options or [] %}
+
+ {% endfor %}
+
+
@@ -102,11 +102,11 @@ Value Label
| Type |
Rel To Instance |
- Rel To/From |
- Window Label |
- Window Upper |
Window Lower |
+ Window Upper |
+ Window Label |
Member of Timeline |
+ Rel To/From |
Save |
Delete |
@@ -135,15 +135,6 @@
-
-
- {% for sv in relative_to_from_options or [] %}
-
- {% endfor %}
-
- |
-
@@ -152,9 +143,9 @@ |
- |
|
+ |
+ |
@@ -163,6 +154,14 @@
+
+
+ {% for sv in relative_to_from_options or [] %}
+
+ {% endfor %}
+
+ |
|
From ef1a7ca620787588586c621b0505bb0782de3d52 Mon Sep 17 00:00:00 2001
From: Darren <3921919+pendingintent@users.noreply.github.com>
Date: Fri, 6 Feb 2026 12:42:13 -0500
Subject: [PATCH 4/6] Added ISO validation rules for windows, value as well as
all-or-nothing test for window values
---
src/soa_builder/web/routers/timings.py | 70 ++++----
src/soa_builder/web/schemas.py | 32 +++-
src/soa_builder/web/templates/timings.html | 40 ++++-
tests/test_routers_timings.py | 180 +++++++++++++++++++++
tests/test_timings.py | 12 +-
5 files changed, 291 insertions(+), 43 deletions(-)
diff --git a/src/soa_builder/web/routers/timings.py b/src/soa_builder/web/routers/timings.py
index b30370b..ef02813 100644
--- a/src/soa_builder/web/routers/timings.py
+++ b/src/soa_builder/web/routers/timings.py
@@ -3,6 +3,8 @@
import os
from typing import Optional
+from pydantic import ValidationError
+
from fastapi import APIRouter, HTTPException, Request, Form
from fastapi.responses import JSONResponse, HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
@@ -291,21 +293,25 @@ def ui_create_timing(
conn_c2.close()
except Exception:
rtf_code_uid = None
- payload = TimingCreate(
- name=name,
- label=label,
- description=description,
- type=code_uid,
- value=value,
- value_label=value_label,
- relative_to_from=rtf_code_uid,
- relative_from_schedule_instance=relative_from_schedule_instance,
- relative_to_schedule_instance=relative_to_schedule_instance,
- window_label=window_label,
- window_upper=window_upper,
- window_lower=window_lower,
- member_of_timeline=member_of_timeline,
- )
+ try:
+ payload = TimingCreate(
+ name=name,
+ label=label,
+ description=description,
+ type=code_uid,
+ value=value,
+ value_label=value_label,
+ relative_to_from=rtf_code_uid,
+ relative_from_schedule_instance=relative_from_schedule_instance,
+ relative_to_schedule_instance=relative_to_schedule_instance,
+ window_label=window_label,
+ window_upper=window_upper,
+ window_lower=window_lower,
+ member_of_timeline=member_of_timeline,
+ )
+ except ValidationError as exc:
+ msgs = "; ".join(e["msg"] for e in exc.errors())
+ raise HTTPException(400, f"Validation error: {msgs}")
create_timing(soa_id, payload)
return RedirectResponse(url=f"/ui/soa/{int(soa_id)}/timings", status_code=303)
@@ -623,21 +629,25 @@ def ui_update_timing(
# On any error, fall back to previous behavior (leave fields unchanged)
mapped_type = None if type_submission_value not in ("",) else ""
mapped_rtf = None if relative_to_from_submission_value not in ("",) else ""
- payload = TimingUpdate(
- name=name,
- label=label,
- description=description,
- type=mapped_type,
- value=value,
- value_label=value_label,
- relative_to_from=mapped_rtf,
- relative_from_schedule_instance=relative_from_schedule_instance,
- relative_to_schedule_instance=relative_to_schedule_instance,
- window_label=window_label,
- window_upper=window_upper,
- window_lower=window_lower,
- member_of_timeline=member_of_timeline,
- )
+ try:
+ payload = TimingUpdate(
+ name=name,
+ label=label,
+ description=description,
+ type=mapped_type,
+ value=value,
+ value_label=value_label,
+ relative_to_from=mapped_rtf,
+ relative_from_schedule_instance=relative_from_schedule_instance,
+ relative_to_schedule_instance=relative_to_schedule_instance,
+ window_label=window_label,
+ window_upper=window_upper,
+ window_lower=window_lower,
+ member_of_timeline=member_of_timeline,
+ )
+ except ValidationError as exc:
+ msgs = "; ".join(e["msg"] for e in exc.errors())
+ raise HTTPException(400, f"Validation error: {msgs}")
update_timing(soa_id, timing_id, payload)
return RedirectResponse(url=f"/ui/soa/{int(soa_id)}/timings", status_code=303)
diff --git a/src/soa_builder/web/schemas.py b/src/soa_builder/web/schemas.py
index 67695bf..b7892f5 100644
--- a/src/soa_builder/web/schemas.py
+++ b/src/soa_builder/web/schemas.py
@@ -1,6 +1,26 @@
+import re
from typing import List, Optional
-from pydantic import BaseModel
+from pydantic import BaseModel, field_validator
+
+# ISO 8601 duration pattern supporting both standard (-P2D) and USDM (P-2D) conventions
+_ISO8601_DURATION_RE = re.compile(
+ r"^-?P-?(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$"
+)
+
+
+def _validate_iso8601_duration(v: Optional[str]) -> Optional[str]:
+ if v is None:
+ return v
+ v = v.strip()
+ if not v:
+ return None
+ m = _ISO8601_DURATION_RE.match(v)
+ if not m or not any(m.group(i) is not None for i in range(1, 8)):
+ raise ValueError(
+ f"'{v}' is not a valid ISO 8601 duration (e.g. P1D, P2W, PT8H, -P2D)"
+ )
+ return v
class InstanceUpdate(BaseModel):
@@ -42,6 +62,11 @@ class TimingCreate(BaseModel):
window_lower: Optional[str] = None
member_of_timeline: Optional[str] = None
+ @field_validator("value", "window_lower", "window_upper", mode="before")
+ @classmethod
+ def check_iso8601_duration(cls, v: Optional[str]) -> Optional[str]:
+ return _validate_iso8601_duration(v)
+
class TimingUpdate(BaseModel):
name: str
@@ -58,6 +83,11 @@ class TimingUpdate(BaseModel):
window_lower: Optional[str] = None
member_of_timeline: Optional[str] = None
+ @field_validator("value", "window_lower", "window_upper", mode="before")
+ @classmethod
+ def check_iso8601_duration(cls, v: Optional[str]) -> Optional[str]:
+ return _validate_iso8601_duration(v)
+
class ScheduleTimelineCreate(BaseModel):
name: str
diff --git a/src/soa_builder/web/templates/timings.html b/src/soa_builder/web/templates/timings.html
index 378c04c..6b909d4 100644
--- a/src/soa_builder/web/templates/timings.html
+++ b/src/soa_builder/web/templates/timings.html
@@ -33,7 +33,7 @@
-
+
@@ -59,11 +59,11 @@
-
+
-
+
@@ -125,7 +125,7 @@
+ |
|
@@ -143,8 +143,8 @@ |
- |
+ |
+ |
|
@@ -178,4 +178,32 @@ No timings yet. |
{% endfor %}
+
+
{% endblock %}
diff --git a/tests/test_routers_timings.py b/tests/test_routers_timings.py
index a02bb49..8c9ec83 100644
--- a/tests/test_routers_timings.py
+++ b/tests/test_routers_timings.py
@@ -336,3 +336,183 @@ def test_timing_member_of_timeline():
assert resp.status_code == 201
data = resp.json()
assert data["member_of_timeline"] == timeline_uid
+
+
+def test_window_lower_rejects_non_iso8601():
+ """Test that window_lower rejects non-ISO 8601 values."""
+ r = client.post("/soa", json={"name": "Window Validate Lower"})
+ soa_id = r.json()["id"]
+
+ timing_data = {"name": "Bad Lower", "window_lower": "2 days"}
+ resp = client.post(f"/soa/{soa_id}/timings", json=timing_data)
+ assert resp.status_code == 422
+
+
+def test_window_upper_rejects_non_iso8601():
+ """Test that window_upper rejects non-ISO 8601 values."""
+ r = client.post("/soa", json={"name": "Window Validate Upper"})
+ soa_id = r.json()["id"]
+
+ timing_data = {"name": "Bad Upper", "window_upper": "+3"}
+ resp = client.post(f"/soa/{soa_id}/timings", json=timing_data)
+ assert resp.status_code == 422
+
+
+def test_window_accepts_valid_iso8601_durations():
+ """Test that various valid ISO 8601 durations are accepted."""
+ r = client.post("/soa", json={"name": "Window Valid ISO"})
+ soa_id = r.json()["id"]
+
+ valid_durations = [
+ ("P1D", "P1D"),
+ ("P2W", "P2W"),
+ ("PT8H", "PT8H"),
+ ("-P2D", "-P2D"),
+ ("P-2D", "P-2D"),
+ ("P1Y2M3D", "P1Y2M3D"),
+ ("PT1H30M", "PT1H30M"),
+ ]
+ for i, (lower, upper) in enumerate(valid_durations):
+ timing_data = {
+ "name": f"Valid Duration {i}",
+ "window_lower": lower,
+ "window_upper": upper,
+ "window_label": f"Window {i}",
+ }
+ resp = client.post(f"/soa/{soa_id}/timings", json=timing_data)
+ assert resp.status_code == 201, f"Failed for duration: {lower}/{upper}"
+ data = resp.json()
+ assert data["window_lower"] == lower
+ assert data["window_upper"] == upper
+
+
+def test_window_rejects_bare_p():
+ """Test that bare 'P' without any components is rejected."""
+ r = client.post("/soa", json={"name": "Window Bare P"})
+ soa_id = r.json()["id"]
+
+ timing_data = {"name": "Bare P", "window_lower": "P"}
+ resp = client.post(f"/soa/{soa_id}/timings", json=timing_data)
+ assert resp.status_code == 422
+
+
+def test_update_timing_rejects_invalid_window():
+ """Test that PATCH update also validates window fields."""
+ r = client.post("/soa", json={"name": "Update Window Validate"})
+ soa_id = r.json()["id"]
+
+ timing_resp = client.post(f"/soa/{soa_id}/timings", json={"name": "Good"})
+ timing_id = timing_resp.json()["id"]
+
+ resp = client.patch(
+ f"/soa/{soa_id}/timings/{timing_id}",
+ json={"name": "Good", "window_upper": "bad"},
+ )
+ assert resp.status_code == 422
+
+
+def test_ui_create_timing_rejects_invalid_window():
+ """Test that UI create form rejects non-ISO 8601 window values."""
+ r = client.post("/soa", json={"name": "UI Window Validate"})
+ soa_id = r.json()["id"]
+
+ form_data = {"name": "Bad Window", "window_lower": "not-iso"}
+ resp = client.post(
+ f"/ui/soa/{soa_id}/timings/create", data=form_data, follow_redirects=False
+ )
+ assert resp.status_code == 400
+
+
+def test_value_rejects_non_iso8601():
+ """Test that value rejects non-ISO 8601 values."""
+ r = client.post("/soa", json={"name": "Value Validate"})
+ soa_id = r.json()["id"]
+
+ timing_data = {"name": "Bad Value", "value": "5 days"}
+ resp = client.post(f"/soa/{soa_id}/timings", json=timing_data)
+ assert resp.status_code == 422
+
+
+def test_value_rejects_plain_number():
+ """Test that a plain number like '5' is rejected for value."""
+ r = client.post("/soa", json={"name": "Value Plain Number"})
+ soa_id = r.json()["id"]
+
+ timing_data = {"name": "Plain Num", "value": "5"}
+ resp = client.post(f"/soa/{soa_id}/timings", json=timing_data)
+ assert resp.status_code == 422
+
+
+def test_value_accepts_valid_iso8601_durations():
+ """Test that various valid ISO 8601 durations are accepted for value."""
+ r = client.post("/soa", json={"name": "Value Valid ISO"})
+ soa_id = r.json()["id"]
+
+ valid_values = ["P1D", "P2W", "PT8H", "-P2D", "P-2D", "P1Y2M3D", "PT1H30M"]
+ for i, val in enumerate(valid_values):
+ timing_data = {"name": f"Valid Value {i}", "value": val}
+ resp = client.post(f"/soa/{soa_id}/timings", json=timing_data)
+ assert resp.status_code == 201, f"Failed for value: {val}"
+ assert resp.json()["value"] == val
+
+
+def test_update_timing_rejects_invalid_value():
+ """Test that PATCH update also validates value field."""
+ r = client.post("/soa", json={"name": "Update Value Validate"})
+ soa_id = r.json()["id"]
+
+ timing_resp = client.post(
+ f"/soa/{soa_id}/timings", json={"name": "Good", "value": "P1D"}
+ )
+ timing_id = timing_resp.json()["id"]
+
+ resp = client.patch(
+ f"/soa/{soa_id}/timings/{timing_id}",
+ json={"name": "Good", "value": "not-a-duration"},
+ )
+ assert resp.status_code == 422
+
+
+def test_ui_create_timing_rejects_invalid_value():
+ """Test that UI create form rejects non-ISO 8601 value."""
+ r = client.post("/soa", json={"name": "UI Value Validate"})
+ soa_id = r.json()["id"]
+
+ form_data = {"name": "Bad Value", "value": "two weeks"}
+ resp = client.post(
+ f"/ui/soa/{soa_id}/timings/create", data=form_data, follow_redirects=False
+ )
+ assert resp.status_code == 400
+
+
+def test_window_all_or_none_accepts_all_three():
+ """Test that providing all three window fields is accepted."""
+ r = client.post("/soa", json={"name": "Window Complete"})
+ soa_id = r.json()["id"]
+
+ resp = client.post(
+ f"/soa/{soa_id}/timings",
+ json={
+ "name": "T1",
+ "window_lower": "-P1D",
+ "window_upper": "P2D",
+ "window_label": "Visit Window",
+ },
+ )
+ assert resp.status_code == 201
+
+
+def test_window_all_or_none_accepts_none():
+ """Test that providing no window fields is accepted."""
+ r = client.post("/soa", json={"name": "Window None"})
+ soa_id = r.json()["id"]
+
+ resp = client.post(
+ f"/soa/{soa_id}/timings",
+ json={"name": "No Window"},
+ )
+ assert resp.status_code == 201
+ data = resp.json()
+ assert data["window_lower"] is None
+ assert data["window_upper"] is None
+ assert data["window_label"] is None
diff --git a/tests/test_timings.py b/tests/test_timings.py
index 92f4b1e..415014c 100644
--- a/tests/test_timings.py
+++ b/tests/test_timings.py
@@ -52,14 +52,14 @@ def test_update_timing_mutable_fields_and_updated_fields():
"label": " Label X ",
"description": " Desc Y ",
"type": " relative ",
- "value": " 5 ",
+ "value": " P5D ",
"value_label": " days ",
"relative_to_from": " from ",
"relative_from_schedule_instance": " Arm A ",
"relative_to_schedule_instance": " Epoch 1 ",
"window_label": " Window ",
- "window_upper": " +2 ",
- "window_lower": " -1 ",
+ "window_upper": " P2D ",
+ "window_lower": " -P1D ",
}
r = client.patch(f"/soa/{soa_id}/timings/{tid}", json=payload)
assert r.status_code == 200, r.text
@@ -70,14 +70,14 @@ def test_update_timing_mutable_fields_and_updated_fields():
assert data["label"] == "Label X"
assert data["description"] == "Desc Y"
assert data["type"] == "relative"
- assert data["value"] == "5"
+ assert data["value"] == "P5D"
assert data["value_label"] == "days"
assert data["relative_to_from"] == "from"
assert data["relative_from_schedule_instance"] == "Arm A"
assert data["relative_to_schedule_instance"] == "Epoch 1"
assert data["window_label"] == "Window"
- assert data["window_upper"] == "+2"
- assert data["window_lower"] == "-1"
+ assert data["window_upper"] == "P2D"
+ assert data["window_lower"] == "-P1D"
# updated_fields must include changed keys
uf = set(data["updated_fields"])
From ee05f7ea42bd763a2272709f1b630d285f70af32 Mon Sep 17 00:00:00 2001
From: Darren <3921919+pendingintent@users.noreply.github.com>
Date: Sun, 8 Feb 2026 15:51:16 -0600
Subject: [PATCH 5/6] Removed numpy dependency
---
requirements.txt | 1 -
1 file changed, 1 deletion(-)
diff --git a/requirements.txt b/requirements.txt
index 7b7968f..9ced3b4 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -23,7 +23,6 @@ MarkupSafe==3.0.3
mccabe==0.7.0
mypy_extensions==1.1.0
nodeenv==1.9.1
-numpy==1.26.4
openpyxl==3.1.5
packaging==25.0
pandas==2.3.3
From f52d47cdb28dfbccd02d01be2c4a4cfa37bfcb59 Mon Sep 17 00:00:00 2001
From: Darren <3921919+pendingintent@users.noreply.github.com>
Date: Sun, 8 Feb 2026 15:58:26 -0600
Subject: [PATCH 6/6] Ensure window values are all-or-nothing
---
src/soa_builder/web/schemas.py | 40 ++++++++++++++++-
tests/test_routers_timings.py | 80 ++++++++++++++++++++++++++++++++++
2 files changed, 119 insertions(+), 1 deletion(-)
diff --git a/src/soa_builder/web/schemas.py b/src/soa_builder/web/schemas.py
index b7892f5..0132106 100644
--- a/src/soa_builder/web/schemas.py
+++ b/src/soa_builder/web/schemas.py
@@ -1,7 +1,7 @@
import re
from typing import List, Optional
-from pydantic import BaseModel, field_validator
+from pydantic import BaseModel, field_validator, model_validator
# ISO 8601 duration pattern supporting both standard (-P2D) and USDM (P-2D) conventions
_ISO8601_DURATION_RE = re.compile(
@@ -23,6 +23,30 @@ def _validate_iso8601_duration(v: Optional[str]) -> Optional[str]:
return v
+def _validate_window_all_or_none(
+ window_lower: Optional[str],
+ window_upper: Optional[str],
+ window_label: Optional[str],
+) -> None:
+ """Enforce that window_lower, window_upper, and window_label are all provided or all absent."""
+
+ def _present(v: Optional[str]) -> bool:
+ return v is not None and v.strip() != ""
+
+ provided = [_present(window_lower), _present(window_upper), _present(window_label)]
+ if any(provided) and not all(provided):
+ missing = []
+ if not _present(window_lower):
+ missing.append("window_lower")
+ if not _present(window_upper):
+ missing.append("window_upper")
+ if not _present(window_label):
+ missing.append("window_label")
+ raise ValueError(
+ f"Window fields are all-or-nothing: missing {', '.join(missing)}"
+ )
+
+
class InstanceUpdate(BaseModel):
name: Optional[str] = None
label: Optional[str] = None
@@ -67,6 +91,13 @@ class TimingCreate(BaseModel):
def check_iso8601_duration(cls, v: Optional[str]) -> Optional[str]:
return _validate_iso8601_duration(v)
+ @model_validator(mode="after")
+ def check_window_all_or_none(self) -> "TimingCreate":
+ _validate_window_all_or_none(
+ self.window_lower, self.window_upper, self.window_label
+ )
+ return self
+
class TimingUpdate(BaseModel):
name: str
@@ -88,6 +119,13 @@ class TimingUpdate(BaseModel):
def check_iso8601_duration(cls, v: Optional[str]) -> Optional[str]:
return _validate_iso8601_duration(v)
+ @model_validator(mode="after")
+ def check_window_all_or_none(self) -> "TimingUpdate":
+ _validate_window_all_or_none(
+ self.window_lower, self.window_upper, self.window_label
+ )
+ return self
+
class ScheduleTimelineCreate(BaseModel):
name: str
diff --git a/tests/test_routers_timings.py b/tests/test_routers_timings.py
index 8c9ec83..de52528 100644
--- a/tests/test_routers_timings.py
+++ b/tests/test_routers_timings.py
@@ -516,3 +516,83 @@ def test_window_all_or_none_accepts_none():
assert data["window_lower"] is None
assert data["window_upper"] is None
assert data["window_label"] is None
+
+
+def test_window_all_or_none_rejects_lower_only():
+ """Test that providing only window_lower is rejected."""
+ r = client.post("/soa", json={"name": "Window Lower Only"})
+ soa_id = r.json()["id"]
+
+ resp = client.post(
+ f"/soa/{soa_id}/timings",
+ json={"name": "T1", "window_lower": "-P1D"},
+ )
+ assert resp.status_code == 422
+
+
+def test_window_all_or_none_rejects_upper_only():
+ """Test that providing only window_upper is rejected."""
+ r = client.post("/soa", json={"name": "Window Upper Only"})
+ soa_id = r.json()["id"]
+
+ resp = client.post(
+ f"/soa/{soa_id}/timings",
+ json={"name": "T1", "window_upper": "P2D"},
+ )
+ assert resp.status_code == 422
+
+
+def test_window_all_or_none_rejects_label_only():
+ """Test that providing only window_label is rejected."""
+ r = client.post("/soa", json={"name": "Window Label Only"})
+ soa_id = r.json()["id"]
+
+ resp = client.post(
+ f"/soa/{soa_id}/timings",
+ json={"name": "T1", "window_label": "Visit Window"},
+ )
+ assert resp.status_code == 422
+
+
+def test_window_all_or_none_rejects_two_of_three():
+ """Test that providing two of three window fields is rejected."""
+ r = client.post("/soa", json={"name": "Window Two of Three"})
+ soa_id = r.json()["id"]
+
+ resp = client.post(
+ f"/soa/{soa_id}/timings",
+ json={"name": "T1", "window_lower": "-P1D", "window_upper": "P2D"},
+ )
+ assert resp.status_code == 422
+
+
+def test_window_all_or_none_update_rejects_partial():
+ """Test that PATCH update also enforces all-or-nothing window rule."""
+ r = client.post("/soa", json={"name": "Window Update Partial"})
+ soa_id = r.json()["id"]
+
+ timing_resp = client.post(f"/soa/{soa_id}/timings", json={"name": "T1"})
+ timing_id = timing_resp.json()["id"]
+
+ resp = client.patch(
+ f"/soa/{soa_id}/timings/{timing_id}",
+ json={"name": "T1", "window_lower": "-P1D"},
+ )
+ assert resp.status_code == 422
+
+
+def test_window_all_or_none_rejects_whitespace_only_label():
+ """Test that whitespace-only window_label counts as absent."""
+ r = client.post("/soa", json={"name": "Window Whitespace Label"})
+ soa_id = r.json()["id"]
+
+ resp = client.post(
+ f"/soa/{soa_id}/timings",
+ json={
+ "name": "T1",
+ "window_lower": "-P1D",
+ "window_upper": "P2D",
+ "window_label": " ",
+ },
+ )
+ assert resp.status_code == 422