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:
parent
db869a7c97
commit
9338b0a851
File diff suppressed because one or more lines are too long
|
|
@ -16,7 +16,7 @@
|
|||
background: #13131f;
|
||||
}
|
||||
</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">
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
|
|
@ -3,72 +3,136 @@ package main
|
|||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"verstak/internal/core/activity"
|
||||
"verstak/internal/core/util"
|
||||
"verstak/internal/core/worklog"
|
||||
)
|
||||
|
||||
// TestAcceptSuggestionWithEndToEnd verifies the full chain:
|
||||
// 1. Create a node
|
||||
// 2. Create activity events for that node
|
||||
// 3. Accept a suggestion with those event IDs
|
||||
// 4. Verify worklog_entry_events contains exactly those events
|
||||
// 5. Verify GetWorklogEntryEvents returns the linked events
|
||||
func TestAcceptSuggestionWithEndToEnd(t *testing.T) {
|
||||
// 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)
|
||||
|
||||
// 1. Create a node
|
||||
n, err := app.CreateNodeFromTemplate("", "Test Suggestion", "folder.default")
|
||||
// Create a node
|
||||
n, err := app.CreateNodeFromTemplate("", "Regression Node", "folder.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create node: %v", err)
|
||||
}
|
||||
|
||||
// 2. Create activity events for the node.
|
||||
// We need to insert events with today's timestamp so GetSuggestions picks them up.
|
||||
// But to avoid time dependency, we also directly accept with known event IDs.
|
||||
eid1 := insertTestEvent(t, app, n.ID, activity.TypeNoteCreated, "note", "note1", "Test note 1")
|
||||
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")
|
||||
// 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)
|
||||
|
||||
// 3. Accept suggestion with these event IDs
|
||||
dto, err := app.AcceptSuggestionWith(n.ID, "Работа с заметками", 15, "", string(eventIDsJSON))
|
||||
dto, err := app.AcceptSuggestionWith(n.ID, "Работа с заметками и файлами", 20, "", string(eventIDsJSON))
|
||||
if err != nil {
|
||||
t.Fatalf("AcceptSuggestionWith: %v", err)
|
||||
}
|
||||
|
||||
if dto.ID == "" {
|
||||
t.Fatal("AcceptSuggestionWith returned empty entry ID")
|
||||
// Check worklog_entry_events count
|
||||
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
|
||||
var linkCount int
|
||||
err = app.db.QueryRow(
|
||||
`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)
|
||||
// Check JOIN with activity_events
|
||||
if n := countJoined(t, app, dto.ID); n != 3 {
|
||||
t.Errorf("JOIN count = %d, want 3", n)
|
||||
}
|
||||
|
||||
// 5. Verify JOIN with activity_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
|
||||
// Check GetWorklogEntryEvents returns 3 events
|
||||
events, err := app.GetWorklogEntryEvents(dto.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetWorklogEntryEvents: %v", err)
|
||||
|
|
@ -77,40 +141,144 @@ func TestAcceptSuggestionWithEndToEnd(t *testing.T) {
|
|||
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))
|
||||
for _, ev := range events {
|
||||
returnedIDs[ev.ID] = true
|
||||
}
|
||||
for _, want := range eventIDs {
|
||||
if !returnedIDs[want] {
|
||||
t.Errorf("event %s not found in GetWorklogEntryEvents result", want)
|
||||
for _, eid := range eventIDs {
|
||||
if !returnedIDs[eid] {
|
||||
t.Errorf("event %s missing from GetWorklogEntryEvents", eid)
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Verify source is 'suggestion'
|
||||
var source string
|
||||
err = app.db.QueryRow(
|
||||
`SELECT source FROM worklog_entries WHERE id = ?`, dto.ID).Scan(&source)
|
||||
if err != nil {
|
||||
t.Fatalf("get source: %v", err)
|
||||
}
|
||||
if source != worklog.SourceSuggestion {
|
||||
t.Errorf("source = %q, want %q", source, worklog.SourceSuggestion)
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// insertTestEvent inserts an activity event and returns its ID.
|
||||
func insertTestEvent(t *testing.T, app *App, nodeID, eventType, targetType, targetID, title string) string {
|
||||
t.Helper()
|
||||
id := util.UUID7()
|
||||
now := "2026-06-03T12:00:00Z"
|
||||
_, 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, "{}", now)
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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("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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
const targetNode = ev.nodeId
|
||||
const target = resolveActivityTarget(ev)
|
||||
const targetNode = target.nodeId
|
||||
if (!targetNode) return
|
||||
try {
|
||||
const node = await wailsCall('GetNodeDetail', targetNode)
|
||||
if (!node) return
|
||||
selectNode(node)
|
||||
if (ev.targetType === 'note') {
|
||||
if (target.tab === 'notes') {
|
||||
activeTab = 'notes'
|
||||
if (ev.targetId) {
|
||||
if (target.noteId) {
|
||||
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)
|
||||
}
|
||||
} else if (ev.targetType === 'file' || ev.targetType === 'folder') {
|
||||
} else if (target.tab === 'files') {
|
||||
activeTab = 'files'
|
||||
if (ev.targetId && ev.targetPath) {
|
||||
try { await wailsCall('OpenFolder', ev.targetPath) } catch(e) {}
|
||||
if (target.targetPath) {
|
||||
try { await wailsCall('OpenFolder', target.targetPath) } catch(e) {}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue