fix(status): show ringing sessions in every client's recent history (#283)
* fix(status): show ringing sessions in every client's recent history * test(routes): pin visited-first ordering and ringing jump ranks, align history comments
- commit
- 8ea43d2a8f7cda686ff0eadd41b6638df726e801
- author
- bnema · · 2026-09-24 10:14 UTC
- committer
- GitHub · · 2026-09-24 10:14 UTC
- parent
- 32ce382
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
10 files · +90 -32
M docs/session-navigation.md +1 -1
| @@ -27,7 +27,7 @@ Open it with `SSP` in the palette. It lists local sessions and, if you added hos | |||
| 27 | 27 | | `CNS` | create a named session; asks where when several daemons are available | | |
| 28 | 28 | | `CES` | create a numbered session | | |
| 29 | 29 | ||
| 30 | - | Recent-session history belongs to one client and is not saved. A new client starts with an empty history. | |
| 30 | + | Recent-session history belongs to one client and is not saved. A new client starts with an empty history. A session that rings a bell also shows in the history with its bell, after the sessions this client visited, until any client sees it. | |
| 31 | 31 | ||
| 32 | 32 | ## Move panes and tabs | |
| 33 | 33 | ||
| @@ -27,7 +27,7 @@ Open it with `SSP` in the palette. It lists local sessions and, if you added hos | |||||
| 27 | | `CNS` | create a named session; asks where when several daemons are available | | 27 | | `CNS` | create a named session; asks where when several daemons are available | | ||
| 28 | | `CES` | create a numbered session | | 28 | | `CES` | create a numbered session | | ||
| 29 | 29 | ||||
| 30 | - | Recent-session history belongs to one client and is not saved. A new client starts with an empty history. | 30 | + | Recent-session history belongs to one client and is not saved. A new client starts with an empty history. A session that rings a bell also shows in the history with its bell, after the sessions this client visited, until any client sees it. |
| 31 | 31 | ||||
| 32 | ## Move panes and tabs | 32 | ## Move panes and tabs | ||
| 33 | 33 | ||||
M internal/protocol/routes.go +1 -1
| @@ -319,7 +319,7 @@ type RecentRouteEntry struct { | |||
| 319 | 319 | // observes: a smaller value is older. It is zero without attention. | |
| 320 | 320 | AttentionSeq uint64 | |
| 321 | 321 | // Visited reports that this client attached to the route. The status-bar | |
| 322 | - | // history and jump-recent ranks show only visited routes. | |
| 322 | + | // history and jump-recent ranks show visited routes plus ringing ones. | |
| 323 | 323 | Visited bool | |
| 324 | 324 | } | |
| 325 | 325 | ||
| @@ -319,7 +319,7 @@ type RecentRouteEntry struct { | |||||
| 319 | // observes: a smaller value is older. It is zero without attention. | 319 | // observes: a smaller value is older. It is zero without attention. | ||
| 320 | AttentionSeq uint64 | 320 | AttentionSeq uint64 | ||
| 321 | // Visited reports that this client attached to the route. The status-bar | 321 | // Visited reports that this client attached to the route. The status-bar | ||
| 322 | - | // history and jump-recent ranks show only visited routes. | 322 | + | // history and jump-recent ranks show visited routes plus ringing ones. |
| 323 | Visited bool | 323 | Visited bool | ||
| 324 | } | 324 | } | ||
| 325 | 325 | ||||
M internal/protocol/wire/schema/session.proto +1 -1
| @@ -163,7 +163,7 @@ message RecentRouteEntry { | |||
| 163 | 163 | // Client-assigned attention onset order; zero without attention. | |
| 164 | 164 | uint64 attention_seq = 10; | |
| 165 | 165 | // True once this client attached to the route; the status-bar history | |
| 166 | - | // shows only visited routes. | |
| 166 | + | // shows visited routes plus ringing ones. | |
| 167 | 167 | bool visited = 11; | |
| 168 | 168 | } | |
| 169 | 169 | ||
| @@ -163,7 +163,7 @@ message RecentRouteEntry { | |||||
| 163 | // Client-assigned attention onset order; zero without attention. | 163 | // Client-assigned attention onset order; zero without attention. | ||
| 164 | uint64 attention_seq = 10; | 164 | uint64 attention_seq = 10; | ||
| 165 | // True once this client attached to the route; the status-bar history | 165 | // True once this client attached to the route; the status-bar history | ||
| 166 | - | // shows only visited routes. | 166 | + | // shows visited routes plus ringing ones. |
| 167 | bool visited = 11; | 167 | bool visited = 11; | ||
| 168 | } | 168 | } | ||
| 169 | 169 | ||||
M internal/protocol/wire/session.pb.go +1 -1 generated file: collapsed
M internal/usecase/client/route_ledger.go +2 -1
| @@ -32,7 +32,8 @@ import ( | |||
| 32 | 32 | // - Visited marks entries this client attached to. Attach recency sorts | |
| 33 | 33 | // first, so visited entries are always a prefix of Entries and Previous | |
| 34 | 34 | // is the first of them; the daemon's status history and jump-recent | |
| 35 | - | // ranks show only that prefix, while the palette shows every entry. | |
| 35 | + | // ranks show that prefix plus any ringing entry after it, so visited | |
| 36 | + | // ranks stay stable, while the palette shows every entry. | |
| 36 | 37 | // - The attached lifecycle is metadata only (ActiveEntry), never an entry. | |
| 37 | 38 | // - Attention is any(tab.Attention). AttentionSeq orders onsets as the | |
| 38 | 39 | // client observed them, across daemons, so the serving daemon can jump to | |
| @@ -32,7 +32,8 @@ import ( | |||||
| 32 | // - Visited marks entries this client attached to. Attach recency sorts | 32 | // - Visited marks entries this client attached to. Attach recency sorts | ||
| 33 | // first, so visited entries are always a prefix of Entries and Previous | 33 | // first, so visited entries are always a prefix of Entries and Previous | ||
| 34 | // is the first of them; the daemon's status history and jump-recent | 34 | // is the first of them; the daemon's status history and jump-recent | ||
| 35 | - | // ranks show only that prefix, while the palette shows every entry. | 35 | + | // ranks show that prefix plus any ringing entry after it, so visited |
| 36 | + | // ranks stay stable, while the palette shows every entry. | |||
| 36 | // - The attached lifecycle is metadata only (ActiveEntry), never an entry. | 37 | // - The attached lifecycle is metadata only (ActiveEntry), never an entry. | ||
| 37 | // - Attention is any(tab.Attention). AttentionSeq orders onsets as the | 38 | // - Attention is any(tab.Attention). AttentionSeq orders onsets as the | ||
| 38 | // client observed them, across daemons, so the serving daemon can jump to | 39 | // client observed them, across daemons, so the serving daemon can jump to | ||
M internal/usecase/client/route_ledger_test.go +37 -0
| @@ -256,6 +256,43 @@ func TestRouteLedgerAttention(t *testing.T) { | |||
| 256 | 256 | } | |
| 257 | 257 | } | |
| 258 | 258 | ||
| 259 | + | // TestRouteLedgerVisitedPrefixPrecedesRingingRoutes pins the order the | |
| 260 | + | // daemon's status history relies on: visited routes stay a prefix of Entries | |
| 261 | + | // even when an unvisited route on a higher tier rings, so ringing routes never | |
| 262 | + | // shift the jump-recent ranks of visited ones. | |
| 263 | + | func TestRouteLedgerVisitedPrefixPrecedesRingingRoutes(t *testing.T) { | |
| 264 | + | tests := []struct { | |
| 265 | + | name string | |
| 266 | + | visited routeActive | |
| 267 | + | build ports.BrokerSnapshot | |
| 268 | + | }{ | |
| 269 | + | { | |
| 270 | + | name: "visited stopped local before ringing live local", | |
| 271 | + | visited: routeTestActive(true, "old", 2), | |
| 272 | + | build: routeTestSnapshot(routeTestLocal(routeTestLive("here", 4, 9), routeTestLive("loud", 1, 8, true), routeTestStopped("old", 2, 1))), | |
| 273 | + | }, | |
| 274 | + | { | |
| 275 | + | name: "visited remote before ringing live local", | |
| 276 | + | visited: routeTestActive(false, "r", 3), | |
| 277 | + | build: routeTestSnapshot(routeTestLocal(routeTestLive("here", 4, 9), routeTestLive("loud", 1, 8, true)), routeTestRemoteHost(routeTestLive("r", 3, 0))), | |
| 278 | + | }, | |
| 279 | + | } | |
| 280 | + | for _, tt := range tests { | |
| 281 | + | t.Run(tt.name, func(t *testing.T) { | |
| 282 | + | ledger := newRouteLedger() | |
| 283 | + | ledger.build(tt.build, tt.visited) | |
| 284 | + | snapshot, _ := ledger.build(tt.build, routeTestActive(true, "here", 4)) | |
| 285 | + | require.NoError(t, snapshot.Validate()) | |
| 286 | + | require.Len(t, snapshot.Entries, 2) | |
| 287 | + | require.True(t, snapshot.Entries[0].Visited, "the visited route leads") | |
| 288 | + | require.Equal(t, tt.visited.target.SessionName, snapshot.Entries[0].Name) | |
| 289 | + | require.False(t, snapshot.Entries[1].Visited) | |
| 290 | + | require.True(t, snapshot.Entries[1].Attention, "the ringing route follows") | |
| 291 | + | require.Equal(t, "loud", snapshot.Entries[1].Name) | |
| 292 | + | }) | |
| 293 | + | } | |
| 294 | + | } | |
| 295 | + | ||
| 259 | 296 | func TestRouteLedgerBounds(t *testing.T) { | |
| 260 | 297 | sessions := make([]catalogue.RemoteCatalogSession, 0, protocol.RouteSnapshotMaxEntries+5) | |
| 261 | 298 | for i := 0; i < protocol.RouteSnapshotMaxEntries+5; i++ { | |
| @@ -256,6 +256,43 @@ func TestRouteLedgerAttention(t *testing.T) { | |||||
| 256 | } | 256 | } | ||
| 257 | } | 257 | } | ||
| 258 | 258 | ||||
| 259 | + | // TestRouteLedgerVisitedPrefixPrecedesRingingRoutes pins the order the | |||
| 260 | + | // daemon's status history relies on: visited routes stay a prefix of Entries | |||
| 261 | + | // even when an unvisited route on a higher tier rings, so ringing routes never | |||
| 262 | + | // shift the jump-recent ranks of visited ones. | |||
| 263 | + | func TestRouteLedgerVisitedPrefixPrecedesRingingRoutes(t *testing.T) { | |||
| 264 | + | tests := []struct { | |||
| 265 | + | name string | |||
| 266 | + | visited routeActive | |||
| 267 | + | build ports.BrokerSnapshot | |||
| 268 | + | }{ | |||
| 269 | + | { | |||
| 270 | + | name: "visited stopped local before ringing live local", | |||
| 271 | + | visited: routeTestActive(true, "old", 2), | |||
| 272 | + | build: routeTestSnapshot(routeTestLocal(routeTestLive("here", 4, 9), routeTestLive("loud", 1, 8, true), routeTestStopped("old", 2, 1))), | |||
| 273 | + | }, | |||
| 274 | + | { | |||
| 275 | + | name: "visited remote before ringing live local", | |||
| 276 | + | visited: routeTestActive(false, "r", 3), | |||
| 277 | + | build: routeTestSnapshot(routeTestLocal(routeTestLive("here", 4, 9), routeTestLive("loud", 1, 8, true)), routeTestRemoteHost(routeTestLive("r", 3, 0))), | |||
| 278 | + | }, | |||
| 279 | + | } | |||
| 280 | + | for _, tt := range tests { | |||
| 281 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 282 | + | ledger := newRouteLedger() | |||
| 283 | + | ledger.build(tt.build, tt.visited) | |||
| 284 | + | snapshot, _ := ledger.build(tt.build, routeTestActive(true, "here", 4)) | |||
| 285 | + | require.NoError(t, snapshot.Validate()) | |||
| 286 | + | require.Len(t, snapshot.Entries, 2) | |||
| 287 | + | require.True(t, snapshot.Entries[0].Visited, "the visited route leads") | |||
| 288 | + | require.Equal(t, tt.visited.target.SessionName, snapshot.Entries[0].Name) | |||
| 289 | + | require.False(t, snapshot.Entries[1].Visited) | |||
| 290 | + | require.True(t, snapshot.Entries[1].Attention, "the ringing route follows") | |||
| 291 | + | require.Equal(t, "loud", snapshot.Entries[1].Name) | |||
| 292 | + | }) | |||
| 293 | + | } | |||
| 294 | + | } | |||
| 295 | + | ||||
| 259 | func TestRouteLedgerBounds(t *testing.T) { | 296 | func TestRouteLedgerBounds(t *testing.T) { | ||
| 260 | sessions := make([]catalogue.RemoteCatalogSession, 0, protocol.RouteSnapshotMaxEntries+5) | 297 | sessions := make([]catalogue.RemoteCatalogSession, 0, protocol.RouteSnapshotMaxEntries+5) | ||
| 261 | for i := 0; i < protocol.RouteSnapshotMaxEntries+5; i++ { | 298 | for i := 0; i < protocol.RouteSnapshotMaxEntries+5; i++ { | ||
M internal/usecase/daemon/palette.go +4 -4
| @@ -282,7 +282,7 @@ func (d *Daemon) refreshPalette(ac *attachedClient) { | |||
| 282 | 282 | } | |
| 283 | 283 | ||
| 284 | 284 | func recentRouteHints(snapshot protocol.RecentRouteSnapshot, args []string) palette.ContextualHints { | |
| 285 | - | snapshot = visitedRouteSnapshot(snapshot) | |
| 285 | + | snapshot = historyRouteSnapshot(snapshot) | |
| 286 | 286 | formatted := formatRecentRouteSnapshot(snapshot) | |
| 287 | 287 | names := make([]string, len(formatted)) | |
| 288 | 288 | for i, entry := range formatted { | |
| @@ -1124,11 +1124,11 @@ func (e paletteExec) YankLastNotification() error { | |||
| 1124 | 1124 | } | |
| 1125 | 1125 | ||
| 1126 | 1126 | func (e paletteExec) JumpRecentSession(rank int) error { | |
| 1127 | - | visited := visitedRouteSnapshot(e.routeSnapshot) | |
| 1128 | - | if visited.Generation == 0 || rank < 1 || rank > len(visited.Entries) { | |
| 1127 | + | history := historyRouteSnapshot(e.routeSnapshot) | |
| 1128 | + | if history.Generation == 0 || rank < 1 || rank > len(history.Entries) { | |
| 1129 | 1129 | return command.ErrInvalidArguments | |
| 1130 | 1130 | } | |
| 1131 | - | entry := visited.Entries[rank-1] | |
| 1131 | + | entry := history.Entries[rank-1] | |
| 1132 | 1132 | return e.NavigateRecentRoute(protocol.RouteNavigationAction{ | |
| 1133 | 1133 | SnapshotGeneration: e.routeSnapshot.Generation, | |
| 1134 | 1134 | Key: entry.Key, | |
| @@ -282,7 +282,7 @@ func (d *Daemon) refreshPalette(ac *attachedClient) { | |||||
| 282 | } | 282 | } | ||
| 283 | 283 | ||||
| 284 | func recentRouteHints(snapshot protocol.RecentRouteSnapshot, args []string) palette.ContextualHints { | 284 | func recentRouteHints(snapshot protocol.RecentRouteSnapshot, args []string) palette.ContextualHints { | ||
| 285 | - | snapshot = visitedRouteSnapshot(snapshot) | 285 | + | snapshot = historyRouteSnapshot(snapshot) |
| 286 | formatted := formatRecentRouteSnapshot(snapshot) | 286 | formatted := formatRecentRouteSnapshot(snapshot) | ||
| 287 | names := make([]string, len(formatted)) | 287 | names := make([]string, len(formatted)) | ||
| 288 | for i, entry := range formatted { | 288 | for i, entry := range formatted { | ||
| @@ -1124,11 +1124,11 @@ func (e paletteExec) YankLastNotification() error { | |||||
| 1124 | } | 1124 | } | ||
| 1125 | 1125 | ||||
| 1126 | func (e paletteExec) JumpRecentSession(rank int) error { | 1126 | func (e paletteExec) JumpRecentSession(rank int) error { | ||
| 1127 | - | visited := visitedRouteSnapshot(e.routeSnapshot) | 1127 | + | history := historyRouteSnapshot(e.routeSnapshot) |
| 1128 | - | if visited.Generation == 0 || rank < 1 || rank > len(visited.Entries) { | 1128 | + | if history.Generation == 0 || rank < 1 || rank > len(history.Entries) { |
| 1129 | return command.ErrInvalidArguments | 1129 | return command.ErrInvalidArguments | ||
| 1130 | } | 1130 | } | ||
| 1131 | - | entry := visited.Entries[rank-1] | 1131 | + | entry := history.Entries[rank-1] |
| 1132 | return e.NavigateRecentRoute(protocol.RouteNavigationAction{ | 1132 | return e.NavigateRecentRoute(protocol.RouteNavigationAction{ | ||
| 1133 | SnapshotGeneration: e.routeSnapshot.Generation, | 1133 | SnapshotGeneration: e.routeSnapshot.Generation, | ||
| 1134 | Key: entry.Key, | 1134 | Key: entry.Key, | ||
M internal/usecase/daemon/recent_route_snapshot.go +11 -9
| @@ -35,21 +35,23 @@ func recentRoutePresentationsFromSnapshot(snapshot protocol.RecentRouteSnapshot) | |||
| 35 | 35 | return out | |
| 36 | 36 | } | |
| 37 | 37 | ||
| 38 | - | // visitedRouteSnapshot keeps only the routes this client attached to. The | |
| 39 | - | // status-bar history and jump-recent ranks start from the current session and | |
| 40 | - | // grow as the client visits others; the palette and attention keep the full | |
| 41 | - | // snapshot. | |
| 42 | - | func visitedRouteSnapshot(snapshot protocol.RecentRouteSnapshot) protocol.RecentRouteSnapshot { | |
| 38 | + | // historyRouteSnapshot keeps the routes the status-bar history and | |
| 39 | + | // jump-recent ranks show: every route this client attached to, plus every | |
| 40 | + | // route that rings. Attention is shared by all clients, so a fresh client | |
| 41 | + | // still sees a bell on a session it never visited. Visited routes lead the | |
| 42 | + | // snapshot, so their ranks are unaffected by ringing ones. The palette and | |
| 43 | + | // attention jumps keep the full snapshot. | |
| 44 | + | func historyRouteSnapshot(snapshot protocol.RecentRouteSnapshot) protocol.RecentRouteSnapshot { | |
| 43 | 45 | if snapshot.Entries == nil { | |
| 44 | 46 | return snapshot | |
| 45 | 47 | } | |
| 46 | - | visited := make([]protocol.RecentRouteEntry, 0, len(snapshot.Entries)) | |
| 48 | + | history := make([]protocol.RecentRouteEntry, 0, len(snapshot.Entries)) | |
| 47 | 49 | for _, entry := range snapshot.Entries { | |
| 48 | - | if entry.Visited { | |
| 49 | - | visited = append(visited, entry) | |
| 50 | + | if entry.Visited || entry.Attention { | |
| 51 | + | history = append(history, entry) | |
| 50 | 52 | } | |
| 51 | 53 | } | |
| 52 | - | snapshot.Entries = visited | |
| 54 | + | snapshot.Entries = history | |
| 53 | 55 | return snapshot | |
| 54 | 56 | } | |
| 55 | 57 | ||
| @@ -35,21 +35,23 @@ func recentRoutePresentationsFromSnapshot(snapshot protocol.RecentRouteSnapshot) | |||||
| 35 | return out | 35 | return out | ||
| 36 | } | 36 | } | ||
| 37 | 37 | ||||
| 38 | - | // visitedRouteSnapshot keeps only the routes this client attached to. The | 38 | + | // historyRouteSnapshot keeps the routes the status-bar history and |
| 39 | - | // status-bar history and jump-recent ranks start from the current session and | 39 | + | // jump-recent ranks show: every route this client attached to, plus every |
| 40 | - | // grow as the client visits others; the palette and attention keep the full | 40 | + | // route that rings. Attention is shared by all clients, so a fresh client |
| 41 | - | // snapshot. | 41 | + | // still sees a bell on a session it never visited. Visited routes lead the |
| 42 | - | func visitedRouteSnapshot(snapshot protocol.RecentRouteSnapshot) protocol.RecentRouteSnapshot { | 42 | + | // snapshot, so their ranks are unaffected by ringing ones. The palette and |
| 43 | + | // attention jumps keep the full snapshot. | |||
| 44 | + | func historyRouteSnapshot(snapshot protocol.RecentRouteSnapshot) protocol.RecentRouteSnapshot { | |||
| 43 | if snapshot.Entries == nil { | 45 | if snapshot.Entries == nil { | ||
| 44 | return snapshot | 46 | return snapshot | ||
| 45 | } | 47 | } | ||
| 46 | - | visited := make([]protocol.RecentRouteEntry, 0, len(snapshot.Entries)) | 48 | + | history := make([]protocol.RecentRouteEntry, 0, len(snapshot.Entries)) |
| 47 | for _, entry := range snapshot.Entries { | 49 | for _, entry := range snapshot.Entries { | ||
| 48 | - | if entry.Visited { | 50 | + | if entry.Visited || entry.Attention { |
| 49 | - | visited = append(visited, entry) | 51 | + | history = append(history, entry) |
| 50 | } | 52 | } | ||
| 51 | } | 53 | } | ||
| 52 | - | snapshot.Entries = visited | 54 | + | snapshot.Entries = history |
| 53 | return snapshot | 55 | return snapshot | ||
| 54 | } | 56 | } | ||
| 55 | 57 | ||||
M internal/usecase/daemon/recent_route_snapshot_test.go +30 -12
| @@ -65,21 +65,30 @@ func TestAttachmentRouteSnapshotCopiesPublishedEntries(t *testing.T) { | |||
| 65 | 65 | require.Equal(t, "before", ac.routeSnapshotCopy().Entries[0].Name) | |
| 66 | 66 | } | |
| 67 | 67 | ||
| 68 | - | func TestPaletteJumpRecentSessionTargetsVisitedRank(t *testing.T) { | |
| 68 | + | func TestPaletteJumpRecentSessionTargetsHistoryRank(t *testing.T) { | |
| 69 | 69 | unvisited := testRouteEntry(5, 1, "other", 5, protocol.RouteKindLocal) | |
| 70 | 70 | unvisited.Visited = false | |
| 71 | - | snapshot := protocol.RecentRouteSnapshot{ | |
| 71 | + | ringing := testRouteEntry(6, 1, "loud", 6, protocol.RouteKindLocal) | |
| 72 | + | ringing.Visited, ringing.Attention, ringing.AttentionSeq = false, true, 1 | |
| 73 | + | quiet := protocol.RecentRouteSnapshot{ | |
| 72 | 74 | Generation: 4, | |
| 73 | 75 | Entries: []protocol.RecentRouteEntry{unvisited, testRouteEntry(2, 3, "remote", 2, protocol.RouteKindRemote)}, | |
| 74 | 76 | } | |
| 77 | + | rung := protocol.RecentRouteSnapshot{ | |
| 78 | + | Generation: 4, | |
| 79 | + | Entries: []protocol.RecentRouteEntry{testRouteEntry(2, 3, "remote", 2, protocol.RouteKindRemote), unvisited, ringing}, | |
| 80 | + | } | |
| 75 | 81 | tests := []struct { | |
| 76 | - | name string | |
| 77 | - | rank int | |
| 78 | - | wantKey uint64 | |
| 79 | - | wantErr error | |
| 82 | + | name string | |
| 83 | + | snapshot protocol.RecentRouteSnapshot | |
| 84 | + | rank int | |
| 85 | + | wantKey uint64 | |
| 86 | + | wantGen uint64 | |
| 87 | + | wantErr error | |
| 80 | 88 | }{ | |
| 81 | - | {name: "rank 1 skips the unvisited entry", rank: 1, wantKey: 2}, | |
| 82 | - | {name: "rank beyond the visited prefix is refused", rank: 2, wantErr: command.ErrInvalidArguments}, | |
| 89 | + | {name: "rank 1 skips the unvisited entry", snapshot: quiet, rank: 1, wantKey: 2, wantGen: 3}, | |
| 90 | + | {name: "rank beyond the history is refused", snapshot: quiet, rank: 2, wantErr: command.ErrInvalidArguments}, | |
| 91 | + | {name: "rank after the visited prefix reaches a ringing entry", snapshot: rung, rank: 2, wantKey: 6, wantGen: 1}, | |
| 83 | 92 | } | |
| 84 | 93 | for _, tt := range tests { | |
| 85 | 94 | t.Run(tt.name, func(t *testing.T) { | |
| @@ -94,7 +103,7 @@ func TestPaletteJumpRecentSessionTargetsVisitedRank(t *testing.T) { | |||
| 94 | 103 | require.True(t, admitted) | |
| 95 | 104 | defer effect.End() | |
| 96 | 105 | ||
| 97 | - | exec := paletteExec{d: d, sess: source, attachment: source, ac: ac, routeSnapshot: snapshot, effect: effect} | |
| 106 | + | exec := paletteExec{d: d, sess: source, attachment: source, ac: ac, routeSnapshot: tt.snapshot, effect: effect} | |
| 98 | 107 | err := exec.JumpRecentSession(tt.rank) | |
| 99 | 108 | if tt.wantErr != nil { | |
| 100 | 109 | require.ErrorIs(t, err, tt.wantErr) | |
| @@ -110,8 +119,8 @@ func TestPaletteJumpRecentSessionTargetsVisitedRank(t *testing.T) { | |||
| 110 | 119 | } | |
| 111 | 120 | } | |
| 112 | 121 | require.True(t, found) | |
| 113 | - | require.Equal(t, protocol.RouteNavigationAction{SnapshotGeneration: 4, Key: tt.wantKey, Generation: 3}, action) | |
| 114 | - | hints := recentRouteHints(snapshot, nil) | |
| 122 | + | require.Equal(t, protocol.RouteNavigationAction{SnapshotGeneration: 4, Key: tt.wantKey, Generation: tt.wantGen}, action) | |
| 123 | + | hints := recentRouteHints(tt.snapshot, nil) | |
| 115 | 124 | require.Equal(t, action.Key, hints.Recent[tt.rank-1].Key, "hint and jump share the rank mapping") | |
| 116 | 125 | }) | |
| 117 | 126 | } | |
| @@ -134,9 +143,13 @@ func TestRecentRouteHintsRetainSnapshotSelectionIdentity(t *testing.T) { | |||
| 134 | 143 | require.Equal(t, uint64(7), hints.Recent[0].Generation) | |
| 135 | 144 | } | |
| 136 | 145 | ||
| 137 | - | func TestStatusHistoryShowsOnlyVisitedRoutes(t *testing.T) { | |
| 146 | + | func TestStatusHistoryShowsVisitedAndRingingRoutes(t *testing.T) { | |
| 138 | 147 | unvisited := testRouteEntry(4, 1, "other", 4, protocol.RouteKindLocal) | |
| 139 | 148 | unvisited.Visited = false | |
| 149 | + | ringingLocal := testRouteEntry(5, 1, "build", 5, protocol.RouteKindLocal) | |
| 150 | + | ringingLocal.Visited, ringingLocal.Attention, ringingLocal.AttentionSeq = false, true, 1 | |
| 151 | + | ringingRemote := testRouteEntry(6, 1, "deploy", 6, protocol.RouteKindRemote) | |
| 152 | + | ringingRemote.Visited, ringingRemote.Attention, ringingRemote.AttentionSeq = false, true, 2 | |
| 140 | 153 | tests := []struct { | |
| 141 | 154 | name string | |
| 142 | 155 | entries []protocol.RecentRouteEntry | |
| @@ -144,6 +157,8 @@ func TestStatusHistoryShowsOnlyVisitedRoutes(t *testing.T) { | |||
| 144 | 157 | }{ | |
| 145 | 158 | {name: "fresh client shows no history", entries: []protocol.RecentRouteEntry{unvisited}, want: nil}, | |
| 146 | 159 | {name: "visited routes keep their order", entries: []protocol.RecentRouteEntry{testRouteEntry(2, 1, "work", 2, protocol.RouteKindLocal), unvisited, testRouteEntry(3, 1, "logs", 3, protocol.RouteKindLocal)}, want: []string{"work", "logs"}}, | |
| 160 | + | {name: "fresh client still shows ringing local and remote routes", entries: []protocol.RecentRouteEntry{unvisited, ringingLocal, ringingRemote}, want: []string{"build", "deploy@remote"}}, | |
| 161 | + | {name: "ringing routes follow visited ones", entries: []protocol.RecentRouteEntry{testRouteEntry(2, 1, "work", 2, protocol.RouteKindLocal), unvisited, ringingLocal}, want: []string{"work", "build"}}, | |
| 147 | 162 | } | |
| 148 | 163 | for _, tt := range tests { | |
| 149 | 164 | t.Run(tt.name, func(t *testing.T) { | |
| @@ -155,6 +170,9 @@ func TestStatusHistoryShowsOnlyVisitedRoutes(t *testing.T) { | |||
| 155 | 170 | var names []string | |
| 156 | 171 | for _, entry := range state.mru { | |
| 157 | 172 | names = append(names, entry.name) | |
| 173 | + | if entry.name == "build" || entry.name == "deploy@remote" { | |
| 174 | + | require.True(t, entry.attention, "%s must draw its bell", entry.name) | |
| 175 | + | } | |
| 158 | 176 | } | |
| 159 | 177 | require.Equal(t, tt.want, names) | |
| 160 | 178 | require.Equal(t, len(tt.want), len(recentRouteHints(ac.routeSnapshotCopy(), nil).Recent)) | |
| @@ -65,21 +65,30 @@ func TestAttachmentRouteSnapshotCopiesPublishedEntries(t *testing.T) { | |||||
| 65 | require.Equal(t, "before", ac.routeSnapshotCopy().Entries[0].Name) | 65 | require.Equal(t, "before", ac.routeSnapshotCopy().Entries[0].Name) | ||
| 66 | } | 66 | } | ||
| 67 | 67 | ||||
| 68 | - | func TestPaletteJumpRecentSessionTargetsVisitedRank(t *testing.T) { | 68 | + | func TestPaletteJumpRecentSessionTargetsHistoryRank(t *testing.T) { |
| 69 | unvisited := testRouteEntry(5, 1, "other", 5, protocol.RouteKindLocal) | 69 | unvisited := testRouteEntry(5, 1, "other", 5, protocol.RouteKindLocal) | ||
| 70 | unvisited.Visited = false | 70 | unvisited.Visited = false | ||
| 71 | - | snapshot := protocol.RecentRouteSnapshot{ | 71 | + | ringing := testRouteEntry(6, 1, "loud", 6, protocol.RouteKindLocal) |
| 72 | + | ringing.Visited, ringing.Attention, ringing.AttentionSeq = false, true, 1 | |||
| 73 | + | quiet := protocol.RecentRouteSnapshot{ | |||
| 72 | Generation: 4, | 74 | Generation: 4, | ||
| 73 | Entries: []protocol.RecentRouteEntry{unvisited, testRouteEntry(2, 3, "remote", 2, protocol.RouteKindRemote)}, | 75 | Entries: []protocol.RecentRouteEntry{unvisited, testRouteEntry(2, 3, "remote", 2, protocol.RouteKindRemote)}, | ||
| 74 | } | 76 | } | ||
| 77 | + | rung := protocol.RecentRouteSnapshot{ | |||
| 78 | + | Generation: 4, | |||
| 79 | + | Entries: []protocol.RecentRouteEntry{testRouteEntry(2, 3, "remote", 2, protocol.RouteKindRemote), unvisited, ringing}, | |||
| 80 | + | } | |||
| 75 | tests := []struct { | 81 | tests := []struct { | ||
| 76 | - | name string | 82 | + | name string |
| 77 | - | rank int | 83 | + | snapshot protocol.RecentRouteSnapshot |
| 78 | - | wantKey uint64 | 84 | + | rank int |
| 79 | - | wantErr error | 85 | + | wantKey uint64 |
| 86 | + | wantGen uint64 | |||
| 87 | + | wantErr error | |||
| 80 | }{ | 88 | }{ | ||
| 81 | - | {name: "rank 1 skips the unvisited entry", rank: 1, wantKey: 2}, | 89 | + | {name: "rank 1 skips the unvisited entry", snapshot: quiet, rank: 1, wantKey: 2, wantGen: 3}, |
| 82 | - | {name: "rank beyond the visited prefix is refused", rank: 2, wantErr: command.ErrInvalidArguments}, | 90 | + | {name: "rank beyond the history is refused", snapshot: quiet, rank: 2, wantErr: command.ErrInvalidArguments}, |
| 91 | + | {name: "rank after the visited prefix reaches a ringing entry", snapshot: rung, rank: 2, wantKey: 6, wantGen: 1}, | |||
| 83 | } | 92 | } | ||
| 84 | for _, tt := range tests { | 93 | for _, tt := range tests { | ||
| 85 | t.Run(tt.name, func(t *testing.T) { | 94 | t.Run(tt.name, func(t *testing.T) { | ||
| @@ -94,7 +103,7 @@ func TestPaletteJumpRecentSessionTargetsVisitedRank(t *testing.T) { | |||||
| 94 | require.True(t, admitted) | 103 | require.True(t, admitted) | ||
| 95 | defer effect.End() | 104 | defer effect.End() | ||
| 96 | 105 | ||||
| 97 | - | exec := paletteExec{d: d, sess: source, attachment: source, ac: ac, routeSnapshot: snapshot, effect: effect} | 106 | + | exec := paletteExec{d: d, sess: source, attachment: source, ac: ac, routeSnapshot: tt.snapshot, effect: effect} |
| 98 | err := exec.JumpRecentSession(tt.rank) | 107 | err := exec.JumpRecentSession(tt.rank) | ||
| 99 | if tt.wantErr != nil { | 108 | if tt.wantErr != nil { | ||
| 100 | require.ErrorIs(t, err, tt.wantErr) | 109 | require.ErrorIs(t, err, tt.wantErr) | ||
| @@ -110,8 +119,8 @@ func TestPaletteJumpRecentSessionTargetsVisitedRank(t *testing.T) { | |||||
| 110 | } | 119 | } | ||
| 111 | } | 120 | } | ||
| 112 | require.True(t, found) | 121 | require.True(t, found) | ||
| 113 | - | require.Equal(t, protocol.RouteNavigationAction{SnapshotGeneration: 4, Key: tt.wantKey, Generation: 3}, action) | 122 | + | require.Equal(t, protocol.RouteNavigationAction{SnapshotGeneration: 4, Key: tt.wantKey, Generation: tt.wantGen}, action) |
| 114 | - | hints := recentRouteHints(snapshot, nil) | 123 | + | hints := recentRouteHints(tt.snapshot, nil) |
| 115 | require.Equal(t, action.Key, hints.Recent[tt.rank-1].Key, "hint and jump share the rank mapping") | 124 | require.Equal(t, action.Key, hints.Recent[tt.rank-1].Key, "hint and jump share the rank mapping") | ||
| 116 | }) | 125 | }) | ||
| 117 | } | 126 | } | ||
| @@ -134,9 +143,13 @@ func TestRecentRouteHintsRetainSnapshotSelectionIdentity(t *testing.T) { | |||||
| 134 | require.Equal(t, uint64(7), hints.Recent[0].Generation) | 143 | require.Equal(t, uint64(7), hints.Recent[0].Generation) | ||
| 135 | } | 144 | } | ||
| 136 | 145 | ||||
| 137 | - | func TestStatusHistoryShowsOnlyVisitedRoutes(t *testing.T) { | 146 | + | func TestStatusHistoryShowsVisitedAndRingingRoutes(t *testing.T) { |
| 138 | unvisited := testRouteEntry(4, 1, "other", 4, protocol.RouteKindLocal) | 147 | unvisited := testRouteEntry(4, 1, "other", 4, protocol.RouteKindLocal) | ||
| 139 | unvisited.Visited = false | 148 | unvisited.Visited = false | ||
| 149 | + | ringingLocal := testRouteEntry(5, 1, "build", 5, protocol.RouteKindLocal) | |||
| 150 | + | ringingLocal.Visited, ringingLocal.Attention, ringingLocal.AttentionSeq = false, true, 1 | |||
| 151 | + | ringingRemote := testRouteEntry(6, 1, "deploy", 6, protocol.RouteKindRemote) | |||
| 152 | + | ringingRemote.Visited, ringingRemote.Attention, ringingRemote.AttentionSeq = false, true, 2 | |||
| 140 | tests := []struct { | 153 | tests := []struct { | ||
| 141 | name string | 154 | name string | ||
| 142 | entries []protocol.RecentRouteEntry | 155 | entries []protocol.RecentRouteEntry | ||
| @@ -144,6 +157,8 @@ func TestStatusHistoryShowsOnlyVisitedRoutes(t *testing.T) { | |||||
| 144 | }{ | 157 | }{ | ||
| 145 | {name: "fresh client shows no history", entries: []protocol.RecentRouteEntry{unvisited}, want: nil}, | 158 | {name: "fresh client shows no history", entries: []protocol.RecentRouteEntry{unvisited}, want: nil}, | ||
| 146 | {name: "visited routes keep their order", entries: []protocol.RecentRouteEntry{testRouteEntry(2, 1, "work", 2, protocol.RouteKindLocal), unvisited, testRouteEntry(3, 1, "logs", 3, protocol.RouteKindLocal)}, want: []string{"work", "logs"}}, | 159 | {name: "visited routes keep their order", entries: []protocol.RecentRouteEntry{testRouteEntry(2, 1, "work", 2, protocol.RouteKindLocal), unvisited, testRouteEntry(3, 1, "logs", 3, protocol.RouteKindLocal)}, want: []string{"work", "logs"}}, | ||
| 160 | + | {name: "fresh client still shows ringing local and remote routes", entries: []protocol.RecentRouteEntry{unvisited, ringingLocal, ringingRemote}, want: []string{"build", "deploy@remote"}}, | |||
| 161 | + | {name: "ringing routes follow visited ones", entries: []protocol.RecentRouteEntry{testRouteEntry(2, 1, "work", 2, protocol.RouteKindLocal), unvisited, ringingLocal}, want: []string{"work", "build"}}, | |||
| 147 | } | 162 | } | ||
| 148 | for _, tt := range tests { | 163 | for _, tt := range tests { | ||
| 149 | t.Run(tt.name, func(t *testing.T) { | 164 | t.Run(tt.name, func(t *testing.T) { | ||
| @@ -155,6 +170,9 @@ func TestStatusHistoryShowsOnlyVisitedRoutes(t *testing.T) { | |||||
| 155 | var names []string | 170 | var names []string | ||
| 156 | for _, entry := range state.mru { | 171 | for _, entry := range state.mru { | ||
| 157 | names = append(names, entry.name) | 172 | names = append(names, entry.name) | ||
| 173 | + | if entry.name == "build" || entry.name == "deploy@remote" { | |||
| 174 | + | require.True(t, entry.attention, "%s must draw its bell", entry.name) | |||
| 175 | + | } | |||
| 158 | } | 176 | } | ||
| 159 | require.Equal(t, tt.want, names) | 177 | require.Equal(t, tt.want, names) | ||
| 160 | require.Equal(t, len(tt.want), len(recentRouteHints(ac.routeSnapshotCopy(), nil).Recent)) | 178 | require.Equal(t, len(tt.want), len(recentRouteHints(ac.routeSnapshotCopy(), nil).Recent)) | ||
M internal/usecase/daemon/status.go +2 -2
| @@ -237,7 +237,7 @@ func rankedRecentForHintsWithSnapshot(hints *palette.ContextualHints, snapshot p | |||
| 237 | 237 | } | |
| 238 | 238 | var formatted []recentRouteDisplay | |
| 239 | 239 | if snapshot.Generation != 0 { | |
| 240 | - | formatted = formatRecentRouteSnapshot(visitedRouteSnapshot(snapshot)) | |
| 240 | + | formatted = formatRecentRouteSnapshot(historyRouteSnapshot(snapshot)) | |
| 241 | 241 | } | |
| 242 | 242 | entries := make([]rankedRecent, 0, len(hints.Recent)) | |
| 243 | 243 | for i, hint := range hints.Recent { | |
| @@ -296,7 +296,7 @@ func (d *Daemon) barStateForAttachmentPaletteHintsFor(cur *session, ac *attached | |||
| 296 | 296 | if ranked != nil { | |
| 297 | 297 | state.rankedRecent = ranked | |
| 298 | 298 | } else if routeSnapshot.Generation != 0 { | |
| 299 | - | state.mru = formatRecentRouteSnapshot(visitedRouteSnapshot(routeSnapshot)) | |
| 299 | + | state.mru = formatRecentRouteSnapshot(historyRouteSnapshot(routeSnapshot)) | |
| 300 | 300 | } | |
| 301 | 301 | return state | |
| 302 | 302 | } | |
| @@ -237,7 +237,7 @@ func rankedRecentForHintsWithSnapshot(hints *palette.ContextualHints, snapshot p | |||||
| 237 | } | 237 | } | ||
| 238 | var formatted []recentRouteDisplay | 238 | var formatted []recentRouteDisplay | ||
| 239 | if snapshot.Generation != 0 { | 239 | if snapshot.Generation != 0 { | ||
| 240 | - | formatted = formatRecentRouteSnapshot(visitedRouteSnapshot(snapshot)) | 240 | + | formatted = formatRecentRouteSnapshot(historyRouteSnapshot(snapshot)) |
| 241 | } | 241 | } | ||
| 242 | entries := make([]rankedRecent, 0, len(hints.Recent)) | 242 | entries := make([]rankedRecent, 0, len(hints.Recent)) | ||
| 243 | for i, hint := range hints.Recent { | 243 | for i, hint := range hints.Recent { | ||
| @@ -296,7 +296,7 @@ func (d *Daemon) barStateForAttachmentPaletteHintsFor(cur *session, ac *attached | |||||
| 296 | if ranked != nil { | 296 | if ranked != nil { | ||
| 297 | state.rankedRecent = ranked | 297 | state.rankedRecent = ranked | ||
| 298 | } else if routeSnapshot.Generation != 0 { | 298 | } else if routeSnapshot.Generation != 0 { | ||
| 299 | - | state.mru = formatRecentRouteSnapshot(visitedRouteSnapshot(routeSnapshot)) | 299 | + | state.mru = formatRecentRouteSnapshot(historyRouteSnapshot(routeSnapshot)) |
| 300 | } | 300 | } | ||
| 301 | return state | 301 | return state | ||
| 302 | } | 302 | } | ||