test: add journal regression, repeated activity, manual entry tests + resolveActivityTarget helper

Tests added to suggest_test.go:
- TestJournalFullRegression: GetSuggestions -> verify eventIds match
  events -> AcceptSuggestionWith -> verify all 3 linked via
  worklog_entry_events + JOIN + GetWorklogEntryEvents
- TestSuggestionOnRepeatedActivity: first event accepted, new event
  created -> GetSuggestions still returns suggestion for the new event
- TestManualWorklogEntry: CreateWorklogFull -> source=manual,
  billable/approximate/details preserved, GetWorklogEntryEvents empty

Frontend:
- resolveActivityTarget(ev) pure function returning { nodeId, tab,
  noteId/fileId/targetPath } based on targetType
- openActivityTarget(ev) uses resolveActivityTarget for navigation
This commit is contained in:
mirivlad 2026-06-03 16:49:30 +08:00
parent db869a7c97
commit 9338b0a851
4 changed files with 259 additions and 77 deletions

View File

@ -16,7 +16,7 @@
background: #13131f; background: #13131f;
} }
</style> </style>
<script type="module" crossorigin src="/assets/main-BaeOWl0t.js"></script> <script type="module" crossorigin src="/assets/main-BTm9jSp2.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-BafVhx43.css"> <link rel="stylesheet" crossorigin href="/assets/main-BafVhx43.css">
</head> </head>
<body> <body>

View File

