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
18 changes: 17 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,23 @@ Types encouraged: `feat:`, `fix:`, `docs:`, `refactor:`

## Testing

### Unit Tests
### Unit Test Principles

Write tests that earn their keep. Every test should justify its existence by verifying something non-obvious.

**What to test:**
- **Non-trivial transformations** — regex, recursion, type dispatch, multi-step logic where inputs interact in non-obvious ways
- **Boundary conditions** — empty inputs, None values, edge cases where behavior changes (e.g., leading digits in identifiers)
- **Regression tests for bugs** — every bug fix should include a test that would have caught it. The test should fail without the fix.
- **Integration-style unit tests** — testing a function end-to-end with real inputs is more valuable than mocking every internal call (e.g., test `generate_graphql_schema()` with a real schema, not with mocked sub-functions)

**What not to test:**
- **Trivial wrappers** — if a function is a one-liner delegating to another tested function (e.g., `".".join([f(x) for x in path.split(".")])`)
- **Framework behavior** — don't test that Pydantic validates types or that `re.sub` works; test *your* logic
- **Obvious guard clauses** — `if not s: return s` doesn't need its own test case unless the empty-input behavior is part of a documented contract
- **Coverage for coverage's sake** — a placeholder test like `assert module is not None` has no value; either write a real test or leave the file empty

### Unit Test Mechanics
- Tests are in `test/` mirroring source structure
- Uses pytest with `asyncio_mode = auto`
- Run specific module tests: `uv run pytest test/components/lif/<module>/`
Expand Down
34 changes: 26 additions & 8 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,21 +97,39 @@ pre-commit run --all-files

---

## Running Tests
## Testing

Tests are required for new features and bugfixes.
Tests are required for new features and bug fixes.

Run the test suite with:
### Running Tests

```bash
uxv pytest
uv run pytest # Run all tests
uv run pytest test/components/lif/<module>/ # Run tests for a specific component
uv run pytest test/path/to/test_file.py -v # Run a specific test file
```

Or if the virtual environment has been activated with `source .venv/bin.activate`:
### What Makes a Good Unit Test

```bash
pytest
```
Write tests that earn their keep. Every test should verify something non-obvious about the code's behavior.

**Do test:**
- **Non-trivial transformations** — regex logic, recursion, type dispatch, multi-step pipelines where inputs interact in unexpected ways
- **Boundary conditions** — empty inputs, None values, edge cases where behavior changes (e.g., leading digits in identifiers, CamelCase splitting combined with special characters)
- **Bug regressions** — every bug fix should include a test that fails without the fix and passes with it
- **End-to-end behavior** — testing a function with real inputs is more valuable than mocking every internal call. For example, test `generate_graphql_schema()` with an actual OpenAPI schema rather than mocking sub-functions.

**Don't test:**
- **Trivial wrappers** — if a function is a one-liner delegating to another tested function, testing it adds noise, not confidence
- **Framework behavior** — don't test that Pydantic validates types or that `re.sub` works; test *your* logic that uses them
- **Obvious guard clauses** — `if not s: return s` doesn't need its own test case unless the empty-input behavior is a documented contract
- **Coverage for coverage's sake** — a placeholder test like `assert module is not None` has no value. Either write a real test or leave the file empty.

**Guidelines:**
- Mirror the source structure in `test/` (`test/components/lif/<module>/test_core.py`)
- Group related tests into classes for organization
- Prefer real objects over mocks when practical — mocks can mask bugs in the interaction between components
- Use `pytest.raises()` with `match=` to verify specific error messages, not just error types

---

Expand Down
1 change: 1 addition & 0 deletions components/lif/string_utils/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ def safe_identifier(name: str) -> str:
s1 = re.sub(r"(.)([A-Z][a-z]+)", r"\1_\2", name)
s2 = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", s1)
safe = re.sub(r"\W|^(?=\d)", "_", s2)
safe = re.sub(r"_+", "_", safe) # Collapse consecutive underscores
return safe.lower()


Expand Down
89 changes: 86 additions & 3 deletions test/components/lif/string_utils/test_core.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,88 @@
from lif.string_utils import core
from datetime import date, datetime

from lif.string_utils import (
safe_identifier,
to_pascal_case,
to_snake_case,
to_camel_case,
dict_keys_to_snake,
dict_keys_to_camel,
convert_dates_to_strings,
to_value_enum_name,
)

def test_sample():
assert core is not None

class TestSafeIdentifier:
def test_special_chars_replaced(self):
assert safe_identifier("First Name") == "first_name"
assert safe_identifier("first-name") == "first_name"
assert safe_identifier("first$name") == "first_name"

def test_leading_digit_prefixed(self):
assert safe_identifier("123abc") == "_123abc"

def test_camel_case_boundaries(self):
assert safe_identifier("CamelCase") == "camel_case"
assert safe_identifier("camelCaseABC") == "camel_case_abc"

def test_consecutive_underscores_collapsed(self):
"""Regression: CamelCase splitting + special chars should not produce double underscores."""
assert safe_identifier("some--thing") == "some_thing"
assert safe_identifier("A__B") == "a_b"


class TestToPascalCase:
def test_from_snake_case(self):
assert to_pascal_case("hello_world") == "HelloWorld"

def test_multiple_parts(self):
assert to_pascal_case("hello", "world") == "HelloWorld"


class TestToSnakeCase:
def test_from_camel_and_pascal(self):
assert to_snake_case("CamelCase") == "camel_case"
assert to_snake_case("camelCase") == "camel_case"

def test_acronym_boundaries(self):
"""Acronyms like HTTP and ID should split correctly at boundaries."""
assert to_snake_case("HTTPServerID") == "http_server_id"


class TestToCamelCase:
def test_from_snake_case(self):
assert to_camel_case("hello_world") == "helloWorld"

def test_lowercases_first_letter(self):
assert to_camel_case("HelloWorld") == "helloWorld"


class TestDictKeyTransforms:
def test_nested_keys_to_snake(self):
"""Recursion through nested dicts and lists."""
data = {"FirstName": "Alice", "Address": {"zipCode": 12345}, "items": [{"itemID": 1}]}
out = dict_keys_to_snake(data)
assert out == {"first_name": "Alice", "address": {"zip_code": 12345}, "items": [{"item_id": 1}]}

def test_nested_keys_to_camel(self):
"""Recursion through nested dicts and lists."""
data = {"first_name": "Bob", "address": {"zip_code": 12345}, "items": [{"item_id": 1}]}
out = dict_keys_to_camel(data)
assert out == {"firstName": "Bob", "address": {"zipCode": 12345}, "items": [{"itemId": 1}]}


class TestConvertDatesToStrings:
def test_nested_dates_and_datetimes(self):
"""Type dispatch: date and datetime converted, other types preserved."""
d = date(2020, 1, 2)
dt = datetime(2020, 1, 2, 3, 4, 5)
obj = {"when": d, "arr": [dt, {"n": 1}]}
out = convert_dates_to_strings(obj)
assert out == {"when": d.isoformat(), "arr": [dt.isoformat(), {"n": 1}]}


class TestToValueEnumName:
def test_special_chars_and_leading_digits(self):
assert to_value_enum_name("in progress") == "IN_PROGRESS"
assert to_value_enum_name("done!") == "DONE_"
assert to_value_enum_name("123start") == "_123START"