Skip to content

Commit 7286ba3

Browse files
Merge pull request #87 from CodandoTV/chore/auto-generate-tests-workflow
chore: auto generate unit tests via Claude API
2 parents 0305399 + 605fa23 commit 7286ba3

3 files changed

Lines changed: 518 additions & 0 deletions

File tree

.claude/notes/progress.md

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
# CraftD — Notas de Progresso (Claude)
2+
3+
> Pasta criada por Claude Code para registrar decisões, análises e o que foi feito em cada sessão.
4+
5+
---
6+
7+
## Sessão 1 — 2026-03-28
8+
9+
### Objetivo
10+
Analisar o módulo `android_kmp/craftd-core`, mapear cobertura de testes e criar infraestrutura para geração automática de testes via GitHub Actions + Claude API.
11+
12+
---
13+
14+
### 1. Análise de cobertura — `android_kmp/craftd-core`
15+
16+
#### Arquivos em `commonMain`
17+
18+
| Arquivo | Pacote | Tem teste? |
19+
|---------|--------|------------|
20+
| `SimpleProperties.kt` | `data.model.base` ||
21+
| `SimplePropertiesResponse.kt` | `data.model.base` ||
22+
| `ActionProperties.kt` | `data.model.action` ||
23+
| `AnalyticsProperties.kt` | `data.model.action` ||
24+
| `ButtonProperties.kt` | `data.model.button` ||
25+
| `TextProperties.kt` | `data.model.text` ||
26+
| `CheckBoxProperties.kt` | `data.model.checkbox` ||
27+
| `StyleProperties.kt` | `data.model.checkbox` ||
28+
| `CraftDAlign.kt` | `domain` ||
29+
| `CraftDTextStyle.kt` | `domain` ||
30+
| `CraftDComponentKey.kt` | `presentation` ||
31+
| `CraftDViewListener.kt` | `presentation` ||
32+
| `ViewMapper.kt` | `data` ||
33+
34+
#### Arquivos em `androidMain`
35+
36+
| Arquivo | Pacote | Tem teste? |
37+
|---------|--------|------------|
38+
| `CraftDSimplePropertiesDiffCallback.kt` | (root) ||
39+
| `ViewMapperVo.kt` | `data` ||
40+
| `ContextExtesion.kt` | `extensions` ||
41+
42+
**Cobertura atual: 0% — nenhum teste existe.**
43+
44+
#### Prioridade de implementação
45+
1. **`ViewMapper.kt`** — lógica real com `try/catch` silencioso em `convertToElement`
46+
2. **`CraftDSimplePropertiesDiffCallback.kt`** — lógica de diff com `AbstractMap`
47+
3. **`SimplePropertiesResponse.kt`** — mapper `toSimpleProperties` / `toListSimpleProperties`
48+
4. **`CraftDComponentKey.kt`** — constante `CRAFT_D` + valores do enum
49+
5. Data classes restantes — construção, defaults, `copy()`, `equals`
50+
51+
---
52+
53+
### 2. Decisões de arquitetura
54+
55+
#### Workflow separado vs step no `pr.yml`
56+
- **Decisão:** workflow separado (`.github/workflows/generate-tests.yml`)
57+
- **Motivo:** falha na API do Claude não bloqueia CI; responsabilidades separadas; pode ser desabilitado independentemente
58+
- Dispara via `workflow_run` após o `pr.yml` passar com sucesso
59+
60+
#### Padrão de testes
61+
- Sem referência existente no projeto → **JUnit4 + MockK**
62+
- Nomenclatura: `NomeDoArquivoTest.kt`
63+
- Método style: backtick notation `` `given X when Y then Z`() ``
64+
- Path: `src/test/java/...` espelhando o pacote do arquivo original
65+
66+
---
67+
68+
### 3. Arquivos criados nesta sessão
69+
70+
| Arquivo | Descrição |
71+
|---------|-----------|
72+
| `.github/scripts/generate_tests.py` | Script Python que chama `claude-opus-4-6` para gerar testes |
73+
| `.github/workflows/generate-tests.yml` | Workflow que detecta arquivos alterados e abre PR com testes |
74+
75+
#### Fluxo do workflow
76+
```
77+
PR aberto
78+
→ pr.yml (build + test) passa
79+
→ generate-tests.yml dispara
80+
→ detecta .kt alterados em craftd-core (exceto *Test.kt)
81+
→ para cada arquivo sem teste → chama Claude API
82+
→ escreve arquivos em src/test/java/...
83+
→ cria branch: chore/add-tests-craftd-core-pr-{N}
84+
→ abre PR automático com os testes gerados
85+
```
86+
87+
#### Branch e commit padrão
88+
- Branch: `chore/add-tests-craftd-core-pr-{número do PR}`
89+
- Commit: `test: add unit tests for craftd-core (auto-generated via Claude)`
90+
- PR title: `[Auto] Add unit tests for craftd-core`
91+
92+
---
93+
94+
### 4. Pendências identificadas
95+
96+
- [ ] Adicionar dependências de teste no `android_kmp/craftd-core/build.gradle.kts`:
97+
```kotlin
98+
commonTest.dependencies {
99+
implementation(kotlin("test"))
100+
}
101+
androidUnitTest.dependencies {
102+
implementation(libs.junit)
103+
implementation(libs.mockk)
104+
}
105+
```
106+
- [ ] Adicionar secret `ANTHROPIC_API_KEY` em _Settings → Secrets → Actions_ do repositório
107+
- [ ] Gerar os testes manualmente para os 16 arquivos sem cobertura (aguardando confirmação do usuário)
108+
- [ ] Validar que o workflow consegue resolver `github.event.workflow_run.pull_requests[0]` (pode precisar de ajuste dependendo do tipo de PR)
109+
110+
---
111+
112+
### 5. Dependências do projeto (`craftd-core`)
113+
114+
```kotlin
115+
// commonMain
116+
api(libs.kotlinx.serialization.json)
117+
implementation(libs.kotlinx.coroutines.core)
118+
implementation(compose.runtime)
119+
implementation(compose.foundation)
120+
implementation(compose.material3)
121+
122+
// androidMain
123+
implementation(libs.androidx.core)
124+
implementation(libs.androidx.appcompat)
125+
implementation(libs.google.material)
126+
implementation(libs.fasterxml.jackson)
127+
implementation(libs.fasterxml.jackson.databind)
128+
```
129+
130+
---
131+
132+
*Atualizado em: 2026-03-28*

