an app to share curated trails sidetrail.app
1

Configure Feed

Select the types of activity you want to include in your feed.

at main 28 kB View raw
1/** 2 * Trail Lifecycle Tests 3 * 4 * Scenarios covering trails from creation through deletion, 5 * including how they appear to users and cascade effects. 6 */ 7 8import { describe, it, expect } from "vitest"; 9import * as queries from "../queries"; 10import { 11 emit, 12 emitDelete, 13 ALICE, 14 BOB, 15 CAROL, 16 DAVE, 17 EVE, 18 FRANK, 19 GRACE, 20 setCurrentUser, 21 generateTid, 22} from "./helpers"; 23 24const now = () => new Date().toISOString(); 25 26describe("Publishing a trail", () => { 27 it("new trail with activity appears in the home feed and creator's profile", async () => { 28 const stopTid = generateTid(); 29 const trail = await emit("app.sidetrail.trail", ALICE.did, { 30 $type: "app.sidetrail.trail", 31 title: "My First Trail", 32 description: "A beginner's guide", 33 stops: [ 34 { tid: stopTid, title: "Start Here", content: "Welcome!" }, 35 { tid: generateTid(), title: "Next Step", content: "Keep going!" }, 36 ], 37 accentColor: "#ff5500", 38 backgroundColor: "#fff8f0", 39 createdAt: now(), 40 }); 41 42 // Add non-author activity so it appears in home feed 43 await emit("app.sidetrail.walk", BOB.did, { 44 $type: "app.sidetrail.walk", 45 trail: { uri: trail.uri, cid: trail.cid }, 46 visitedStops: [stopTid], 47 createdAt: now(), 48 updatedAt: now(), 49 }); 50 51 // Appears in home feed (requires non-author activity) 52 const trails = await queries.loadTrails(); 53 expect(trails).toHaveLength(1); 54 expect(trails[0].title).toBe("My First Trail"); 55 expect(trails[0].creator.handle).toBe(ALICE.handle); 56 expect(trails[0].stopsCount).toBe(2); 57 58 // Appears on creator's profile (doesn't require activity) 59 const publishedTrails = await queries.loadUserPublishedTrails(ALICE.handle); 60 expect(publishedTrails).toHaveLength(1); 61 expect(publishedTrails[0].title).toBe("My First Trail"); 62 63 // Detail page works 64 const detail = await queries.loadTrailDetail(ALICE.handle, trail.rkey); 65 expect(detail.header.title).toBe("My First Trail"); 66 expect(detail.stops).toHaveLength(2); 67 }); 68 69 it("trail without activity appears in home feed as fallback", async () => { 70 // Create a trail with no activity 71 await emit("app.sidetrail.trail", ALICE.did, { 72 $type: "app.sidetrail.trail", 73 title: "Lonely Trail", 74 description: "No one has walked this", 75 stops: [ 76 { tid: generateTid(), title: "Stop", content: "Content" }, 77 { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 78 ], 79 accentColor: "#111", 80 backgroundColor: "#eee", 81 createdAt: now(), 82 }); 83 84 // Appears in home feed even without activity 85 const trails = await queries.loadTrails(); 86 expect(trails).toHaveLength(1); 87 expect(trails[0].title).toBe("Lonely Trail"); 88 89 // Also appears on creator's profile 90 const publishedTrails = await queries.loadUserPublishedTrails(ALICE.handle); 91 expect(publishedTrails).toHaveLength(1); 92 }); 93}); 94 95describe("Discovering trails with active walkers", () => { 96 it("trail cards show up to 3 most recent walkers", async () => { 97 const stopTid = generateTid(); 98 const trail = await emit("app.sidetrail.trail", ALICE.did, { 99 $type: "app.sidetrail.trail", 100 title: "Popular Trail", 101 description: "Many people walking", 102 stops: [ 103 { tid: stopTid, title: "Stop", content: "Content" }, 104 { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 105 ], 106 accentColor: "#ff0000", 107 backgroundColor: "#ffffff", 108 createdAt: now(), 109 }); 110 111 // Four people walk - oldest first 112 await emit("app.sidetrail.walk", BOB.did, { 113 $type: "app.sidetrail.walk", 114 trail: { uri: trail.uri, cid: trail.cid }, 115 visitedStops: [stopTid], 116 createdAt: "2024-01-01T10:00:00Z", 117 updatedAt: "2024-01-01T10:00:00Z", 118 }); 119 120 await emit("app.sidetrail.walk", CAROL.did, { 121 $type: "app.sidetrail.walk", 122 trail: { uri: trail.uri, cid: trail.cid }, 123 visitedStops: [stopTid], 124 createdAt: "2024-01-02T10:00:00Z", 125 updatedAt: "2024-01-02T10:00:00Z", 126 }); 127 128 await emit("app.sidetrail.walk", DAVE.did, { 129 $type: "app.sidetrail.walk", 130 trail: { uri: trail.uri, cid: trail.cid }, 131 visitedStops: [stopTid], 132 createdAt: "2024-01-03T10:00:00Z", 133 updatedAt: "2024-01-03T10:00:00Z", 134 }); 135 136 await emit("app.sidetrail.walk", EVE.did, { 137 $type: "app.sidetrail.walk", 138 trail: { uri: trail.uri, cid: trail.cid }, 139 visitedStops: [stopTid], 140 createdAt: "2024-01-04T10:00:00Z", 141 updatedAt: "2024-01-04T10:00:00Z", 142 }); 143 144 await emit("app.sidetrail.walk", FRANK.did, { 145 $type: "app.sidetrail.walk", 146 trail: { uri: trail.uri, cid: trail.cid }, 147 visitedStops: [stopTid], 148 createdAt: "2024-01-05T10:00:00Z", 149 updatedAt: "2024-01-05T10:00:00Z", 150 }); 151 152 await emit("app.sidetrail.walk", GRACE.did, { 153 $type: "app.sidetrail.walk", 154 trail: { uri: trail.uri, cid: trail.cid }, 155 visitedStops: [stopTid], 156 createdAt: "2024-01-06T10:00:00Z", 157 updatedAt: "2024-01-06T10:00:00Z", 158 }); 159 160 const trails = await queries.loadTrails(); 161 const activeWalkers = await queries.loadTrailActiveWalkers(trails[0].uri); 162 163 // Should show exactly 5, most recent first (limit is 5) 164 expect(activeWalkers).toHaveLength(5); 165 // Bob (oldest) should be excluded 166 const handles = activeWalkers.map((w: { handle: string }) => w.handle); 167 expect(handles).not.toContain(BOB.handle); 168 }); 169 170 it("active walkers shows most recent activity first", async () => { 171 const stopTid = generateTid(); 172 const trail = await emit("app.sidetrail.trail", ALICE.did, { 173 $type: "app.sidetrail.trail", 174 title: "Activity Order Trail", 175 description: "Testing order", 176 stops: [ 177 { tid: stopTid, title: "Stop", content: "Content" }, 178 { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 179 ], 180 accentColor: "#00ff00", 181 backgroundColor: "#f0fff0", 182 createdAt: now(), 183 }); 184 185 // Bob walks first, Carol walks later 186 await emit("app.sidetrail.walk", BOB.did, { 187 $type: "app.sidetrail.walk", 188 trail: { uri: trail.uri, cid: trail.cid }, 189 visitedStops: [stopTid], 190 createdAt: "2024-01-01T10:00:00Z", 191 updatedAt: "2024-01-01T10:00:00Z", 192 }); 193 194 await emit("app.sidetrail.walk", CAROL.did, { 195 $type: "app.sidetrail.walk", 196 trail: { uri: trail.uri, cid: trail.cid }, 197 visitedStops: [stopTid], 198 createdAt: "2024-06-01T10:00:00Z", 199 updatedAt: "2024-06-01T10:00:00Z", 200 }); 201 202 const trails = await queries.loadTrails(); 203 const activeWalkers = await queries.loadTrailActiveWalkers(trails[0].uri); 204 205 expect(activeWalkers[0].handle).toBe(CAROL.handle); 206 }); 207}); 208 209describe("Viewing a trail's detail page", () => { 210 it("shows walkers at their current stops", async () => { 211 const stop1 = generateTid(); 212 const stop2 = generateTid(); 213 const stop3 = generateTid(); 214 215 const trail = await emit("app.sidetrail.trail", ALICE.did, { 216 $type: "app.sidetrail.trail", 217 title: "Multi-stop Trail", 218 description: "See where everyone is", 219 stops: [ 220 { tid: stop1, title: "Beginning", content: "Start here" }, 221 { tid: stop2, title: "Middle", content: "Keep going" }, 222 { tid: stop3, title: "End", content: "Almost there" }, 223 ], 224 accentColor: "#0000ff", 225 backgroundColor: "#f0f0ff", 226 createdAt: now(), 227 }); 228 229 // Bob at stop 1, Carol at stop 2, Dave finished (at stop 3) 230 await emit("app.sidetrail.walk", BOB.did, { 231 $type: "app.sidetrail.walk", 232 trail: { uri: trail.uri, cid: trail.cid }, 233 visitedStops: [stop1], 234 createdAt: now(), 235 updatedAt: now(), 236 }); 237 238 await emit("app.sidetrail.walk", CAROL.did, { 239 $type: "app.sidetrail.walk", 240 trail: { uri: trail.uri, cid: trail.cid }, 241 visitedStops: [stop1, stop2], 242 createdAt: now(), 243 updatedAt: now(), 244 }); 245 246 await emit("app.sidetrail.walk", DAVE.did, { 247 $type: "app.sidetrail.walk", 248 trail: { uri: trail.uri, cid: trail.cid }, 249 visitedStops: [stop1, stop2, stop3], 250 createdAt: now(), 251 updatedAt: now(), 252 }); 253 254 const detail = await queries.loadTrailDetail(ALICE.handle, trail.rkey); 255 256 const atStop1 = await detail.stops[0].walkersHere; 257 const atStop2 = await detail.stops[1].walkersHere; 258 const atStop3 = await detail.stops[2].walkersHere; 259 260 expect(atStop1.map((w) => w.handle)).toEqual([BOB.handle]); 261 expect(atStop2.map((w) => w.handle)).toEqual([CAROL.handle]); 262 expect(atStop3.map((w) => w.handle)).toEqual([DAVE.handle]); 263 }); 264 265 it("shows all walkers including those who completed", async () => { 266 const stopTid = generateTid(); 267 const trail = await emit("app.sidetrail.trail", ALICE.did, { 268 $type: "app.sidetrail.trail", 269 title: "Walkers and Completers", 270 description: "Mixed activity", 271 stops: [ 272 { tid: stopTid, title: "Stop", content: "Content" }, 273 { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 274 ], 275 accentColor: "#ff00ff", 276 backgroundColor: "#fff0ff", 277 createdAt: now(), 278 }); 279 280 // Bob is actively walking 281 await emit("app.sidetrail.walk", BOB.did, { 282 $type: "app.sidetrail.walk", 283 trail: { uri: trail.uri, cid: trail.cid }, 284 visitedStops: [stopTid], 285 createdAt: now(), 286 updatedAt: now(), 287 }); 288 289 // Carol completed the trail 290 await emit("app.sidetrail.completion", CAROL.did, { 291 $type: "app.sidetrail.completion", 292 trail: { uri: trail.uri, cid: trail.cid }, 293 createdAt: now(), 294 }); 295 296 const detail = await queries.loadTrailDetail(ALICE.handle, trail.rkey); 297 const walkers = await detail.walkers; 298 const completions = await detail.completions; 299 300 // Both appear in walkers (activity feed) 301 expect(walkers).toHaveLength(2); 302 const walkerHandles = walkers.map((w) => w.user.handle); 303 expect(walkerHandles).toContain(BOB.handle); 304 expect(walkerHandles).toContain(CAROL.handle); 305 306 // Only Carol in completions 307 expect(completions).toHaveLength(1); 308 expect(completions[0].user.handle).toBe(CAROL.handle); 309 }); 310 311 it("marks current user in walkers list", async () => { 312 const stopTid = generateTid(); 313 const trail = await emit("app.sidetrail.trail", ALICE.did, { 314 $type: "app.sidetrail.trail", 315 title: "IsYou Test", 316 description: "Testing current user marker", 317 stops: [ 318 { tid: stopTid, title: "Stop", content: "Content" }, 319 { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 320 ], 321 accentColor: "#123456", 322 backgroundColor: "#654321", 323 createdAt: now(), 324 }); 325 326 await emit("app.sidetrail.walk", BOB.did, { 327 $type: "app.sidetrail.walk", 328 trail: { uri: trail.uri, cid: trail.cid }, 329 visitedStops: [stopTid], 330 createdAt: now(), 331 updatedAt: now(), 332 }); 333 334 await emit("app.sidetrail.walk", CAROL.did, { 335 $type: "app.sidetrail.walk", 336 trail: { uri: trail.uri, cid: trail.cid }, 337 visitedStops: [stopTid], 338 createdAt: now(), 339 updatedAt: now(), 340 }); 341 342 setCurrentUser(BOB); 343 const detail = await queries.loadTrailDetail(ALICE.handle, trail.rkey); 344 const walkers = await detail.walkers; 345 346 const bob = walkers.find((w) => w.user.handle === BOB.handle); 347 const carol = walkers.find((w) => w.user.handle === CAROL.handle); 348 349 expect(bob?.isYou).toBe(true); 350 expect(carol?.isYou).toBe(false); 351 }); 352}); 353 354describe("Deleting a trail", () => { 355 it("trail disappears from home feed and author's profile", async () => { 356 const stopTid = generateTid(); 357 const trail = await emit("app.sidetrail.trail", ALICE.did, { 358 $type: "app.sidetrail.trail", 359 title: "Ephemeral Trail", 360 description: "Will be deleted", 361 stops: [ 362 { tid: stopTid, title: "Stop", content: "Content" }, 363 { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 364 ], 365 accentColor: "#aaa", 366 backgroundColor: "#bbb", 367 createdAt: now(), 368 }); 369 370 // Add non-author activity so it appears in home feed 371 await emit("app.sidetrail.walk", BOB.did, { 372 $type: "app.sidetrail.walk", 373 trail: { uri: trail.uri, cid: trail.cid }, 374 visitedStops: [stopTid], 375 createdAt: now(), 376 updatedAt: now(), 377 }); 378 379 let trails = await queries.loadTrails(); 380 expect(trails).toHaveLength(1); 381 382 await emitDelete("app.sidetrail.trail", trail.uri); 383 384 trails = await queries.loadTrails(); 385 expect(trails).toHaveLength(0); 386 387 const publishedTrails = await queries.loadUserPublishedTrails(ALICE.handle); 388 expect(publishedTrails).toHaveLength(0); 389 }); 390 391 it("detail page throws 'Trail not found' after deletion", async () => { 392 const trail = await emit("app.sidetrail.trail", ALICE.did, { 393 $type: "app.sidetrail.trail", 394 title: "Soon Gone", 395 description: "Will 404", 396 stops: [ 397 { tid: generateTid(), title: "Stop", content: "Content" }, 398 { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 399 ], 400 accentColor: "#ccc", 401 backgroundColor: "#ddd", 402 createdAt: now(), 403 }); 404 405 await emitDelete("app.sidetrail.trail", trail.uri); 406 407 await expect(queries.loadTrailDetail(ALICE.handle, trail.rkey)).rejects.toThrow( 408 "Trail not found", 409 ); 410 }); 411 412 it("walkers' in-progress walks disappear when trail is deleted", async () => { 413 const stopTid = generateTid(); 414 const trail = await emit("app.sidetrail.trail", ALICE.did, { 415 $type: "app.sidetrail.trail", 416 title: "Disappearing Trail", 417 description: "Walkers will be orphaned", 418 stops: [ 419 { tid: stopTid, title: "Stop", content: "Content" }, 420 { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 421 ], 422 accentColor: "#111", 423 backgroundColor: "#222", 424 createdAt: now(), 425 }); 426 427 await emit("app.sidetrail.walk", BOB.did, { 428 $type: "app.sidetrail.walk", 429 trail: { uri: trail.uri, cid: trail.cid }, 430 visitedStops: [stopTid], 431 createdAt: now(), 432 updatedAt: now(), 433 }); 434 435 setCurrentUser(BOB); 436 let walks = await queries.loadWalks(); 437 expect(walks).toHaveLength(1); 438 439 // Trail author deletes the trail 440 await emitDelete("app.sidetrail.trail", trail.uri); 441 442 // Bob's walk is orphaned - doesn't appear in his walks 443 walks = await queries.loadWalks(); 444 expect(walks).toHaveLength(0); 445 }); 446 447 it("completions disappear from profiles when trail is deleted", async () => { 448 const trail = await emit("app.sidetrail.trail", ALICE.did, { 449 $type: "app.sidetrail.trail", 450 title: "Completed Then Deleted", 451 description: "Completion will be orphaned", 452 stops: [ 453 { tid: generateTid(), title: "Stop", content: "Content" }, 454 { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 455 ], 456 accentColor: "#333", 457 backgroundColor: "#444", 458 createdAt: now(), 459 }); 460 461 await emit("app.sidetrail.completion", BOB.did, { 462 $type: "app.sidetrail.completion", 463 trail: { uri: trail.uri, cid: trail.cid }, 464 createdAt: now(), 465 }); 466 467 let completedTrails = await queries.loadUserCompletedTrails(BOB.handle); 468 expect(completedTrails).toHaveLength(1); 469 470 await emitDelete("app.sidetrail.trail", trail.uri); 471 472 completedTrails = await queries.loadUserCompletedTrails(BOB.handle); 473 expect(completedTrails).toHaveLength(0); 474 }); 475 476 it("walking badges disappear when trail is deleted", async () => { 477 const trail = await emit("app.sidetrail.trail", ALICE.did, { 478 $type: "app.sidetrail.trail", 479 title: "Badge Trail", 480 description: "Badge will disappear", 481 stops: [ 482 { tid: generateTid(), title: "Stop", content: "Content" }, 483 { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 484 ], 485 accentColor: "#abcdef", 486 backgroundColor: "#fedcba", 487 createdAt: now(), 488 }); 489 490 await emit("app.sidetrail.walk", BOB.did, { 491 $type: "app.sidetrail.walk", 492 trail: { uri: trail.uri, cid: trail.cid }, 493 visitedStops: [], 494 createdAt: now(), 495 updatedAt: now(), 496 }); 497 498 setCurrentUser(BOB); 499 let badges = await queries.loadWalkingBadges(); 500 expect(badges).toHaveLength(1); 501 502 await emitDelete("app.sidetrail.trail", trail.uri); 503 504 badges = await queries.loadWalkingBadges(); 505 expect(badges).toHaveLength(0); 506 }); 507}); 508 509describe("Hotness ranking", () => { 510 it("trail with more recent activity ranks higher", async () => { 511 const stopTidA = generateTid(); 512 const stopTidB = generateTid(); 513 514 // Two trails 515 const trailA = await emit("app.sidetrail.trail", ALICE.did, { 516 $type: "app.sidetrail.trail", 517 title: "Trail A - Old Activity", 518 description: "Walked a while ago", 519 stops: [ 520 { tid: stopTidA, title: "Stop", content: "Content" }, 521 { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 522 ], 523 accentColor: "#111", 524 backgroundColor: "#eee", 525 createdAt: now(), 526 }); 527 528 const trailB = await emit("app.sidetrail.trail", BOB.did, { 529 $type: "app.sidetrail.trail", 530 title: "Trail B - Recent Activity", 531 description: "Just walked", 532 stops: [ 533 { tid: stopTidB, title: "Stop", content: "Content" }, 534 { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 535 ], 536 accentColor: "#222", 537 backgroundColor: "#ddd", 538 createdAt: now(), 539 }); 540 541 // Trail A has older activity (7 days ago) 542 const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(); 543 await emit("app.sidetrail.walk", CAROL.did, { 544 $type: "app.sidetrail.walk", 545 trail: { uri: trailA.uri, cid: trailA.cid }, 546 visitedStops: [stopTidA], 547 createdAt: sevenDaysAgo, 548 updatedAt: sevenDaysAgo, 549 }); 550 551 // Trail B has recent activity (now) 552 await emit("app.sidetrail.walk", DAVE.did, { 553 $type: "app.sidetrail.walk", 554 trail: { uri: trailB.uri, cid: trailB.cid }, 555 visitedStops: [stopTidB], 556 createdAt: now(), 557 updatedAt: now(), 558 }); 559 560 const trails = await queries.loadTrails(); 561 562 // Trail B with more recent activity ranks higher 563 expect(trails[0].title).toBe("Trail B - Recent Activity"); 564 expect(trails[1].title).toBe("Trail A - Old Activity"); 565 }); 566 567 it("each unique walker contributes to hotness", async () => { 568 const stopTidA = generateTid(); 569 const stopTidB = generateTid(); 570 571 // Two trails 572 const trailA = await emit("app.sidetrail.trail", ALICE.did, { 573 $type: "app.sidetrail.trail", 574 title: "Trail A - One Walker", 575 description: "One person walking", 576 stops: [ 577 { tid: stopTidA, title: "Stop", content: "Content" }, 578 { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 579 ], 580 accentColor: "#111", 581 backgroundColor: "#eee", 582 createdAt: now(), 583 }); 584 585 const trailB = await emit("app.sidetrail.trail", BOB.did, { 586 $type: "app.sidetrail.trail", 587 title: "Trail B - Three Walkers", 588 description: "Three people walking", 589 stops: [ 590 { tid: stopTidB, title: "Stop", content: "Content" }, 591 { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 592 ], 593 accentColor: "#222", 594 backgroundColor: "#ddd", 595 createdAt: now(), 596 }); 597 598 // One walker on Trail A 599 await emit("app.sidetrail.walk", CAROL.did, { 600 $type: "app.sidetrail.walk", 601 trail: { uri: trailA.uri, cid: trailA.cid }, 602 visitedStops: [stopTidA], 603 createdAt: now(), 604 updatedAt: now(), 605 }); 606 607 // Three walkers on Trail B 608 await emit("app.sidetrail.walk", CAROL.did, { 609 $type: "app.sidetrail.walk", 610 trail: { uri: trailB.uri, cid: trailB.cid }, 611 visitedStops: [stopTidB], 612 createdAt: now(), 613 updatedAt: now(), 614 }); 615 616 await emit("app.sidetrail.walk", DAVE.did, { 617 $type: "app.sidetrail.walk", 618 trail: { uri: trailB.uri, cid: trailB.cid }, 619 visitedStops: [stopTidB], 620 createdAt: now(), 621 updatedAt: now(), 622 }); 623 624 await emit("app.sidetrail.walk", EVE.did, { 625 $type: "app.sidetrail.walk", 626 trail: { uri: trailB.uri, cid: trailB.cid }, 627 visitedStops: [stopTidB], 628 createdAt: now(), 629 updatedAt: now(), 630 }); 631 632 const trails = await queries.loadTrails(); 633 634 // Trail B with more walkers should rank higher 635 expect(trails[0].title).toBe("Trail B - Three Walkers"); 636 expect(trails[1].title).toBe("Trail A - One Walker"); 637 }); 638 639 it("completions also contribute to hotness", async () => { 640 const stopTidA = generateTid(); 641 const stopTidB = generateTid(); 642 643 // Two trails 644 const trailA = await emit("app.sidetrail.trail", ALICE.did, { 645 $type: "app.sidetrail.trail", 646 title: "Trail A - Only Walk", 647 description: "Someone is walking", 648 stops: [ 649 { tid: stopTidA, title: "Stop", content: "Content" }, 650 { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 651 ], 652 accentColor: "#111", 653 backgroundColor: "#eee", 654 createdAt: now(), 655 }); 656 657 const trailB = await emit("app.sidetrail.trail", BOB.did, { 658 $type: "app.sidetrail.trail", 659 title: "Trail B - Walk Plus Completion", 660 description: "Someone walked and completed", 661 stops: [ 662 { tid: stopTidB, title: "Stop", content: "Content" }, 663 { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 664 ], 665 accentColor: "#222", 666 backgroundColor: "#ddd", 667 createdAt: now(), 668 }); 669 670 // Trail A: one walk 671 await emit("app.sidetrail.walk", CAROL.did, { 672 $type: "app.sidetrail.walk", 673 trail: { uri: trailA.uri, cid: trailA.cid }, 674 visitedStops: [stopTidA], 675 createdAt: now(), 676 updatedAt: now(), 677 }); 678 679 // Trail B: one walk + one completion (more activity) 680 await emit("app.sidetrail.walk", DAVE.did, { 681 $type: "app.sidetrail.walk", 682 trail: { uri: trailB.uri, cid: trailB.cid }, 683 visitedStops: [stopTidB], 684 createdAt: now(), 685 updatedAt: now(), 686 }); 687 688 await emit("app.sidetrail.completion", EVE.did, { 689 $type: "app.sidetrail.completion", 690 trail: { uri: trailB.uri, cid: trailB.cid }, 691 createdAt: now(), 692 }); 693 694 const trails = await queries.loadTrails(); 695 696 // Trail B with more activity (walk + completion) ranks higher 697 expect(trails[0].title).toBe("Trail B - Walk Plus Completion"); 698 expect(trails[1].title).toBe("Trail A - Only Walk"); 699 }); 700 701 it("trails with activity rank above trails without activity", async () => { 702 const stopTid = generateTid(); 703 704 // Create an old trail with recent activity 705 const sixtyDaysAgo = new Date(Date.now() - 60 * 24 * 60 * 60 * 1000).toISOString(); 706 const oldTrailWithActivity = await emit("app.sidetrail.trail", ALICE.did, { 707 $type: "app.sidetrail.trail", 708 title: "Old Trail With Activity", 709 description: "Old but has activity", 710 stops: [ 711 { tid: stopTid, title: "Stop", content: "Content" }, 712 { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 713 ], 714 accentColor: "#111", 715 backgroundColor: "#eee", 716 createdAt: sixtyDaysAgo, 717 }); 718 719 // Create another newer trail with no activity 720 const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); 721 await emit("app.sidetrail.trail", BOB.did, { 722 $type: "app.sidetrail.trail", 723 title: "New Trail No Activity", 724 description: "Newer but no one has walked", 725 stops: [ 726 { tid: generateTid(), title: "Stop", content: "Content" }, 727 { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 728 ], 729 accentColor: "#222", 730 backgroundColor: "#ddd", 731 createdAt: oneDayAgo, 732 }); 733 734 // Add activity to the old trail 735 await emit("app.sidetrail.walk", CAROL.did, { 736 $type: "app.sidetrail.walk", 737 trail: { uri: oldTrailWithActivity.uri, cid: oldTrailWithActivity.cid }, 738 visitedStops: [stopTid], 739 createdAt: now(), 740 updatedAt: now(), 741 }); 742 743 const trails = await queries.loadTrails(); 744 745 // Recent activity outweighs creation recency 746 expect(trails).toHaveLength(2); 747 expect(trails[0].title).toBe("Old Trail With Activity"); 748 expect(trails[1].title).toBe("New Trail No Activity"); 749 }); 750 751 it("author's own activity does not contribute to hotness", async () => { 752 const stopTidA = generateTid(); 753 const stopTidB = generateTid(); 754 755 // Two trails created at the same time 756 const trailA = await emit("app.sidetrail.trail", ALICE.did, { 757 $type: "app.sidetrail.trail", 758 title: "Trail A - Author Self-Walk", 759 description: "Author walked their own trail", 760 stops: [ 761 { tid: stopTidA, title: "Stop", content: "Content" }, 762 { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 763 ], 764 accentColor: "#111", 765 backgroundColor: "#eee", 766 createdAt: now(), 767 }); 768 769 const trailB = await emit("app.sidetrail.trail", BOB.did, { 770 $type: "app.sidetrail.trail", 771 title: "Trail B - Non-Author Walk", 772 description: "Someone else walked", 773 stops: [ 774 { tid: stopTidB, title: "Stop", content: "Content" }, 775 { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 776 ], 777 accentColor: "#222", 778 backgroundColor: "#ddd", 779 createdAt: now(), 780 }); 781 782 // Trail A: author walks their own trail (shouldn't count toward hotness) 783 await emit("app.sidetrail.walk", ALICE.did, { 784 $type: "app.sidetrail.walk", 785 trail: { uri: trailA.uri, cid: trailA.cid }, 786 visitedStops: [stopTidA], 787 createdAt: now(), 788 updatedAt: now(), 789 }); 790 791 // Trail B: non-author walks (should count toward hotness) 792 await emit("app.sidetrail.walk", CAROL.did, { 793 $type: "app.sidetrail.walk", 794 trail: { uri: trailB.uri, cid: trailB.cid }, 795 visitedStops: [stopTidB], 796 createdAt: now(), 797 updatedAt: now(), 798 }); 799 800 const trails = await queries.loadTrails(); 801 802 // Both trails appear, but Trail B (with non-author activity) ranks first 803 // since author activity doesn't count toward the score 804 expect(trails).toHaveLength(2); 805 expect(trails[0].title).toBe("Trail B - Non-Author Walk"); 806 expect(trails[1].title).toBe("Trail A - Author Self-Walk"); 807 }); 808}); 809 810describe("Empty states", () => { 811 it("home feed is empty when no trails exist", async () => { 812 const trails = await queries.loadTrails(); 813 expect(trails).toHaveLength(0); 814 }); 815 816 it("trail detail page works even with no activity", async () => { 817 const trail = await emit("app.sidetrail.trail", ALICE.did, { 818 $type: "app.sidetrail.trail", 819 title: "Lonely Trail", 820 description: "Nobody here yet", 821 stops: [ 822 { tid: generateTid(), title: "Stop", content: "Content" }, 823 { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 824 ], 825 accentColor: "#555", 826 backgroundColor: "#666", 827 createdAt: now(), 828 }); 829 830 const detail = await queries.loadTrailDetail(ALICE.handle, trail.rkey); 831 const walkers = await detail.walkers; 832 const completions = await detail.completions; 833 const activeWalkers = await queries.loadTrailActiveWalkers(trail.uri); 834 835 expect(walkers).toHaveLength(0); 836 expect(completions).toHaveLength(0); 837 expect(activeWalkers).toHaveLength(0); 838 }); 839 840 it("non-existent trail throws error", async () => { 841 await expect(queries.loadTrailDetail(ALICE.handle, "nonexistent")).rejects.toThrow( 842 "Trail not found", 843 ); 844 }); 845});