feat(0130): kanban_cpp v2 — backend Go + 5 registry parser fns + epic/sub-issues
Registry (issue 0130a):
- 5 fns infra: parse_issue_md, write_issue_md, scan_issues_dir,
scan_flows_dir, watch_dir_fsnotify
- 3 tipos: Issue, Flow, FsEvent
- Tests round-trip + scan reales + watcher fsnotify (all PASS)
- Capability group 'kanban' nuevo (docs/capabilities/kanban.md)
Apps:
- apps/kanban_cpp/ (sub-repo) — frontend ImGui: board drag-drop,
flows, filters, detail con CSV editors
- apps/kanban_cpp/backend/ — Go service port 8487: REST + SSE +
fsnotify watcher, parser bidireccional MD<->SQLite cache
Issues:
- dev/issues/0130-kanban-cpp-v2.md (epic)
- 0130a parser, 0130b backend, 0130c frontend
CMakeLists.txt: add_subdirectory apps/kanban_cpp (registrado por
init_cpp_app scaffolder).
End-to-end verde: backend devuelve 189 issues + 9 flows; PATCH a
/api/issues/{id} reescribe .md (solo frontmatter, body intacto);
frontend --self-test exit 0; tests Go infra 5/5 PASS.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestWatchDirFsnotify(t *testing.T) {
|
||||
t.Run("detecta escritura de archivo", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
ch, err := WatchDirFsnotify(ctx, tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("WatchDirFsnotify: %v", err)
|
||||
}
|
||||
|
||||
// Dar tiempo al watcher para arrancar
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
// Escribir un archivo
|
||||
testFile := filepath.Join(tmpDir, "test.md")
|
||||
if err := os.WriteFile(testFile, []byte("hello"), 0644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
// Esperar evento (debounce 200ms + margen)
|
||||
select {
|
||||
case ev, ok := <-ch:
|
||||
if !ok {
|
||||
t.Fatal("channel closed unexpectedly")
|
||||
}
|
||||
if ev.Path != testFile {
|
||||
t.Errorf("Path: got %q, want %q", ev.Path, testFile)
|
||||
}
|
||||
if ev.Op != "create" && ev.Op != "write" {
|
||||
t.Errorf("Op: got %q, want 'create' or 'write'", ev.Op)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
t.Fatal("timeout waiting for fs event")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("canal se cierra cuando ctx cancela", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
ch, err := WatchDirFsnotify(ctx, tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("WatchDirFsnotify: %v", err)
|
||||
}
|
||||
|
||||
// Cancelar inmediatamente
|
||||
cancel()
|
||||
|
||||
// El canal debe cerrarse
|
||||
timeout := time.After(2 * time.Second)
|
||||
// Drenar cualquier evento pendiente hasta que el canal se cierre
|
||||
for {
|
||||
select {
|
||||
case _, ok := <-ch:
|
||||
if !ok {
|
||||
return // canal cerrado correctamente
|
||||
}
|
||||
case <-timeout:
|
||||
t.Fatal("channel not closed after ctx cancel within 2s")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("error en directorio inexistente", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
_, err := WatchDirFsnotify(ctx, "/nonexistent/dir/that/does/not/exist")
|
||||
if err == nil {
|
||||
t.Error("expected error for nonexistent directory")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("debounce agrupa multiples escrituras", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
ch, err := WatchDirFsnotify(ctx, tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("WatchDirFsnotify: %v", err)
|
||||
}
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
testFile := filepath.Join(tmpDir, "debounce.md")
|
||||
// Escribir 5 veces rapidamente
|
||||
for i := 0; i < 5; i++ {
|
||||
_ = os.WriteFile(testFile, []byte("content"), 0644)
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
|
||||
// Esperar debounce + margen
|
||||
time.Sleep(400 * time.Millisecond)
|
||||
|
||||
// Debe haber llegado al menos un evento pero no 5
|
||||
eventCount := 0
|
||||
drain:
|
||||
for {
|
||||
select {
|
||||
case _, ok := <-ch:
|
||||
if !ok {
|
||||
break drain
|
||||
}
|
||||
eventCount++
|
||||
default:
|
||||
break drain
|
||||
}
|
||||
}
|
||||
if eventCount == 0 {
|
||||
t.Error("expected at least one debounced event")
|
||||
}
|
||||
if eventCount >= 5 {
|
||||
t.Errorf("debounce failed: got %d events, expected fewer than 5", eventCount)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user