.github/scripts/generate_tests.py

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Generates JUnit4 + MockK unit tests for Kotlin files using the Claude API.
4+
5+
Usage:
6+
python generate_tests.py <file1.kt> [file2.kt ...]
7+
8+
Or via environment variable:
9+
CHANGED_FILES="file1.kt\nfile2.kt" python generate_tests.py
10+
"""
11+
12+
import os
13+
import sys
14+
import re
15+
import anthropic
16+
17+
18+
def extract_package(content: str) -> str:
19+
match = re.search(r"^package\s+([\w.]+)", content, re.MULTILINE)
20+
return match.group(1) if match else ""
21+
22+
23+
def source_to_test_path(source_path: str) -> str:
24+
"""
25+
Maps a production source path to its expected test path.
26+
27+
Examples:
28+
src/commonMain/kotlin/com/...Foo.kt -> src/test/java/com/...FooTest.kt
29+
src/androidMain/kotlin/com/...Bar.kt -> src/test/java/com/...BarTest.kt
30+
"""
31+
path = re.sub(
32+
r"src/(common|android)Main/kotlin/",
33+
"src/test/java/",
34+
source_path,
35+
)
36+
return path[:-3] + "Test.kt"
37+
38+
39+
def file_name_without_ext(path: str) -> str:
40+
return os.path.basename(path).replace(".kt", "")
41+
42+
43+
def build_prompt(file_path: str, content: str, package_name: str) -> str:
44+
class_name = file_name_without_ext(file_path)
45+
return f"""You are an expert Kotlin developer specializing in unit testing for Kotlin Multiplatform (KMP) projects.
46+
47+
## Project context
48+
- **CraftD** is a Server Driven UI library for Android/KMP.
49+
- The server returns a list of `SimpleProperties` (key + value as `JsonElement`).
50+
- The lib renders native components dynamically via `CraftDBuilder`.
51+
- Stack: Kotlin, JUnit4, MockK, kotlinx.serialization, Jetpack Compose.
52+
53+
## Your task
54+
Generate comprehensive unit tests for the Kotlin source file below.
55+
56+
## Requirements
57+
- Framework: **JUnit4** (`@RunWith(JUnit4::class)`) + **MockK** (`io.mockk.*`)
58+
- Package: `{package_name}`
59+
- Test class name: `{class_name}Test`
60+
- Test method style: backtick notation — e.g. `` `given null JsonElement when convertToElement then returns null`() ``
61+
- **Data classes**: test construction with all params, default values, `copy()`, `equals`/`hashCode`.
62+
- **Extension functions with JsonElement**: test null input, valid JSON deserialization, malformed/wrong-type JSON (expect `null` return).
63+
- **DiffUtil callbacks**: test `areItemsTheSame` (same key, different key) and `areContentsTheSame` (equal value, different value, `AbstractMap` comparison edge case).
64+
- **Enums**: verify all enum constant names exist using `enumValueOf`.
65+
- **Mapper functions** (`toSimpleProperties`, `toListSimpleProperties`): test with sample data, empty list, and null-value fields.
66+
- Do **not** include explanations or markdown fences.
67+
- Output **only** the complete Kotlin test file, starting with `package {package_name}`.
68+
69+
## Source file
70+
Path: `{file_path}`
71+
72+
```kotlin
73+
{content}
74+
```
75+
"""
76+
77+
78+
def call_claude(client: anthropic.Anthropic, prompt: str) -> str:
79+
message = client.messages.create(
80+
model="claude-haiku-4-5-20251001",
81+
max_tokens=4096,
82+
messages=[{"role": "user", "content": prompt}],
83+
)
84+
return message.content[0].text
85+
86+
87+
def write_github_output(key: str, value: str) -> None:
88+
output_file = os.environ.get("GITHUB_OUTPUT", "")
89+
if not output_file:
90+
return
91+
with open(output_file, "a") as f:
92+
if "\n" in value:
93+
f.write(f"{key}<<EOF\n{value}\nEOF\n")
94+
else:
95+
f.write(f"{key}={value}\n")
96+
97+
98+
def write_step_summary(lines: list) -> None:
99+
summary_file = os.environ.get("GITHUB_STEP_SUMMARY", "")
100+
if not summary_file:
101+
return
102+
with open(summary_file, "a") as f:
103+
f.write("\n".join(lines) + "\n")
104+
105+
106+
def main() -> None:
107+
api_key = os.environ.get("ANTHROPIC_API_KEY")
108+
if not api_key:
109+
print("ERROR: ANTHROPIC_API_KEY is not set.", file=sys.stderr)
110+
sys.exit(1)
111+
112+
# Collect files from CLI args or CHANGED_FILES env var
113+
if len(sys.argv) > 1:
114+
raw = " ".join(sys.argv[1:])
115+
files = [f.strip() for f in re.split(r"[\s]+", raw) if f.strip()]
116+
else:
117+
files = [
118+
f.strip()
119+
for f in os.environ.get("CHANGED_FILES", "").splitlines()
120+
if f.strip()
121+
]
122+
123+
if not files:
124+
print("No Kotlin files to process.")
125+
write_github_output("generated_count", "0")
126+
sys.exit(0)
127+
128+
client = anthropic.Anthropic(api_key=api_key)
129+
generated = []
130+
131+
for file_path in files:
132+
if not os.path.isfile(file_path):
133+
print(f"[SKIP] File not found: {file_path}")
134+
continue
135+
136+
test_path = source_to_test_path(file_path)
137+
138+
if os.path.isfile(test_path):
139+
print(f"[SKIP] Test already exists: {test_path}")
140+
continue
141+
142+
print(f"[GEN] {file_path}")
143+
print(f" -> {test_path}")
144+
145+
with open(file_path, "r", encoding="utf-8") as f:
146+
content = f.read()
147+
148+
package_name = extract_package(content)
149+
if not package_name:
150+
print(f"[WARN] Could not extract package from {file_path}, skipping.")
151+
continue
152+
153+
try:
154+
prompt = build_prompt(file_path, content, package_name)
155+
test_content = call_claude(client, prompt)
156+
157+
os.makedirs(os.path.dirname(test_path), exist_ok=True)
158+
with open(test_path, "w", encoding="utf-8") as f:
159+
f.write(test_content)
160+
161+
generated.append(
162+
{
163+
"source": file_path,
164+
"test": test_path,
165+
"test_name": file_name_without_ext(file_path) + "Test.kt",
166+
}
167+
)
168+
print(f"[OK] Written: {test_path}\n")
169+
170+
except Exception as exc:
171+
print(f"[ERROR] Failed to generate test for {file_path}: {exc}", file=sys.stderr)
172+
173+
count = len(generated)
174+
print(f"\nDone. Generated {count} test file(s).")
175+
176+
write_github_output("generated_count", str(count))
177+
178+
if generated:
179+
test_files = "\n".join(item["test"] for item in generated)
180+
covered_names = ", ".join(item["test_name"] for item in generated)
181+
write_github_output("test_files", test_files)
182+
write_github_output("covered_names", covered_names)
183+
184+
summary_lines = [
185+
"## Auto-generated Unit Tests",
186+
"",
187+
"| Source file | Generated test |",
188+
"| --- | --- |",
189+
]
190+
for item in generated:
191+
summary_lines.append(f"| `{item['source']}` | `{item['test']}` |")
192+
write_step_summary(summary_lines)
193+
194+
195+
if __name__ == "__main__":
196+
main()

0 commit comments

Comments
 (0)