Files
turnstone/tests/test_canonicalize_skill_string_list.py
Patrick Buckley 93af031fc7 feat(skills): parse and store Anthropic spec paths + uplift columns
Implements PR1 of issue #569 — parser + storage + admin UI for the
Anthropic Claude Code skill spec `paths:` SKILL.md frontmatter field
(glob patterns gating model-initiated autoload).  The autoload filter
that consumes `paths` is deferred to a follow-up PR pending the
workstream-CWD design discussion.

Migration 056 bundles three additional columns whose consumer PRs are
filed but not yet implemented:

* `hidden_from_menu` (boolean) — backs the spec's `user-invocable:
  false` (issue #571).
* `arguments` (JSON list) — backs spec `arguments:` named arg slots
  (issue #572).
* `argument_hint` (string) — autocomplete display string (issue #572).

The deferred columns surface in `SkillInfo` (response) so consumers can
read them, but are deliberately absent from `CreateSkillRequest` and
`UpdateSkillRequest` — the create/update handlers don't yet read them
and advertising a writable field the handler would silently ignore
would be an OpenAPI lie.

Surface
- Parser: `ParsedSkill.paths` populated from frontmatter; accepts the
  spec's YAML-list-or-CSV-string shape via the existing
  `_extract_list` machinery.
- Storage: 4 new columns on `prompt_templates`; `SKILL_MUTABLE`
  extended; `_row_to_dict` calls extended to cast the new bool;
  protocol + SQLite + PostgreSQL `create_prompt_template` signatures
  threaded.
- HTTP: admin create/update/install/parse handlers plumb `paths`
  through.  Pydantic schemas extended accordingly.
- Admin UI: `skill-paths` and `etm-paths` inputs on the create + edit
  modals; field map and read/write helpers wired across paste-parse,
  reset, create-send, edit-load, edit-send, and the readonly-disable
  list.

Notable
- `_canonicalize_skill_string_list` collapses the list-or-CSV-or-JSON-
  string normalization shared between admin_create_skill and
  admin_update_skill.  Treats `None` as no-value so a body containing
  `{"paths": null}` doesn't CSV-split through `str(None)` and store
  the literal `["None"]`.  Will back `arguments` once #572 wires its
  consumer.

Tests
- Parser: TestPaths covers YAML list, CSV string, empty, full-
  frontmatter integration (tests/test_skill_parser.py).
- Storage: round-trip suite covers create + read + update for each
  of the four new columns on both backends
  (tests/test_storage_skill_spec_uplift.py).
- Helper: focused unit tests for the canonicalizer including the
  regression-net case for the null-corruption bug
  (tests/test_canonicalize_skill_string_list.py).
- HTTP boundary: extended test_parses_full_frontmatter +
  test_parses_minimal_frontmatter to assert `paths` survives the
  admin parse endpoint.
2026-05-23 14:20:36 -07:00

73 lines
2.9 KiB
Python

"""Unit tests for ``_canonicalize_skill_string_list`` in console.server.
Backs the admin create/update handlers' wire-shape normalization for
JSON-array-string skill fields (``paths`` today; ``arguments`` once
#572 wires its consumer). The interesting cases are the corruption
paths the regex / split previously took on ``None``/empty input —
without explicit None handling, ``str(None)`` slid through CSV-split
and stored the literal value ``["None"]``.
"""
from __future__ import annotations
from turnstone.console.server import _canonicalize_skill_string_list
class TestList:
def test_list_of_strings(self) -> None:
assert _canonicalize_skill_string_list(["**/*.py", "docs/**"]) == '["**/*.py", "docs/**"]'
def test_list_trims_and_drops_blank(self) -> None:
assert _canonicalize_skill_string_list([" a ", "", "b"]) == '["a", "b"]'
def test_empty_list(self) -> None:
assert _canonicalize_skill_string_list([]) == "[]"
class TestJsonString:
def test_valid_json_array(self) -> None:
assert _canonicalize_skill_string_list('["**/*.py", "docs/**"]') == '["**/*.py", "docs/**"]'
def test_json_array_trims_elements(self) -> None:
assert _canonicalize_skill_string_list('[" a ", " ", "b"]') == '["a", "b"]'
def test_malformed_json_array_collapses_to_empty(self) -> None:
"""``[``-prefixed unparseable input → empty array, not CSV-split."""
assert _canonicalize_skill_string_list("[not-json") == "[]"
def test_non_array_json_treated_as_csv(self) -> None:
"""A string that doesn't start with ``[`` is CSV input by contract,
even if it happens to be valid JSON for some other shape. No commas
means a single-element list. Pragmatic over strict — the admin UI
round-trips through this helper and a typo doesn't need to error."""
assert _canonicalize_skill_string_list('{"k": "v"}') == '["{\\"k\\": \\"v\\"}"]'
class TestCsvString:
def test_comma_separated(self) -> None:
assert (
_canonicalize_skill_string_list("**/*.py, docs/**, src/api/**")
== '["**/*.py", "docs/**", "src/api/**"]'
)
def test_csv_trims_and_drops_blank(self) -> None:
assert _canonicalize_skill_string_list("a , , b ,") == '["a", "b"]'
def test_single_value_no_comma(self) -> None:
assert _canonicalize_skill_string_list("**/*.py") == '["**/*.py"]'
class TestNullAndEmpty:
def test_none_returns_empty_array(self) -> None:
"""``None`` must NOT corrupt into ``'["None"]'`` (regression bug-1/bug-2)."""
assert _canonicalize_skill_string_list(None) == "[]"
def test_empty_string(self) -> None:
assert _canonicalize_skill_string_list("") == "[]"
def test_whitespace_only_string(self) -> None:
assert _canonicalize_skill_string_list(" ") == "[]"
def test_empty_json_array_string(self) -> None:
assert _canonicalize_skill_string_list("[]") == "[]"