diff --git a/src/store/architecture_projection.c b/src/store/architecture_projection.c index a83e45004..fab0137f4 100644 --- a/src/store/architecture_projection.c +++ b/src/store/architecture_projection.c @@ -194,39 +194,48 @@ static bool ap_cluster_edge(int type) { return type == 1 || type == 5 || type == 11; } +static bool ap_named(const char *text, size_t length, const char *name) { + return length == strlen(name) && !strncmp(text, name, length); +} + +static bool ap_suffix(const char *text, const char *suffix) { + size_t length = strlen(text), tail = strlen(suffix); + return length > tail && !strcmp(text + length - tail, suffix); +} + /* Conservative source conventions supplement the positive indexed is_test * flag. Non-test classification means no test evidence, not production use. - * Python discovers tests by file name (test_*.py, *_test.py, tests.py, - * conftest.py). A singular test/ directory there is often a shipped package - * (django/test/ is Django's public testing API), so for a .py file without a - * test name it is no signal on its own. *package then tells the caller that - * the indexed flag only repeats that directory rule (cbm_is_test_file) and - * must not decide alone either; it keeps one role per file. tests/ and - * __tests__/ stay directory signals, and test/ stays one for other files. */ + * tests/ and __tests__/ are test directories anywhere, and so is a top-level + * test/. Python discovers tests by file name (test_*.py, *_test.py, tests.py, + * conftest.py); a nested test/ there is often a shipped package (django/test/ + * is Django's public testing API), so for a .py file without a test name it + * is no signal on its own. *package then tells the caller that the indexed + * flag only repeats that directory rule (cbm_is_test_file) and must not + * decide alone either; it keeps one role per file. */ static bool ap_test_path(const char *path, bool *package) { const char *base = path; - bool directory = false, singular = false; + bool nested = false; for (const char *p = path;; p++) { if (*p == '/' || *p == '\\' || !*p) { size_t len = (size_t)(p - base); - if ((len == 5 && !strncmp(base, "tests", len)) || - (len == 9 && !strncmp(base, "__tests__", len))) - directory = true; - else if (len == 4 && !strncmp(base, "test", len)) - singular = true; + bool singular = ap_named(base, len, "test"); + if ((singular && base == path) || ap_named(base, len, "tests") || + ap_named(base, len, "__tests__")) + return true; + nested = nested || singular; if (!*p) break; base = p + 1; } } - size_t len = strlen(base); - bool python = len > 3 && !strcmp(base + len - 3, ".py"); - if (directory || !strncmp(base, "test_", 5) || strstr(base, "_test.") || - strstr(base, ".test.") || strstr(base, ".spec.") || - (python && (!strcmp(base, "tests.py") || !strcmp(base, "conftest.py")))) + if (ap_suffix(base, ".py")) { + if (!strcmp(base, "tests.py") || !strcmp(base, "conftest.py")) + return true; + *package = nested; + } else if (nested) return true; - *package = singular && python; - return singular && !python; + return !strncmp(base, "test_", 5) || strstr(base, "_test.") || strstr(base, ".test.") || + strstr(base, ".spec."); } static int ap_counts(ap_context *c, const char *project) { diff --git a/tests/test_architecture_projection.c b/tests/test_architecture_projection.c index be56cc568..088990a9f 100644 --- a/tests/test_architecture_projection.c +++ b/tests/test_architecture_projection.c @@ -1248,8 +1248,10 @@ TEST(projection_python_test_files_follow_name_conventions) { projection_node(store, "Class", "projection.app.tests.ViewTests", "app/tests.py", false); projection_node(store, "Function", "projection.app.models_test.check", "app/models_test.py", false); - projection_node(store, "Function", "projection.conftest.fixture", "test/conftest.py", false); + projection_node(store, "Function", "projection.app.conftest.fixture", "app/conftest.py", false); projection_node(store, "Function", "projection.check", "test/check.c", false); + /* A top-level test/ is a test suite, also for helpers without a test name. */ + projection_node(store, "Function", "projection.test.helpers.build", "test/helpers.py", false); projection_node(store, "Function", "projection.app.views.index", "app/views.py", false); /* django/test/ ships Django's testing API. The extractor flags its * functions from the directory alone; the class carries no flag. */ @@ -1268,7 +1270,8 @@ TEST(projection_python_test_files_follow_name_conventions) { yyjson_doc *doc = yyjson_read(json, strlen(json), 0); ASSERT_NOT_NULL(doc); const char *tests[] = {"projection.app.tests.ViewTests", "projection.app.models_test.check", - "projection.conftest.fixture", "projection.check"}; + "projection.app.conftest.fixture", "projection.check", + "projection.test.helpers.build"}; for (size_t i = 0; i < sizeof(tests) / sizeof(tests[0]); i++) { yyjson_val *part = projection_component_of(doc, tests[i]); ASSERT_NOT_NULL(part); @@ -1283,7 +1286,7 @@ TEST(projection_python_test_files_follow_name_conventions) { ASSERT_NOT_NULL(api); ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(api, "role")), "non_test"); ASSERT_EQ(yyjson_get_int(yyjson_obj_get(api, "member_count")), 2); - ASSERT_EQ(yyjson_arr_size(projection_field(doc, "components")), 6); + ASSERT_EQ(yyjson_arr_size(projection_field(doc, "components")), 7); yyjson_doc_free(doc); free(json); cbm_store_close(store);