verstak/cmd/verstak-gui/suggest_test.go

285 lines
9.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"encoding/json"
"testing"
"time"
"verstak/internal/core/activity"
"verstak/internal/core/util"
"verstak/internal/core/worklog"
)
// nowISO returns the current time in RFC3339, usable as an activity_events.created_at.
func nowISO() string {
return time.Now().UTC().Format(time.RFC3339)
}
// insertTestEvent inserts an activity event at the given created_at and returns its ID.
func insertTestEventAt(t *testing.T, app *App, nodeID, eventType, targetType, targetID, title, createdAt string) string {
t.Helper()
id := util.UUID7()
_, err := app.db.Exec(
`INSERT INTO activity_events(id,node_id,event_type,target_type,target_id,target_path,title,metadata,created_at)
VALUES(?,?,?,?,?,?,?,?,?)`,
id, nodeID, eventType, targetType, targetID, "", title, "{}", createdAt)
if err != nil {
t.Fatalf("insert event: %v", err)
}
return id
}
// insertTestEvent creates a "now" event.
func insertTestEvent(t *testing.T, app *App, nodeID, eventType, targetType, targetID, title string) string {
return insertTestEventAt(t, app, nodeID, eventType, targetType, targetID, title, nowISO())
}
// countLinked returns the number of worklog_entry_events for an entry.
func countLinked(t *testing.T, app *App, entryID string) int {
t.Helper()
var n int
if err := app.db.QueryRow(`SELECT COUNT(*) FROM worklog_entry_events WHERE entry_id = ?`, entryID).Scan(&n); err != nil {
t.Fatalf("count worklog_entry_events: %v", err)
}
return n
}
// countJoined returns the number of events in worklog_entry_events that successfully
// join to activity_events for a given entry.
func countJoined(t *testing.T, app *App, entryID string) int {
t.Helper()
var n int
if err := app.db.QueryRow(
`SELECT COUNT(*) FROM worklog_entry_events wle
JOIN activity_events ae ON ae.id = wle.event_id
WHERE wle.entry_id = ?`, entryID).Scan(&n); err != nil {
t.Fatalf("join count: %v", err)
}
return n
}
// ---------------------------------------------------------------------------
// Test 1: Full journal regression — GetSuggestions + Accept + verify
// ---------------------------------------------------------------------------
func TestJournalFullRegression(t *testing.T) {
app, _ := setupTestApp(t)
// Create a node
n, err := app.CreateNodeFromTemplate("", "Regression Node", "folder.default")
if err != nil {
t.Fatalf("create node: %v", err)
}
// Create activity events (use now() so GetSuggestions picks them up)
eid1 := insertTestEvent(t, app, n.ID, activity.TypeNoteCreated, "note", "n1", "Создана заметка")
eid2 := insertTestEvent(t, app, n.ID, activity.TypeNoteUpdated, "note", "n1", "Заметка изменена")
eid3 := insertTestEvent(t, app, n.ID, activity.TypeFileAdded, "file", "f1", "Добавлен файл")
// Call GetSuggestions
suggestions, err := app.GetSuggestions()
if err != nil {
t.Fatalf("GetSuggestions: %v", err)
}
// Must find at least one suggestion for our node
if len(suggestions) == 0 {
t.Fatal("GetSuggestions returned 0 suggestions, expected at least 1")
}
var found bool
for _, s := range suggestions {
if s.NodeID == n.ID {
found = true
// Verify events match what we inserted
if len(s.Events) != 3 {
t.Fatalf("suggestion has %d events, want 3", len(s.Events))
}
if len(s.EventIDs) != len(s.Events) {
t.Fatalf("suggestion eventIds (%d) != events (%d)", len(s.EventIDs), len(s.Events))
}
// Each eventId must be in the events list
idSet := make(map[string]bool, len(s.Events))
for _, ev := range s.Events {
idSet[ev.ID] = true
}
for _, eid := range s.EventIDs {
if !idSet[eid] {
t.Errorf("eventId %s not found in events list", eid)
}
}
}
}
if !found {
t.Fatalf("no suggestion found for node %s", n.ID)
}
// Accept the suggestion via AcceptSuggestionWith (JSON-serialised eventIDs)
eventIDs := []string{eid1, eid2, eid3}
eventIDsJSON, _ := json.Marshal(eventIDs)
dto, err := app.AcceptSuggestionWith(n.ID, "Работа с заметками и файлами", 20, "", string(eventIDsJSON))
if err != nil {
t.Fatalf("AcceptSuggestionWith: %v", err)
}
// Check worklog_entry_events count
if n := countLinked(t, app, dto.ID); n != 3 {
t.Errorf("worklog_entry_events count = %d, want 3", n)
}
// Check JOIN with activity_events
if n := countJoined(t, app, dto.ID); n != 3 {
t.Errorf("JOIN count = %d, want 3", n)
}
// Check GetWorklogEntryEvents returns 3 events
events, err := app.GetWorklogEntryEvents(dto.ID)
if err != nil {
t.Fatalf("GetWorklogEntryEvents: %v", err)
}
if len(events) != 3 {
t.Errorf("GetWorklogEntryEvents returned %d events, want 3", len(events))
}
// All three event IDs must be present
returnedIDs := make(map[string]bool, len(events))
for _, ev := range events {
returnedIDs[ev.ID] = true
}
for _, eid := range eventIDs {
if !returnedIDs[eid] {
t.Errorf("event %s missing from GetWorklogEntryEvents", eid)
}
}
// Source must be suggestion
var src string
app.db.QueryRow(`SELECT source FROM worklog_entries WHERE id = ?`, dto.ID).Scan(&src)
if src != worklog.SourceSuggestion {
t.Errorf("source = %q, want %q", src, worklog.SourceSuggestion)
}
}
// ---------------------------------------------------------------------------
// Test 2: Repeated activity on same node — suggestion must still appear
// ---------------------------------------------------------------------------
func TestSuggestionOnRepeatedActivity(t *testing.T) {
app, _ := setupTestApp(t)
n, err := app.CreateNodeFromTemplate("", "Repeat Node", "folder.default")
if err != nil {
t.Fatalf("create node: %v", err)
}
// Create a first event and accept it
eid1 := insertTestEvent(t, app, n.ID, activity.TypeNoteCreated, "note", "n1", "Первая заметка")
eid1JSON, _ := json.Marshal([]string{eid1})
_, err = app.AcceptSuggestionWith(n.ID, "Создание заметки", 5, "", string(eid1JSON))
if err != nil {
t.Fatalf("first AcceptSuggestionWith: %v", err)
}
// Now create a brand new event on the same node
eid2 := insertTestEvent(t, app, n.ID, activity.TypeNoteUpdated, "note", "n1", "Заметка изменена")
// GetSuggestions must still return a suggestion for this node (new event is unaccounted)
suggestions, err := app.GetSuggestions()
if err != nil {
t.Fatalf("GetSuggestions: %v", err)
}
var found bool
for _, s := range suggestions {
if s.NodeID == n.ID {
found = true
// Only the second event should be suggested
if len(s.Events) != 1 {
t.Fatalf("expected 1 new event, got %d", len(s.Events))
}
if s.Events[0].ID != eid2 {
t.Errorf("expected event %s, got %s", eid2, s.Events[0].ID)
}
if len(s.EventIDs) != 1 || s.EventIDs[0] != eid2 {
t.Errorf("eventIds does not contain the new event")
}
}
}
if !found {
t.Fatal("GetSuggestions did not return a suggestion for the new event — it should")
}
// Accept the new suggestion
eid2JSON, _ := json.Marshal([]string{eid2})
dto2, err := app.AcceptSuggestionWith(n.ID, "Обновление заметки", 5, "", string(eid2JSON))
if err != nil {
t.Fatalf("second AcceptSuggestionWith: %v", err)
}
// Each entry must have exactly 1 linked event
if n := countLinked(t, app, dto2.ID); n != 1 {
t.Errorf("second entry: worklog_entry_events count = %d, want 1", n)
}
}
// ---------------------------------------------------------------------------
// Test 3: Manual worklog entry — source, billable, details, empty events
// ---------------------------------------------------------------------------
func TestManualWorklogEntry(t *testing.T) {
app, _ := setupTestApp(t)
n, err := app.CreateNodeFromTemplate("", "Manual Node", "folder.default")
if err != nil {
t.Fatalf("create node: %v", err)
}
// Create a manual entry
dto, err := app.CreateWorklogFull(n.ID, "Ручная работа", "Подробное описание", "", 30, true, true)
if err != nil {
t.Fatalf("CreateWorklogFull: %v", err)
}
// Source must be manual
var src string
app.db.QueryRow(`SELECT source FROM worklog_entries WHERE id = ?`, dto.ID).Scan(&src)
if src != worklog.SourceManual {
t.Errorf("source = %q, want %q", src, worklog.SourceManual)
}
// Billable, approximate, details must be correct
var details string
var billable, approximate int
app.db.QueryRow(
`SELECT details, billable, approximate FROM worklog_entries WHERE id = ?`, dto.ID,
).Scan(&details, &billable, &approximate)
if details != "Подробное описание" {
t.Errorf("details = %q, want %q", details, "Подробное описание")
}
if billable != 1 {
t.Errorf("billable = %d, want 1", billable)
}
if approximate != 1 {
t.Errorf("approximate = %d, want 1", approximate)
}
// Minutes must be 30
if dto.Minutes != 30 {
t.Errorf("minutes = %d, want 30", dto.Minutes)
}
// GetWorklogEntryEvents must be empty for a manual entry
events, err := app.GetWorklogEntryEvents(dto.ID)
if err != nil {
t.Fatalf("GetWorklogEntryEvents: %v", err)
}
if len(events) != 0 {
t.Errorf("manual entry has %d linked events, want 0", len(events))
}
// DTO should distinguish manual from suggestion
if dto.Source != worklog.SourceManual {
t.Errorf("dto.Source = %q, want %q", dto.Source, worklog.SourceManual)
}
}