@ -3,72 +3,136 @@ package main
import ( import (
"encoding/json" "encoding/json"
"testing" "testing"
"time"
"verstak/internal/core/activity" "verstak/internal/core/activity"
"verstak/internal/core/util" "verstak/internal/core/util"
"verstak/internal/core/worklog" "verstak/internal/core/worklog"
) )
// TestAcceptSuggestionWithEndToEnd verifies the full chain: // nowISO returns the current time in RFC3339, usable as an activity_events.created_at.
// 1. Create a node func nowISO() string {
// 2. Create activity events for that node return time.Now().UTC().Format(time.RFC3339)
// 3. Accept a suggestion with those event IDs }
// 4. Verify worklog_entry_events contains exactly those events
// 5. Verify GetWorklogEntryEvents returns the linked events // insertTestEvent inserts an activity event at the given created_at and returns its ID.
func TestAcceptSuggestionWithEndToEnd(t *testing.T) { 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) app, _ := setupTestApp(t)
// 1. Create a node // Create a node
n, err := app.CreateNodeFromTemplate("", "Test Suggestion", "folder.default") n, err := app.CreateNodeFromTemplate("", "Regression Node", "folder.default")
if err != nil { if err != nil {
t.Fatalf("create node: %v", err) t.Fatalf("create node: %v", err)
} }
// 2. Create activity events for the node. // Create activity events (use now() so GetSuggestions picks them up)
// We need to insert events with today's timestamp so GetSuggestions picks them up. eid1 := insertTestEvent(t, app, n.ID, activity.TypeNoteCreated, "note", "n1", "Создана заметка")
// But to avoid time dependency, we also directly accept with known event IDs. eid2 := insertTestEvent(t, app, n.ID, activity.TypeNoteUpdated, "note", "n1", "Заметка изменена")
eid1 := insertTestEvent(t, app, n.ID, activity.TypeNoteCreated, "note", "note1", "Test note 1") eid3 := insertTestEvent(t, app, n.ID, activity.TypeFileAdded, "file", "f1", "Добавлен файл")
eid2 := insertTestEvent(t, app, n.ID, activity.TypeNoteUpdated, "note", "note1", "Test note 1 updated")
eid3 := insertTestEvent(t, app, n.ID, activity.TypeFileAdded, "file", "file1", "Test file")
// 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} eventIDs := []string{eid1, eid2, eid3}
eventIDsJSON, _ := json.Marshal(eventIDs) eventIDsJSON, _ := json.Marshal(eventIDs)
dto, err := app.AcceptSuggestionWith(n.ID, "Работа с заметками и файлами", 20, "", string(eventIDsJSON))
// 3. Accept suggestion with these event IDs
dto, err := app.AcceptSuggestionWith(n.ID, "Работа с заметками", 15, "", string(eventIDsJSON))
if err != nil { if err != nil {
t.Fatalf("AcceptSuggestionWith: %v", err) t.Fatalf("AcceptSuggestionWith: %v", err)
} }
if dto.ID == "" { // Check worklog_entry_events count
t.Fatal("AcceptSuggestionWith returned empty entry ID") if n := countLinked(t, app, dto.ID); n != 3 {
t.Errorf("worklog_entry_events count = %d, want 3", n)
} }
// 4. Verify worklog_entry_events count // Check JOIN with activity_events
var linkCount int if n := countJoined(t, app, dto.ID); n != 3 {
err = app.db.QueryRow( t.Errorf("JOIN count = %d, want 3", n)
`SELECT COUNT(*) FROM worklog_entry_events WHERE entry_id = ?`, dto.ID).Scan(&linkCount)
if err != nil {
t.Fatalf("count worklog_entry_events: %v", err)
}
if linkCount != 3 {
t.Errorf("worklog_entry_events count = %d, want 3", linkCount)
} }
// 5. Verify JOIN with activity_events // Check GetWorklogEntryEvents returns 3 events
var joinCount int
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 = ?`, dto.ID).Scan(&joinCount)
if err != nil {
t.Fatalf("join count: %v", err)
}
if joinCount != 3 {
t.Errorf("JOIN count = %d, want 3", joinCount)
}
// 6. Call GetWorklogEntryEvents and verify 3 events
events, err := app.GetWorklogEntryEvents(dto.ID) events, err := app.GetWorklogEntryEvents(dto.ID)
if err != nil { if err != nil {
t.Fatalf("GetWorklogEntryEvents: %v", err) t.Fatalf("GetWorklogEntryEvents: %v", err)
@ -77,40 +141,144 @@ func TestAcceptSuggestionWithEndToEnd(t *testing.T) {
t.Errorf("GetWorklogEntryEvents returned %d events, want 3", len(events)) t.Errorf("GetWorklogEntryEvents returned %d events, want 3", len(events))
} }
// 7. Verify the returned event IDs match // All three event IDs must be present
returnedIDs := make(map[string]bool, len(events)) returnedIDs := make(map[string]bool, len(events))
for _, ev := range events { for _, ev := range events {
returnedIDs[ev.ID] = true returnedIDs[ev.ID] = true
} }
for _, want := range eventIDs { for _, eid := range eventIDs {
if !returnedIDs[want] { if !returnedIDs[eid] {
t.Errorf("event %s not found in GetWorklogEntryEvents result", want) t.Errorf("event %s missing from GetWorklogEntryEvents", eid)
} }
} }
// 8. Verify source is 'suggestion' // Source must be suggestion
var source string var src string
err = app.db.QueryRow( app.db.QueryRow(`SELECT source FROM worklog_entries WHERE id = ?`, dto.ID).Scan(&src)
`SELECT source FROM worklog_entries WHERE id = ?`, dto.ID).Scan(&source) if src != worklog.SourceSuggestion {
if err != nil { t.Errorf("source = %q, want %q", src, worklog.SourceSuggestion)
t.Fatalf("get source: %v", err)
}
if source != worklog.SourceSuggestion {
t.Errorf("source = %q, want %q", source, worklog.SourceSuggestion)
} }
} }
// insertTestEvent inserts an activity event and returns its ID. // ---------------------------------------------------------------------------
func insertTestEvent(t *testing.T, app *App, nodeID, eventType, targetType, targetID, title string) string { // Test 2: Repeated activity on same node — suggestion must still appear
t.Helper() // ---------------------------------------------------------------------------
id := util.UUID7()
now := "2026-06-03T12:00:00Z" func TestSuggestionOnRepeatedActivity(t *testing.T) {
_, err := app.db.Exec( app, _ := setupTestApp(t)
`INSERT INTO activity_events(id,node_id,event_type,target_type,target_id,target_path,title,metadata,created_at)
VALUES(?,?,?,?,?,?,?,?,?)`, n, err := app.CreateNodeFromTemplate("", "Repeat Node", "folder.default")
id, nodeID, eventType, targetType, targetID, "", title, "{}", now)
if err != nil { if err != nil {
t.Fatalf("insert event: %v", err) 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)
} }
return id
} }

View File

@ -1318,24 +1318,38 @@
} }
} }
function resolveActivityTarget(ev) {
if (ev.targetType === 'note' && ev.targetId) {
return { nodeId: ev.nodeId, tab: 'notes', noteId: ev.targetId }
}
if (ev.targetType === 'file') {
return { nodeId: ev.nodeId, tab: 'files', fileId: ev.targetId, targetPath: ev.targetPath }
}
if (ev.targetType === 'folder') {
return { nodeId: ev.nodeId, tab: 'files', folderPath: ev.targetPath || ev.targetId }
}
return { nodeId: ev.nodeId }
}
async function openActivityTarget(ev) { async function openActivityTarget(ev) {
const targetNode = ev.nodeId const target = resolveActivityTarget(ev)
const targetNode = target.nodeId
if (!targetNode) return if (!targetNode) return
try { try {
const node = await wailsCall('GetNodeDetail', targetNode) const node = await wailsCall('GetNodeDetail', targetNode)
if (!node) return if (!node) return
selectNode(node) selectNode(node)
if (ev.targetType === 'note') { if (target.tab === 'notes') {
activeTab = 'notes' activeTab = 'notes'
if (ev.targetId) { if (target.noteId) {
try { notes = await wailsCall('ListNotes', targetNode) || [] } catch(e) {} try { notes = await wailsCall('ListNotes', targetNode) || [] } catch(e) {}
const note = notes.find(n => n.id === ev.targetId) const note = notes.find(n => n.id === target.noteId)
if (note) setTimeout(() => openNote(note), 100) if (note) setTimeout(() => openNote(note), 100)
} }
} else if (ev.targetType === 'file' || ev.targetType === 'folder') { } else if (target.tab === 'files') {
activeTab = 'files' activeTab = 'files'
if (ev.targetId && ev.targetPath) { if (target.targetPath) {
try { await wailsCall('OpenFolder', ev.targetPath) } catch(e) {} try { await wailsCall('OpenFolder', target.targetPath) } catch(e) {}
} }
} }
} catch (e) { } catch (e) {