278 lines
9.1 KiB
Python
278 lines
9.1 KiB
Python
"""Tests for regen_action_docs.
|
|
|
|
Run with: pytest tools/docs -q (or: make docs-test)
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
import regen_action_docs as r
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Extraction: switch
|
|
# --------------------------------------------------------------------------- #
|
|
def test_extract_switch_single_label():
|
|
src = """
|
|
return switch (action) {
|
|
case "CreateSecret" -> handleCreate(req);
|
|
case "GetSecretValue" -> handleGet(req);
|
|
default -> error();
|
|
};
|
|
"""
|
|
assert r.extract_switch_actions(src) == ["CreateSecret", "GetSecretValue"]
|
|
|
|
|
|
def test_extract_switch_multi_label_arm():
|
|
# A single arm carrying several labels (real case: CloudFormation StackSets).
|
|
src = 'case "ListStackSets", "DescribeStackSet", "CreateStackSet" -> handleStackSets(req);'
|
|
assert r.extract_switch_actions(src) == [
|
|
"ListStackSets",
|
|
"DescribeStackSet",
|
|
"CreateStackSet",
|
|
]
|
|
|
|
|
|
def test_extract_switch_multiline_arm():
|
|
# Labels wrapped across lines (valid Java 14+ switch syntax) must all be captured,
|
|
# and the capture must stop at the arm's `->` rather than running into the next
|
|
# arm's `{ ... }` body. A miss here would be invisible: the doc and the regenerated
|
|
# output would agree on the omission and docs-check would stay green.
|
|
src = '''
|
|
return switch (action) {
|
|
case "CreateStackSet",
|
|
"DescribeStackSet",
|
|
"ListStackSets" -> handleStackSets(req);
|
|
case "GetTemplate" -> { return handleGet(req); }
|
|
};
|
|
'''
|
|
assert r.extract_switch_actions(src) == [
|
|
"CreateStackSet",
|
|
"DescribeStackSet",
|
|
"ListStackSets",
|
|
"GetTemplate",
|
|
]
|
|
|
|
|
|
def test_extract_switch_ignores_non_pascal_and_default():
|
|
src = '''
|
|
case "lowercase" -> a();
|
|
case null, default -> b();
|
|
case "Good" -> c();
|
|
'''
|
|
assert r.extract_switch_actions(src) == ["Good"]
|
|
|
|
|
|
def test_extract_switch_excludes_screaming_case_enum_labels():
|
|
# A handler may contain non-action switches (e.g. state enums). SCREAMING_CASE
|
|
# labels must not be picked up as actions; PascalCase actions still are.
|
|
src = '''
|
|
return switch (action) {
|
|
case "EnableRule" -> enable();
|
|
case "DisableRule" -> disable();
|
|
};
|
|
return switch (state.toUpperCase()) {
|
|
case "DISABLED" -> RuleState.DISABLED;
|
|
case "ENABLED" -> RuleState.ENABLED;
|
|
};
|
|
'''
|
|
assert r.extract_switch_actions(src) == ["EnableRule", "DisableRule"]
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Extraction: rest + overrides
|
|
# --------------------------------------------------------------------------- #
|
|
REST_CONTROLLER = """
|
|
@Path("/")
|
|
public class ThingController {
|
|
@POST
|
|
public Response createThing(Req r) { return ok(); }
|
|
|
|
@GET
|
|
public Response listThingsPost(Req r) { return ok(); }
|
|
|
|
@PUT
|
|
public Response updateV2Thing(Req r) { return ok(); }
|
|
}
|
|
"""
|
|
|
|
|
|
def test_extract_rest_ucfirsts_method_names():
|
|
assert r.extract_rest_actions(REST_CONTROLLER) == [
|
|
"CreateThing",
|
|
"ListThingsPost",
|
|
"UpdateV2Thing",
|
|
]
|
|
|
|
|
|
def test_extract_rest_requires_class_path():
|
|
no_path = REST_CONTROLLER.replace('@Path("/")\n', "")
|
|
assert r.extract_rest_actions(no_path) == []
|
|
|
|
|
|
def test_extract_rest_skips_comments_between_annotation_and_method():
|
|
# A Javadoc or inline comment sitting between the verb annotation and the method
|
|
# signature must not reset the lookahead and drop the action.
|
|
src = '''
|
|
@Path("/")
|
|
public class C {
|
|
@POST
|
|
/** Creates a thing. */
|
|
public Response createThing(Req r) { return ok(); }
|
|
|
|
@GET
|
|
// list them
|
|
public Response listThings(Req r) { return ok(); }
|
|
}
|
|
'''
|
|
assert r.extract_rest_actions(src) == ["CreateThing", "ListThings"]
|
|
|
|
|
|
def test_extract_rest_inherits_methods_from_superclass(tmp_path: Path):
|
|
base_src = """
|
|
public abstract class BaseController {
|
|
@GET
|
|
public Response getBaseItem() { return ok(); }
|
|
|
|
@DELETE
|
|
public Response deleteBaseItem() { return ok(); }
|
|
}
|
|
"""
|
|
sub_src = """
|
|
@Path("/items")
|
|
public class ChildController extends BaseController {
|
|
@POST
|
|
public Response createChildItem() { return ok(); }
|
|
}
|
|
"""
|
|
base_file = tmp_path / "BaseController.java"
|
|
sub_file = tmp_path / "ChildController.java"
|
|
base_file.write_text(base_src, encoding="utf-8")
|
|
sub_file.write_text(sub_src, encoding="utf-8")
|
|
|
|
actions = r.extract_rest_actions(sub_src, sub_file)
|
|
assert actions == ["GetBaseItem", "DeleteBaseItem", "CreateChildItem"]
|
|
|
|
|
|
def test_extract_actions_applies_rename_and_exclude(tmp_path: Path):
|
|
src = tmp_path / "ThingController.java"
|
|
src.write_text(REST_CONTROLLER)
|
|
entry = r.ServiceEntry(
|
|
service="thing",
|
|
doc=tmp_path / "thing.md",
|
|
sources=[r.Source(path=src, mode="rest")],
|
|
rename_actions={"UpdateV2Thing": "UpdateThing"},
|
|
exclude_actions=frozenset({"ListThingsPost"}),
|
|
)
|
|
assert r.extract_actions(entry) == ["CreateThing", "UpdateThing"]
|
|
|
|
|
|
def test_extract_actions_dedupes_across_sources(tmp_path: Path):
|
|
a = tmp_path / "A.java"
|
|
b = tmp_path / "B.java"
|
|
a.write_text('case "Foo" -> x();\ncase "Bar" -> y();')
|
|
b.write_text('case "Bar" -> y();\ncase "Baz" -> z();')
|
|
entry = r.ServiceEntry(
|
|
service="s",
|
|
doc=tmp_path / "s.md",
|
|
sources=[r.Source(path=a, mode="switch"), r.Source(path=b, mode="switch")],
|
|
)
|
|
assert r.extract_actions(entry) == ["Foo", "Bar", "Baz"]
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Table parsing: robustness
|
|
# --------------------------------------------------------------------------- #
|
|
def test_split_row_honors_escaped_pipe():
|
|
assert r._split_row(r"| `Foo` | a \| b |") == ["`Foo`", "a | b"]
|
|
|
|
|
|
def test_parse_marker_block_preserves_descriptions():
|
|
md = (
|
|
"pre\n"
|
|
f"{r.MARKER_START}\n"
|
|
"| Action | Description |\n"
|
|
"| --- | --- |\n"
|
|
"| `CreateSecret` | Create a new secret |\n"
|
|
"| `GetSecretValue` | - |\n"
|
|
f"{r.MARKER_END}\n"
|
|
"post\n"
|
|
)
|
|
_, descs, _ = r.parse_marker_block(md)
|
|
assert descs == {"CreateSecret": "Create a new secret", "GetSecretValue": ""}
|
|
|
|
|
|
def test_parse_marker_block_rejects_missing_markers():
|
|
with pytest.raises(ValueError):
|
|
r.parse_marker_block("no markers here")
|
|
|
|
|
|
def test_parse_table_rejects_non_table_line():
|
|
body = "| Action | Description |\n| --- | --- |\nstray prose line\n"
|
|
with pytest.raises(ValueError):
|
|
r._parse_table(body)
|
|
|
|
|
|
def test_parse_table_rejects_extra_columns():
|
|
# An unescaped pipe in a hand-edited description yields a 3-cell row; reject it
|
|
# loudly rather than silently dropping the trailing text.
|
|
body = "| Action | Description |\n| --- | --- |\n| `Foo` | a | b |\n"
|
|
with pytest.raises(ValueError):
|
|
r._parse_table(body)
|
|
|
|
|
|
def test_pipe_in_description_round_trips():
|
|
# A description containing a literal pipe survives render -> parse -> render
|
|
# unchanged (render escapes, the parser unescapes), and stays idempotent.
|
|
block = r.render_marker_block(["Foo"], {"Foo": "does a | b"})
|
|
assert r"\|" in block # escaped on render
|
|
md = f"x\n{r.MARKER_START}\n{block}{r.MARKER_END}\n"
|
|
_, descs, _ = r.parse_marker_block(md)
|
|
assert descs["Foo"] == "does a | b" # unescaped on parse
|
|
assert r.render_marker_block(["Foo"], descs) == block # idempotent
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Regeneration: preservation, placeholders, orphans, idempotency
|
|
# --------------------------------------------------------------------------- #
|
|
DOC = (
|
|
"# Svc\n\n## Supported Actions\n\n"
|
|
f"{r.MARKER_START}\n"
|
|
"| Action | Description |\n"
|
|
"| --- | --- |\n"
|
|
"| `CreateSecret` | Create a new secret |\n"
|
|
"| `OldAction` | gone from code |\n"
|
|
f"{r.MARKER_END}\n\n## Configuration\nprose\n"
|
|
)
|
|
|
|
|
|
def test_regenerate_preserves_known_and_placeholders_new():
|
|
actions = ["CreateSecret", "GetSecretValue"]
|
|
new, orphans = r.regenerate_doc_content(actions, DOC)
|
|
_, descs, _ = r.parse_marker_block(new)
|
|
assert descs["CreateSecret"] == "Create a new secret" # preserved
|
|
assert descs["GetSecretValue"] == "" # new -> placeholder
|
|
assert orphans == ["OldAction"] # dropped + reported
|
|
assert "OldAction" not in new
|
|
|
|
|
|
def test_regenerate_is_idempotent():
|
|
actions = ["CreateSecret", "GetSecretValue"]
|
|
once, _ = r.regenerate_doc_content(actions, DOC)
|
|
twice, _ = r.regenerate_doc_content(actions, once)
|
|
assert once == twice
|
|
|
|
|
|
def test_regenerate_touches_only_marker_block():
|
|
actions = ["CreateSecret", "GetSecretValue"]
|
|
new, _ = r.regenerate_doc_content(actions, DOC)
|
|
assert new.split(r.MARKER_START)[0] == DOC.split(r.MARKER_START)[0]
|
|
assert new.split(r.MARKER_END)[1] == DOC.split(r.MARKER_END)[1]
|
|
|
|
|
|
def test_render_marker_block_shape():
|
|
out = r.render_marker_block(["Foo"], {"Foo": "does foo"})
|
|
assert out == "| Action | Description |\n| --- | --- |\n| `Foo` | does foo |\n"
|