Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions psyflow/BlockUnit.py
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,12 @@ def run_trial(self, func: Callable, **kwargs) -> "BlockUnit":
**kwargs : dict
Additional keyword arguments forwarded to ``func``.
"""
if self.conditions is None:
raise RuntimeError(
f"BlockUnit '{self.block_id}' has no conditions. "
"Call generate_conditions() before run_trial()."
)

self.meta['block_start_time'] = core.getAbsTime()
self.logging_block_info()

Expand All @@ -273,6 +279,11 @@ def run_trial(self, func: Callable, **kwargs) -> "BlockUnit":

for i, cond in enumerate(self.conditions):
result = func(self.win, self.kb, self.settings, cond, **kwargs)
if not isinstance(result, dict):
raise TypeError(
f"Trial function {func.__name__!r} must return a dict, "
f"got {type(result).__name__!r}"
)
result.update({
"trial_index": i,
"block_id": self.block_id,
Expand Down Expand Up @@ -403,10 +414,14 @@ def logging_block_info(self) -> None:
"""
Log block metadata including ID, index, seed, trial count, and condition distribution.
"""
dist = {c: self.conditions.count(c) for c in set(self.conditions)} if self.conditions else {}
if self.conditions is not None and len(self.conditions) > 0:
conds = self.conditions
dist = {c: int(np.sum(conds == c)) for c in set(conds)}
else:
dist = {}
logging.data(f"[BlockUnit] Blockid: {self.block_id}")
logging.data(f"[BlockUnit] Blockidx: {self.block_idx}")
logging.data(f"[BlockUnit] Blockseed: {self.seed}")
logging.data(f"[BlockUnit] Blocktrial-N: {len(self.conditions)}")
logging.data(f"[BlockUnit] Blocktrial-N: {len(self.conditions) if self.conditions is not None else 0}")
logging.data(f"[BlockUnit] Blockdist: {dist}")
logging.data(f"[BlockUnit] Blockconditions: {self.conditions}")
2 changes: 1 addition & 1 deletion psyflow/SubInfo.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ def validate(self, responses) -> bool:
raise ValueError
if digits is not None and len(str(val)) != digits:
raise ValueError
except:
except Exception:
infoDlg = gui.Dlg()
infoDlg.addText(
self._local("invalid_input").format(field=self._local(field['name']))
Expand Down
77 changes: 77 additions & 0 deletions tests/test_BlockUnit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"""Tests for psyflow.BlockUnit."""

import sys
import unittest
from unittest.mock import MagicMock
from types import SimpleNamespace

try:
import numpy # noqa: F401
from psychopy import core, logging # noqa: F401
_HAS_DEPS = True
except ImportError:
_HAS_DEPS = False

if _HAS_DEPS:
from psyflow.BlockUnit import BlockUnit


def _make_settings(**overrides):
"""Create a minimal settings-like object."""
defaults = {
"trials_per_block": 3,
"block_seed": [42],
}
defaults.update(overrides)
return SimpleNamespace(**defaults)


def _make_block(**overrides):
"""Build a BlockUnit without calling __init__ (avoids PsychoPy window)."""
block = BlockUnit.__new__(BlockUnit)
defaults = dict(
block_id="test",
block_idx=0,
n_trials=3,
settings=_make_settings(),
win=MagicMock(),
kb=MagicMock(),
seed=42,
conditions=None,
results=[],
meta={},
_on_start=[],
_on_end=[],
)
defaults.update(overrides)
for k, v in defaults.items():
setattr(block, k, v)
return block


@unittest.skipUnless(_HAS_DEPS, "requires numpy and psychopy")
class TestRunTrialGuards(unittest.TestCase):
"""run_trial() should reject invalid state with clear errors."""

def test_conditions_none_raises_runtime_error(self):
block = _make_block(conditions=None)

with self.assertRaises(RuntimeError) as ctx:
block.run_trial(lambda win, kb, s, c: {"rt": 0.5})

self.assertIn("conditions", str(ctx.exception).lower())

def test_func_returning_none_raises_type_error(self):
block = _make_block(conditions=["A"])

def bad_trial_func(win, kb, settings, cond):
return None

with self.assertRaises(TypeError) as ctx:
block.run_trial(bad_trial_func)

self.assertIn("dict", str(ctx.exception).lower())


if __name__ == "__main__":
unittest.main()
68 changes: 68 additions & 0 deletions tests/test_SubInfo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
"""Tests for psyflow.SubInfo."""

import unittest
from unittest.mock import MagicMock

try:
from psychopy import gui # noqa: F401
_HAS_PSYCHOPY = True
except ImportError:
_HAS_PSYCHOPY = False

if _HAS_PSYCHOPY:
import psyflow.SubInfo as _subinfo_mod
from psyflow.SubInfo import SubInfo
_gui = _subinfo_mod.gui


def _make_subinfo(**field_map_overrides):
"""Build a SubInfo without calling __init__."""
info = SubInfo.__new__(SubInfo)
info.fields = [
{"name": "subject_id", "type": "int",
"constraints": {"min": 101, "max": 999, "digits": 3}}
]
info.field_map = {
"Participant Information": "Info",
"registration_failed": "Failed",
"invalid_input": "Bad: {field}",
}
info.field_map.update(field_map_overrides)
info.subject_data = None
return info


@unittest.skipUnless(_HAS_PSYCHOPY, "requires psychopy")
class TestCollect(unittest.TestCase):
"""SubInfo.collect() control-flow edge cases."""

def test_cancel_returns_none(self):
info = _make_subinfo()

mock_dlg = MagicMock()
mock_dlg.show.return_value = None
_gui.Dlg.return_value = mock_dlg

result = info.collect(exit_on_cancel=False)
self.assertIsNone(result)


@unittest.skipUnless(_HAS_PSYCHOPY, "requires psychopy")
class TestValidate(unittest.TestCase):
"""SubInfo.validate() error handling."""

def test_keyboard_interrupt_propagates(self):
info = _make_subinfo()

class ExplodingStr:
def __int__(self):
raise KeyboardInterrupt("simulated Ctrl+C")
def __str__(self):
return "boom"

with self.assertRaises(KeyboardInterrupt):
info.validate([ExplodingStr()])


if __name__ == "__main__":
unittest.main()
Loading