39 changed files with 158 additions and 5047 deletions
@ -1,105 +0,0 @@ |
|||||||
#+TITLE: Navigation Visualization Clean Implementation Plan |
|
||||||
#+DATE: [2025-01-17] |
|
||||||
#+AUTHOR: gc-alexandria team |
|
||||||
|
|
||||||
* Overview |
|
||||||
|
|
||||||
Clean implementation plan for the event network visualization, focusing on performance and stability. |
|
||||||
|
|
||||||
* Core Principles |
|
||||||
|
|
||||||
1. **Load once, render many**: Fetch all data upfront, toggle visibility without re-fetching |
|
||||||
2. **Simple state management**: Avoid reactive Sets and circular dependencies |
|
||||||
3. **Batched operations**: Minimize network requests by combining queries |
|
||||||
4. **Clean separation**: UI controls in Legend, visualization logic in index.svelte |
|
||||||
|
|
||||||
* Implementation Phases |
|
||||||
|
|
||||||
** Phase 1: Tag Anchor Controls Migration |
|
||||||
- +Move tag type selection from Settings to Legend+ |
|
||||||
- +Move expansion depth control from Settings to Legend+ |
|
||||||
- +Move requirePublications checkbox from Settings to Legend+ |
|
||||||
- +Use native HTML button instead of flowbite Toggle component+ |
|
||||||
- +Clean up Settings panel+ |
|
||||||
|
|
||||||
** Phase 2: Person Visualizer |
|
||||||
- +Add collapsible "Person Visualizer" section in Legend+ |
|
||||||
- +Display all event authors (pubkeys) as list items+ |
|
||||||
- +Fetch display names from kind 0 events+ |
|
||||||
- +Render person nodes as diamond shapes in graph+ |
|
||||||
- +Default all person nodes to disabled state+ |
|
||||||
- +Click to toggle individual person visibility+ |
|
||||||
|
|
||||||
** Phase 3: State Management Fixes |
|
||||||
- Replace reactive Set with object/map for disabled states |
|
||||||
- Use $derived for computed values to avoid circular updates |
|
||||||
- Defer state updates with setTimeout where needed |
|
||||||
- Simplify $effect dependencies |
|
||||||
- Ensure clean data flow without loops |
|
||||||
|
|
||||||
** Phase 4: Fetch Optimization |
|
||||||
- Batch multiple event kinds into single queries |
|
||||||
- Combine 30041 and 30818 content fetches |
|
||||||
- Pre-fetch all person profiles on initial load |
|
||||||
- Cache profile data to avoid re-fetching |
|
||||||
|
|
||||||
** Phase 5: Load-Once Architecture |
|
||||||
- +Fetch ALL configured event kinds upfront (regardless of enabled state)+ |
|
||||||
- +Store complete dataset in memory+ |
|
||||||
- +Only render nodes that are enabled+ |
|
||||||
- +Toggle operations just change visibility, no re-fetch+ |
|
||||||
- +Prevents UI freezing on toggle operations+ |
|
||||||
|
|
||||||
* Technical Details |
|
||||||
|
|
||||||
** State Structure |
|
||||||
#+BEGIN_SRC typescript |
|
||||||
// Avoid Sets for reactive state |
|
||||||
let disabledTagsMap = $state<Record<string, boolean>>({}); |
|
||||||
let disabledPersonsMap = $state<Record<string, boolean>>({}); |
|
||||||
|
|
||||||
// Derived for compatibility |
|
||||||
const disabledTags = $derived(new Set(Object.keys(disabledTagsMap).filter(k => disabledTagsMap[k]))); |
|
||||||
const disabledPersons = $derived(new Set(Object.keys(disabledPersonsMap).filter(k => disabledPersonsMap[k]))); |
|
||||||
#+END_SRC |
|
||||||
|
|
||||||
** Person Node Structure |
|
||||||
#+BEGIN_SRC typescript |
|
||||||
interface PersonAnchor extends NetworkNode { |
|
||||||
type: "PersonAnchor"; |
|
||||||
isPersonAnchor: true; |
|
||||||
pubkey: string; |
|
||||||
displayName?: string; |
|
||||||
} |
|
||||||
#+END_SRC |
|
||||||
|
|
||||||
** Batch Fetch Example |
|
||||||
#+BEGIN_SRC typescript |
|
||||||
// Instead of separate queries |
|
||||||
const contentEvents = await $ndkInstance.fetchEvents({ |
|
||||||
kinds: [30041, 30818], // Batch multiple kinds |
|
||||||
"#d": Array.from(dTags), |
|
||||||
limit: combinedLimit |
|
||||||
}); |
|
||||||
#+END_SRC |
|
||||||
|
|
||||||
* Benefits |
|
||||||
|
|
||||||
1. **Performance**: No re-fetching on toggle operations |
|
||||||
2. **Stability**: Avoids infinite loops and reactive state issues |
|
||||||
3. **UX**: Smooth, instant toggle without freezing |
|
||||||
4. **Maintainability**: Clear separation of concerns |
|
||||||
5. **Scalability**: Handles large numbers of nodes efficiently |
|
||||||
|
|
||||||
* Additional Improvements |
|
||||||
|
|
||||||
** Profile Fetching Optimization |
|
||||||
- When follow list limit is 0, only fetch profiles from event authors |
|
||||||
- Excludes follow list pubkeys from profile fetching when not needed |
|
||||||
- Reduces unnecessary network requests |
|
||||||
|
|
||||||
** Person Node Visual Distinction |
|
||||||
- Green diamonds (#10B981) for authors of displayed events |
|
||||||
- Kind 3 color for people from follow lists |
|
||||||
- Visual clarity on social graph relationships |
|
||||||
- Legend updates to match graph coloring |
|
||||||
@ -1,332 +0,0 @@ |
|||||||
# Visualization Optimization Implementation Guide |
|
||||||
|
|
||||||
**Component**: `/src/lib/navigator/EventNetwork/index.svelte` |
|
||||||
**Author**: Claude Agent 3 (Master Coordinator) |
|
||||||
**Date**: January 6, 2025 |
|
||||||
|
|
||||||
## Implementation Details |
|
||||||
|
|
||||||
### 1. Update Type System |
|
||||||
|
|
||||||
The core of the optimization is a discriminated union type that categorizes parameter changes: |
|
||||||
|
|
||||||
```typescript |
|
||||||
type UpdateType = |
|
||||||
| { kind: 'full'; reason: string } |
|
||||||
| { kind: 'structural'; reason: string; params: Set<string> } |
|
||||||
| { kind: 'visual'; params: Set<string> }; |
|
||||||
``` |
|
||||||
|
|
||||||
### 2. Parameter Tracking |
|
||||||
|
|
||||||
Track current and previous parameter values to detect changes: |
|
||||||
|
|
||||||
```typescript |
|
||||||
let lastUpdateParams = $state<UpdateParams>({ |
|
||||||
events: events, |
|
||||||
eventCount: events?.length || 0, |
|
||||||
levels: currentLevels, |
|
||||||
star: starVisualization, |
|
||||||
tags: showTagAnchors, |
|
||||||
tagType: selectedTagType, |
|
||||||
disabledCount: disabledTags.size, |
|
||||||
tagExpansion: tagExpansionDepth, |
|
||||||
theme: isDarkMode |
|
||||||
}); |
|
||||||
``` |
|
||||||
|
|
||||||
### 3. Change Detection |
|
||||||
|
|
||||||
The update detection has been extracted to a utility module: |
|
||||||
|
|
||||||
```typescript |
|
||||||
import { |
|
||||||
type UpdateType, |
|
||||||
type UpdateParams, |
|
||||||
detectChanges, |
|
||||||
detectUpdateType as detectUpdateTypeUtil, |
|
||||||
logUpdateType |
|
||||||
} from "$lib/utils/updateDetection"; |
|
||||||
``` |
|
||||||
|
|
||||||
### 4. Visual Properties Update Function |
|
||||||
|
|
||||||
The optimized update function that modifies existing elements: |
|
||||||
|
|
||||||
```typescript |
|
||||||
function updateVisualProperties() { |
|
||||||
const startTime = performance.now(); |
|
||||||
debug("updateVisualProperties called"); |
|
||||||
|
|
||||||
if (!svgGroup || !simulation || !nodes.length) { |
|
||||||
debug("Cannot update visual properties - missing required elements"); |
|
||||||
return; |
|
||||||
} |
|
||||||
|
|
||||||
// Update simulation forces based on star mode |
|
||||||
if (starVisualization) { |
|
||||||
simulation |
|
||||||
.force("charge", d3.forceManyBody().strength(-300)) |
|
||||||
.force("link", d3.forceLink(links).id((d: any) => d.id).distance(LINK_DISTANCE)) |
|
||||||
.force("radial", d3.forceRadial(200, width / 2, height / 2)) |
|
||||||
.force("center", null); |
|
||||||
} else { |
|
||||||
simulation |
|
||||||
.force("charge", d3.forceManyBody().strength(-500)) |
|
||||||
.force("link", d3.forceLink(links).id((d: any) => d.id).distance(LINK_DISTANCE)) |
|
||||||
.force("radial", null) |
|
||||||
.force("center", d3.forceCenter(width / 2, height / 2)); |
|
||||||
} |
|
||||||
|
|
||||||
// Update node appearances in-place |
|
||||||
svgGroup.selectAll("g.node") |
|
||||||
.select("circle.visual-circle") |
|
||||||
.attr("class", (d: NetworkNode) => { |
|
||||||
// Class updates for star mode |
|
||||||
}) |
|
||||||
.attr("r", (d: NetworkNode) => { |
|
||||||
// Radius updates |
|
||||||
}) |
|
||||||
.attr("opacity", (d: NetworkNode) => { |
|
||||||
// Opacity for disabled tags |
|
||||||
}) |
|
||||||
.attr("fill", (d: NetworkNode) => { |
|
||||||
// Color updates for theme changes |
|
||||||
}); |
|
||||||
|
|
||||||
// Gentle restart |
|
||||||
simulation.alpha(0.3).restart(); |
|
||||||
|
|
||||||
const updateTime = performance.now() - startTime; |
|
||||||
debug(`Visual properties updated in ${updateTime.toFixed(2)}ms`); |
|
||||||
} |
|
||||||
``` |
|
||||||
|
|
||||||
### 5. Update Routing |
|
||||||
|
|
||||||
The main effect now routes updates based on type: |
|
||||||
|
|
||||||
```typescript |
|
||||||
$effect(() => { |
|
||||||
if (!svg || !events?.length) return; |
|
||||||
|
|
||||||
const currentParams: UpdateParams = { |
|
||||||
events, eventCount: events?.length || 0, |
|
||||||
levels: currentLevels, star: starVisualization, |
|
||||||
tags: showTagAnchors, tagType: selectedTagType, |
|
||||||
disabledCount: disabledTags.size, |
|
||||||
tagExpansion: tagExpansionDepth, theme: isDarkMode |
|
||||||
}; |
|
||||||
|
|
||||||
// Detect changes |
|
||||||
changedParams = detectChanges(lastUpdateParams, currentParams); |
|
||||||
|
|
||||||
if (changedParams.size === 0) { |
|
||||||
debug("No parameter changes detected"); |
|
||||||
return; |
|
||||||
} |
|
||||||
|
|
||||||
// Determine update type |
|
||||||
const updateType = detectUpdateType(changedParams); |
|
||||||
logUpdateType(updateType, changedParams); // Production logging |
|
||||||
|
|
||||||
// Update last parameters immediately |
|
||||||
lastUpdateParams = { ...currentParams }; |
|
||||||
|
|
||||||
// Route to appropriate update |
|
||||||
if (updateType.kind === 'full') { |
|
||||||
performUpdate(updateType); // Immediate |
|
||||||
} else { |
|
||||||
debouncedPerformUpdate(updateType); // Debounced |
|
||||||
} |
|
||||||
}); |
|
||||||
``` |
|
||||||
|
|
||||||
### 6. Debouncing |
|
||||||
|
|
||||||
Intelligent debouncing prevents update storms: |
|
||||||
|
|
||||||
```typescript |
|
||||||
const debouncedPerformUpdate = debounce(performUpdate, 150); |
|
||||||
|
|
||||||
function performUpdate(updateType: UpdateType) { |
|
||||||
try { |
|
||||||
switch (updateType.kind) { |
|
||||||
case 'full': |
|
||||||
updateGraph(); |
|
||||||
break; |
|
||||||
|
|
||||||
case 'structural': |
|
||||||
updateGraph(); // TODO: updateGraphStructure() |
|
||||||
break; |
|
||||||
|
|
||||||
case 'visual': |
|
||||||
if (updateType.params.has('star') || |
|
||||||
updateType.params.has('disabledCount') || |
|
||||||
updateType.params.has('theme')) { |
|
||||||
updateVisualProperties(); |
|
||||||
} else { |
|
||||||
updateGraph(); // Fallback |
|
||||||
} |
|
||||||
break; |
|
||||||
} |
|
||||||
} catch (error) { |
|
||||||
console.error("Error in performUpdate:", error); |
|
||||||
errorMessage = `Error updating graph: ${error instanceof Error ? error.message : String(error)}`; |
|
||||||
} |
|
||||||
} |
|
||||||
``` |
|
||||||
|
|
||||||
### 7. Theme Change Integration |
|
||||||
|
|
||||||
Theme changes now use the optimized path: |
|
||||||
|
|
||||||
```typescript |
|
||||||
const themeObserver = new MutationObserver((mutations) => { |
|
||||||
mutations.forEach((mutation) => { |
|
||||||
if (mutation.attributeName === "class") { |
|
||||||
const newIsDarkMode = document.body.classList.contains("dark"); |
|
||||||
if (newIsDarkMode !== isDarkMode) { |
|
||||||
isDarkMode = newIsDarkMode; |
|
||||||
// The effect will detect this change and call updateVisualProperties() |
|
||||||
} |
|
||||||
} |
|
||||||
}); |
|
||||||
}); |
|
||||||
``` |
|
||||||
|
|
||||||
### 8. Component-Level State |
|
||||||
|
|
||||||
Nodes and links are now persisted at component level: |
|
||||||
|
|
||||||
```typescript |
|
||||||
// Graph data - persisted between updates |
|
||||||
let nodes = $state<NetworkNode[]>([]); |
|
||||||
let links = $state<NetworkLink[]>([]); |
|
||||||
``` |
|
||||||
|
|
||||||
## Performance Monitoring |
|
||||||
|
|
||||||
Both update functions include timing: |
|
||||||
|
|
||||||
```typescript |
|
||||||
const startTime = performance.now(); |
|
||||||
// ... update logic ... |
|
||||||
const updateTime = performance.now() - startTime; |
|
||||||
debug(`Update completed in ${updateTime.toFixed(2)}ms`); |
|
||||||
``` |
|
||||||
|
|
||||||
## Testing the Implementation |
|
||||||
|
|
||||||
### Manual Testing |
|
||||||
|
|
||||||
1. **Enable debug mode**: `const DEBUG = true;` |
|
||||||
2. **Open browser console** |
|
||||||
3. **Test scenarios**: |
|
||||||
- Toggle star mode rapidly |
|
||||||
- Click multiple tags in legend |
|
||||||
- Switch theme |
|
||||||
- Watch console for timing logs |
|
||||||
|
|
||||||
### Expected Console Output |
|
||||||
|
|
||||||
``` |
|
||||||
[EventNetwork] Update type detected: visual Changed params: star |
|
||||||
[EventNetwork] Performing visual update for params: ["star"] |
|
||||||
[EventNetwork] Visual properties updated in 15.23ms |
|
||||||
``` |
|
||||||
|
|
||||||
### Performance Validation |
|
||||||
|
|
||||||
- Visual updates should complete in <50ms |
|
||||||
- No position jumps should occur |
|
||||||
- Simulation should maintain momentum |
|
||||||
- Rapid toggles should be batched |
|
||||||
|
|
||||||
## Utility Module Structure |
|
||||||
|
|
||||||
The change detection logic has been extracted to `/src/lib/utils/updateDetection.ts`: |
|
||||||
|
|
||||||
```typescript |
|
||||||
export interface UpdateParams { |
|
||||||
events: any; |
|
||||||
eventCount: number; |
|
||||||
levels: any; |
|
||||||
star: boolean; |
|
||||||
tags: boolean; |
|
||||||
tagType: string; |
|
||||||
disabledCount: number; |
|
||||||
tagExpansion: number; |
|
||||||
theme: boolean; |
|
||||||
} |
|
||||||
|
|
||||||
export function detectChanges( |
|
||||||
lastParams: UpdateParams, |
|
||||||
currentParams: UpdateParams |
|
||||||
): Set<string> { |
|
||||||
const changes = new Set<string>(); |
|
||||||
for (const [key, value] of Object.entries(currentParams)) { |
|
||||||
if (value !== lastParams[key as keyof UpdateParams]) { |
|
||||||
changes.add(key); |
|
||||||
} |
|
||||||
} |
|
||||||
return changes; |
|
||||||
} |
|
||||||
|
|
||||||
export function detectUpdateType(changes: Set<string>): UpdateType { |
|
||||||
if (changes.has('events') || changes.has('eventCount') || changes.has('levels')) { |
|
||||||
return { kind: 'full', reason: 'Data or depth changed' }; |
|
||||||
} |
|
||||||
|
|
||||||
if (changes.has('tags') || changes.has('tagType') || changes.has('tagExpansion')) { |
|
||||||
return { |
|
||||||
kind: 'structural', |
|
||||||
reason: 'Graph structure changed', |
|
||||||
params: changes |
|
||||||
}; |
|
||||||
} |
|
||||||
|
|
||||||
return { kind: 'visual', params: changes }; |
|
||||||
} |
|
||||||
|
|
||||||
export function logUpdateType(updateType: UpdateType, changedParams: Set<string>) { |
|
||||||
if (process.env.NODE_ENV === 'production') { |
|
||||||
console.log('[Visualization Update]', { |
|
||||||
type: updateType.kind, |
|
||||||
params: Array.from(changedParams), |
|
||||||
timestamp: new Date().toISOString() |
|
||||||
}); |
|
||||||
} |
|
||||||
} |
|
||||||
``` |
|
||||||
|
|
||||||
## Migration Notes |
|
||||||
|
|
||||||
For developers updating existing code: |
|
||||||
|
|
||||||
1. **Import the utility module** for update detection |
|
||||||
2. **Ensure nodes/links are at component level** |
|
||||||
3. **Add theme to tracked parameters** |
|
||||||
4. **Use the performUpdate function** for all updates |
|
||||||
5. **Keep DEBUG = false in production** |
|
||||||
|
|
||||||
## Troubleshooting |
|
||||||
|
|
||||||
### Visual updates not working? |
|
||||||
- Check that nodes/links are accessible |
|
||||||
- Verify the parameter is in visual category |
|
||||||
- Ensure simulation exists |
|
||||||
|
|
||||||
### Updates seem delayed? |
|
||||||
- Check debounce timing (150ms default) |
|
||||||
- Data updates bypass debouncing |
|
||||||
|
|
||||||
### Performance not improved? |
|
||||||
- Verify DEBUG mode shows "visual update" |
|
||||||
- Check browser console for errors |
|
||||||
- Ensure not falling back to updateGraph() |
|
||||||
|
|
||||||
--- |
|
||||||
|
|
||||||
*Implementation guide by Claude Agent 3* |
|
||||||
*Last updated: January 6, 2025* |
|
||||||
@ -1,124 +0,0 @@ |
|||||||
# Visualization Optimization Quick Reference |
|
||||||
|
|
||||||
## At a Glance |
|
||||||
|
|
||||||
The EventNetwork visualization now uses **shallow updates** for visual-only changes, improving performance by **90%+**. |
|
||||||
|
|
||||||
## What Changed? |
|
||||||
|
|
||||||
### Before |
|
||||||
Every parameter change → Full graph recreation → 150-200ms |
|
||||||
|
|
||||||
### After |
|
||||||
- **Visual changes** → Update existing elements → 10-30ms |
|
||||||
- **Data changes** → Full recreation (as before) → 150-200ms |
|
||||||
|
|
||||||
## Parameter Categories |
|
||||||
|
|
||||||
### Visual Updates (Fast) ⚡ |
|
||||||
- `starVisualization` - Star/standard layout |
|
||||||
- `disabledTags` - Tag visibility in legend |
|
||||||
- `isDarkMode` - Theme changes |
|
||||||
|
|
||||||
### Structural Updates (Medium) 🔧 |
|
||||||
- `showTagAnchors` - Add/remove tag nodes |
|
||||||
- `selectedTagType` - Change tag filter |
|
||||||
- `tagExpansionDepth` - Expand relationships |
|
||||||
|
|
||||||
### Full Updates (Slow) 🐌 |
|
||||||
- `events` - New data from relays |
|
||||||
- `levelsToRender` - Depth changes |
|
||||||
- `networkFetchLimit` - Fetch more events |
|
||||||
|
|
||||||
## Key Functions |
|
||||||
|
|
||||||
```typescript |
|
||||||
// Detects what type of update is needed |
|
||||||
detectUpdateType(changedParams) → UpdateType |
|
||||||
|
|
||||||
// Routes updates based on type |
|
||||||
performUpdate(updateType) → void |
|
||||||
|
|
||||||
// Optimized visual updates |
|
||||||
updateVisualProperties() → void |
|
||||||
|
|
||||||
// Full recreation (fallback) |
|
||||||
updateGraph() → void |
|
||||||
``` |
|
||||||
|
|
||||||
## Performance Targets |
|
||||||
|
|
||||||
| Update Type | Target | Actual | Status | |
|
||||||
|------------|--------|--------|--------| |
|
||||||
| Visual | <50ms | 10-30ms | ✅ | |
|
||||||
| Debounce | 150ms | 150ms | ✅ | |
|
||||||
| Position Preservation | Yes | Yes | ✅ | |
|
||||||
|
|
||||||
## Debug Mode |
|
||||||
|
|
||||||
```typescript |
|
||||||
const DEBUG = true; // Line 52 - Shows timing in console |
|
||||||
``` |
|
||||||
|
|
||||||
## Common Patterns |
|
||||||
|
|
||||||
### Adding a New Visual Parameter |
|
||||||
|
|
||||||
1. Add to `UpdateParams` interface |
|
||||||
2. Track in `lastUpdateParams` |
|
||||||
3. Handle in `updateVisualProperties()` |
|
||||||
4. Add to visual check in `performUpdate()` |
|
||||||
|
|
||||||
### Testing Performance |
|
||||||
|
|
||||||
```javascript |
|
||||||
// Browser console |
|
||||||
window.performance.mark('start'); |
|
||||||
// Toggle parameter |
|
||||||
window.performance.mark('end'); |
|
||||||
window.performance.measure('update', 'start', 'end'); |
|
||||||
``` |
|
||||||
|
|
||||||
## Troubleshooting |
|
||||||
|
|
||||||
**Updates seem slow?** |
|
||||||
- Check console for update type (should be "visual") |
|
||||||
- Verify parameter is in correct category |
|
||||||
|
|
||||||
**Position jumps?** |
|
||||||
- Ensure using `updateVisualProperties()` not `updateGraph()` |
|
||||||
- Check nodes/links are persisted |
|
||||||
|
|
||||||
**Debouncing not working?** |
|
||||||
- Visual updates have 150ms delay |
|
||||||
- Data updates are immediate (no delay) |
|
||||||
|
|
||||||
## Architecture Diagram |
|
||||||
|
|
||||||
``` |
|
||||||
User Action |
|
||||||
↓ |
|
||||||
Parameter Change Detection |
|
||||||
↓ |
|
||||||
Categorize Update Type |
|
||||||
↓ |
|
||||||
┌─────────────┬──────────────┬─────────────┐ |
|
||||||
│ Full │ Structural │ Visual │ |
|
||||||
│ (Immediate)│ (Debounced) │ (Debounced) │ |
|
||||||
└──────┬──────┴───────┬──────┴──────┬──────┘ |
|
||||||
↓ ↓ ↓ |
|
||||||
updateGraph() updateGraph() updateVisualProperties() |
|
||||||
(recreate all) (TODO: partial) (modify existing) |
|
||||||
``` |
|
||||||
|
|
||||||
## Next Steps |
|
||||||
|
|
||||||
- [ ] Implement `updateGraphStructure()` for partial updates |
|
||||||
- [ ] Add hover state support |
|
||||||
- [ ] Performance monitoring dashboard |
|
||||||
- [ ] Make debounce configurable |
|
||||||
|
|
||||||
--- |
|
||||||
|
|
||||||
*Quick reference by Claude Agent 3* |
|
||||||
*For full details see: 08-visualization-optimization-implementation.md* |
|
||||||
@ -1,168 +0,0 @@ |
|||||||
# Visualization Performance Optimization Summary |
|
||||||
|
|
||||||
**Date**: January 6, 2025 |
|
||||||
**Project**: gc-alexandria Event Network Visualization |
|
||||||
**Coordination**: Claude Agent 3 (Master Coordinator) |
|
||||||
|
|
||||||
## Executive Summary |
|
||||||
|
|
||||||
Successfully implemented a shallow copy update mechanism that reduces visualization update times by 90%+ for visual-only parameter changes. The optimization avoids full graph recreation when only visual properties change, resulting in smoother user experience and better performance. |
|
||||||
|
|
||||||
## Problem Statement |
|
||||||
|
|
||||||
The visualization component (`/src/lib/navigator/EventNetwork/index.svelte`) was recreating the entire D3.js force simulation graph on every parameter change, including visual-only changes like: |
|
||||||
- Star visualization mode toggle |
|
||||||
- Tag visibility toggles |
|
||||||
- Theme changes |
|
||||||
|
|
||||||
This caused: |
|
||||||
- 150-200ms delays for simple visual updates |
|
||||||
- Position jumps as nodes were recreated |
|
||||||
- Loss of simulation momentum |
|
||||||
- Poor user experience with rapid interactions |
|
||||||
|
|
||||||
## Solution Architecture |
|
||||||
|
|
||||||
### Three-Tier Update System |
|
||||||
|
|
||||||
Implemented a discriminated union type system to categorize updates: |
|
||||||
|
|
||||||
```typescript |
|
||||||
type UpdateType = |
|
||||||
| { kind: 'full'; reason: string } |
|
||||||
| { kind: 'structural'; reason: string; params: Set<string> } |
|
||||||
| { kind: 'visual'; params: Set<string> }; |
|
||||||
``` |
|
||||||
|
|
||||||
### Update Categories |
|
||||||
|
|
||||||
1. **Full Updates** (Data changes): |
|
||||||
- New events from relays |
|
||||||
- Depth level changes |
|
||||||
- Requires complete graph recreation |
|
||||||
|
|
||||||
2. **Structural Updates** (Graph structure changes): |
|
||||||
- Tag anchor additions/removals |
|
||||||
- Tag type changes |
|
||||||
- Requires partial graph update (future work) |
|
||||||
|
|
||||||
3. **Visual Updates** (Appearance only): |
|
||||||
- Star mode toggle |
|
||||||
- Tag visibility |
|
||||||
- Theme changes |
|
||||||
- Uses optimized `updateVisualProperties()` function |
|
||||||
|
|
||||||
### Key Implementation Details |
|
||||||
|
|
||||||
1. **Parameter Change Detection**: |
|
||||||
- Tracks current vs previous parameter values |
|
||||||
- Detects exactly what changed |
|
||||||
- Routes to appropriate update handler |
|
||||||
|
|
||||||
2. **Visual Update Optimization**: |
|
||||||
- Modifies existing DOM elements in-place |
|
||||||
- Updates simulation forces without recreation |
|
||||||
- Preserves node positions and momentum |
|
||||||
- Uses gentle simulation restart (alpha 0.3) |
|
||||||
|
|
||||||
3. **Intelligent Debouncing**: |
|
||||||
- 150ms delay for visual/structural updates |
|
||||||
- Immediate updates for data changes |
|
||||||
- Prevents update storms during rapid interactions |
|
||||||
|
|
||||||
## Performance Results |
|
||||||
|
|
||||||
### Metrics |
|
||||||
|
|
||||||
| Update Type | Before | After | Improvement | |
|
||||||
|------------|--------|-------|-------------| |
|
||||||
| Star Mode Toggle | 150-200ms | 10-30ms | 90% faster | |
|
||||||
| Tag Visibility | 150-200ms | 5-15ms | 93% faster | |
|
||||||
| Theme Change | 150-200ms | 10-20ms | 92% faster | |
|
||||||
|
|
||||||
### Benefits |
|
||||||
|
|
||||||
- ✅ No position jumps |
|
||||||
- ✅ Smooth transitions |
|
||||||
- ✅ Maintains simulation state |
|
||||||
- ✅ Handles rapid parameter changes |
|
||||||
- ✅ Reduced memory allocation |
|
||||||
|
|
||||||
## Code Architecture |
|
||||||
|
|
||||||
### Layer Separation Model |
|
||||||
|
|
||||||
``` |
|
||||||
┌─────────────────────────────┐ |
|
||||||
│ Data Layer │ ← Nostr events |
|
||||||
├─────────────────────────────┤ |
|
||||||
│ Graph Model Layer │ ← Nodes and links |
|
||||||
├─────────────────────────────┤ |
|
||||||
│ Simulation Layer │ ← Force physics |
|
||||||
├─────────────────────────────┤ |
|
||||||
│ Rendering Layer │ ← SVG/DOM |
|
||||||
└─────────────────────────────┘ |
|
||||||
``` |
|
||||||
|
|
||||||
This architecture enables updates at any layer without affecting layers above. |
|
||||||
|
|
||||||
## Implementation Timeline |
|
||||||
|
|
||||||
1. **Analysis Phase** (Agent 1): |
|
||||||
- Identified full recreation issue |
|
||||||
- Documented update triggers |
|
||||||
- Created optimization proposal |
|
||||||
|
|
||||||
2. **Implementation Phase** (Agent 1): |
|
||||||
- Added update type detection |
|
||||||
- Created `updateVisualProperties()` |
|
||||||
- Integrated parameter tracking |
|
||||||
- Added debouncing |
|
||||||
|
|
||||||
3. **Testing Phase** (Agent 2): |
|
||||||
- Created 50+ test cases |
|
||||||
- Validated performance improvements |
|
||||||
- Tested edge cases |
|
||||||
|
|
||||||
## Key Files Modified |
|
||||||
|
|
||||||
- `/src/lib/navigator/EventNetwork/index.svelte` - Main visualization component |
|
||||||
- Added ~200 lines of optimization code |
|
||||||
- Preserved backward compatibility |
|
||||||
|
|
||||||
## Testing Coverage |
|
||||||
|
|
||||||
Agent 2 created comprehensive test coverage: |
|
||||||
- **E2E Tests**: Collapsible UI, tag interactions |
|
||||||
- **Unit Tests**: Update detection, deduplication |
|
||||||
- **Integration Tests**: Display limits, reactivity paths |
|
||||||
- **Performance Tests**: Timing validation, memory usage |
|
||||||
|
|
||||||
## Future Enhancements |
|
||||||
|
|
||||||
1. **Structural Updates** - Implement `updateGraphStructure()` for partial graph updates |
|
||||||
2. **Change Detection Extraction** - Move to utility module |
|
||||||
3. **Performance Dashboard** - Real-time monitoring |
|
||||||
4. **Additional Visual Properties** - Hover states, animations |
|
||||||
|
|
||||||
## Lessons Learned |
|
||||||
|
|
||||||
1. **Profiling First** - Understanding the problem through analysis was crucial |
|
||||||
2. **Incremental Approach** - Starting with visual updates proved the concept |
|
||||||
3. **Layer Separation** - Clean architecture enables targeted optimizations |
|
||||||
4. **Debouncing Matters** - Critical for handling rapid user interactions |
|
||||||
|
|
||||||
## Team Contributions |
|
||||||
|
|
||||||
- **Agent 1 (Visualization)**: Analysis, implementation, documentation |
|
||||||
- **Agent 2 (Testing)**: Test infrastructure, validation, performance baselines |
|
||||||
- **Agent 3 (Coordination)**: Architecture guidance, code reviews, documentation |
|
||||||
|
|
||||||
## Conclusion |
|
||||||
|
|
||||||
The shallow copy optimization successfully addresses the performance issues while maintaining code quality and user experience. The 90%+ improvement in update times creates a noticeably smoother interaction, especially for users rapidly toggling visualization parameters. |
|
||||||
|
|
||||||
--- |
|
||||||
|
|
||||||
*Documentation created by Claude Agent 3 (Master Coordinator)* |
|
||||||
*Last updated: January 6, 2025* |
|
||||||
@ -0,0 +1,41 @@ |
|||||||
|
/** |
||||||
|
* Common utilities shared across network builders |
||||||
|
*/ |
||||||
|
|
||||||
|
/** |
||||||
|
* Seeded random number generator for deterministic layouts |
||||||
|
*/ |
||||||
|
export class SeededRandom { |
||||||
|
private seed: number; |
||||||
|
|
||||||
|
constructor(seed: number) { |
||||||
|
this.seed = seed; |
||||||
|
} |
||||||
|
|
||||||
|
next(): number { |
||||||
|
const x = Math.sin(this.seed++) * 10000; |
||||||
|
return x - Math.floor(x); |
||||||
|
} |
||||||
|
|
||||||
|
nextFloat(min: number, max: number): number { |
||||||
|
return min + this.next() * (max - min); |
||||||
|
} |
||||||
|
|
||||||
|
nextInt(min: number, max: number): number { |
||||||
|
return Math.floor(this.nextFloat(min, max + 1)); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Creates a debug function with a prefix |
||||||
|
* @param prefix - The prefix to add to all debug messages |
||||||
|
* @returns A debug function that can be toggled on/off |
||||||
|
*/ |
||||||
|
export function createDebugFunction(prefix: string) { |
||||||
|
const DEBUG = false; |
||||||
|
return function debug(...args: any[]) { |
||||||
|
if (DEBUG) { |
||||||
|
console.log(`[${prefix}]`, ...args); |
||||||
|
} |
||||||
|
}; |
||||||
|
} |
||||||
@ -1,19 +0,0 @@ |
|||||||
import { writable } from 'svelte/store'; |
|
||||||
|
|
||||||
export interface DisplayLimits { |
|
||||||
max30040: number; // -1 for unlimited
|
|
||||||
max30041: number; // -1 for unlimited
|
|
||||||
fetchIfNotFound: boolean; |
|
||||||
} |
|
||||||
|
|
||||||
// Create the store with default values
|
|
||||||
export const displayLimits = writable<DisplayLimits>({ |
|
||||||
max30040: -1, // Show all publication indices by default
|
|
||||||
max30041: -1, // Show all content by default
|
|
||||||
fetchIfNotFound: false // Don't fetch missing events by default
|
|
||||||
}); |
|
||||||
|
|
||||||
// Helper to check if limits are active
|
|
||||||
export function hasActiveLimits(limits: DisplayLimits): boolean { |
|
||||||
return limits.max30040 !== -1 || limits.max30041 !== -1; |
|
||||||
} |
|
||||||
@ -1,279 +0,0 @@ |
|||||||
import { test, expect } from '@playwright/test'; |
|
||||||
|
|
||||||
test.describe('Collapsible Sections UI', () => { |
|
||||||
test.beforeEach(async ({ page }) => { |
|
||||||
// Navigate to the visualization page
|
|
||||||
await page.goto('/visualize'); |
|
||||||
// Wait for the visualization to load
|
|
||||||
await page.waitForSelector('.leather-legend', { timeout: 10000 }); |
|
||||||
}); |
|
||||||
|
|
||||||
test.describe('Legend Component', () => { |
|
||||||
test('should toggle main legend collapse/expand', async ({ page }) => { |
|
||||||
const legend = page.locator('.leather-legend').first(); |
|
||||||
const legendContent = legend.locator('.legend-content'); |
|
||||||
const toggleButton = legend.locator('button').first(); |
|
||||||
|
|
||||||
// Legend should be expanded by default
|
|
||||||
await expect(legendContent).toBeVisible(); |
|
||||||
|
|
||||||
// Click to collapse
|
|
||||||
await toggleButton.click(); |
|
||||||
await expect(legendContent).not.toBeVisible(); |
|
||||||
|
|
||||||
// Click to expand
|
|
||||||
await toggleButton.click(); |
|
||||||
await expect(legendContent).toBeVisible(); |
|
||||||
}); |
|
||||||
|
|
||||||
test('should toggle Node Types section independently', async ({ page }) => { |
|
||||||
const legend = page.locator('.leather-legend').first(); |
|
||||||
const nodeTypesSection = legend.locator('.legend-section').first(); |
|
||||||
const nodeTypesHeader = nodeTypesSection.locator('.legend-section-header'); |
|
||||||
const nodeTypesList = nodeTypesSection.locator('.legend-list'); |
|
||||||
|
|
||||||
// Node Types should be expanded by default
|
|
||||||
await expect(nodeTypesList).toBeVisible(); |
|
||||||
|
|
||||||
// Click header to collapse
|
|
||||||
await nodeTypesHeader.click(); |
|
||||||
await expect(nodeTypesList).not.toBeVisible(); |
|
||||||
|
|
||||||
// Click header to expand
|
|
||||||
await nodeTypesHeader.click(); |
|
||||||
await expect(nodeTypesList).toBeVisible(); |
|
||||||
}); |
|
||||||
|
|
||||||
test('should toggle Tag Anchors section independently when visible', async ({ page }) => { |
|
||||||
// First enable tag anchors in settings
|
|
||||||
const settings = page.locator('.leather-legend').nth(1); |
|
||||||
const settingsToggle = settings.locator('button').first(); |
|
||||||
|
|
||||||
// Expand settings if needed
|
|
||||||
const settingsContent = settings.locator('.space-y-4'); |
|
||||||
if (!(await settingsContent.isVisible())) { |
|
||||||
await settingsToggle.click(); |
|
||||||
} |
|
||||||
|
|
||||||
// Enable tag anchors
|
|
||||||
const visualSettingsHeader = settings.locator('.settings-section-header').filter({ hasText: 'Visual Settings' }); |
|
||||||
await visualSettingsHeader.click(); |
|
||||||
|
|
||||||
const tagAnchorsToggle = settings.locator('label').filter({ hasText: 'Show Tag Anchors' }).locator('input[type="checkbox"]'); |
|
||||||
if (!(await tagAnchorsToggle.isChecked())) { |
|
||||||
await tagAnchorsToggle.click(); |
|
||||||
} |
|
||||||
|
|
||||||
// Wait for tag anchors to appear in legend
|
|
||||||
await page.waitForTimeout(1000); // Allow time for graph update
|
|
||||||
|
|
||||||
const legend = page.locator('.leather-legend').first(); |
|
||||||
const tagSection = legend.locator('.legend-section').filter({ hasText: 'Active Tag Anchors' }); |
|
||||||
|
|
||||||
if (await tagSection.count() > 0) { |
|
||||||
const tagHeader = tagSection.locator('.legend-section-header'); |
|
||||||
const tagGrid = tagSection.locator('.tag-grid'); |
|
||||||
|
|
||||||
// Should be expanded by default
|
|
||||||
await expect(tagGrid).toBeVisible(); |
|
||||||
|
|
||||||
// Click to collapse
|
|
||||||
await tagHeader.click(); |
|
||||||
await expect(tagGrid).not.toBeVisible(); |
|
||||||
|
|
||||||
// Click to expand
|
|
||||||
await tagHeader.click(); |
|
||||||
await expect(tagGrid).toBeVisible(); |
|
||||||
} |
|
||||||
}); |
|
||||||
|
|
||||||
test('should maintain section states independently', async ({ page }) => { |
|
||||||
const legend = page.locator('.leather-legend').first(); |
|
||||||
const nodeTypesSection = legend.locator('.legend-section').first(); |
|
||||||
const nodeTypesHeader = nodeTypesSection.locator('.legend-section-header'); |
|
||||||
const nodeTypesList = nodeTypesSection.locator('.legend-list'); |
|
||||||
|
|
||||||
// Collapse Node Types section
|
|
||||||
await nodeTypesHeader.click(); |
|
||||||
await expect(nodeTypesList).not.toBeVisible(); |
|
||||||
|
|
||||||
// Toggle main legend
|
|
||||||
const toggleButton = legend.locator('button').first(); |
|
||||||
await toggleButton.click(); // Collapse
|
|
||||||
await toggleButton.click(); // Expand
|
|
||||||
|
|
||||||
// Node Types should still be collapsed
|
|
||||||
await expect(nodeTypesList).not.toBeVisible(); |
|
||||||
}); |
|
||||||
}); |
|
||||||
|
|
||||||
test.describe('Settings Component', () => { |
|
||||||
test('should toggle main settings collapse/expand', async ({ page }) => { |
|
||||||
const settings = page.locator('.leather-legend').nth(1); |
|
||||||
const settingsContent = settings.locator('.space-y-4'); |
|
||||||
const toggleButton = settings.locator('button').first(); |
|
||||||
|
|
||||||
// Settings should be collapsed by default
|
|
||||||
await expect(settingsContent).not.toBeVisible(); |
|
||||||
|
|
||||||
// Click to expand
|
|
||||||
await toggleButton.click(); |
|
||||||
await expect(settingsContent).toBeVisible(); |
|
||||||
|
|
||||||
// Click to collapse
|
|
||||||
await toggleButton.click(); |
|
||||||
await expect(settingsContent).not.toBeVisible(); |
|
||||||
}); |
|
||||||
|
|
||||||
test('should toggle all settings sections independently', async ({ page }) => { |
|
||||||
const settings = page.locator('.leather-legend').nth(1); |
|
||||||
const toggleButton = settings.locator('button').first(); |
|
||||||
|
|
||||||
// Expand settings
|
|
||||||
await toggleButton.click(); |
|
||||||
|
|
||||||
const sections = [ |
|
||||||
{ name: 'Event Types', contentSelector: 'text="Event Kind Filter"' }, |
|
||||||
{ name: 'Initial Load', contentSelector: 'text="Network Fetch Limit"' }, |
|
||||||
{ name: 'Display Limits', contentSelector: 'text="Max Publication Indices"' }, |
|
||||||
{ name: 'Graph Traversal', contentSelector: 'text="Search through already fetched"' }, |
|
||||||
{ name: 'Visual Settings', contentSelector: 'text="Star Network View"' } |
|
||||||
]; |
|
||||||
|
|
||||||
for (const section of sections) { |
|
||||||
const sectionHeader = settings.locator('.settings-section-header').filter({ hasText: section.name }); |
|
||||||
const sectionContent = settings.locator('.settings-section').filter({ has: sectionHeader }); |
|
||||||
|
|
||||||
// All sections should be expanded by default
|
|
||||||
await expect(sectionContent.locator(section.contentSelector)).toBeVisible(); |
|
||||||
|
|
||||||
// Click to collapse
|
|
||||||
await sectionHeader.click(); |
|
||||||
await expect(sectionContent.locator(section.contentSelector)).not.toBeVisible(); |
|
||||||
|
|
||||||
// Click to expand
|
|
||||||
await sectionHeader.click(); |
|
||||||
await expect(sectionContent.locator(section.contentSelector)).toBeVisible(); |
|
||||||
} |
|
||||||
}); |
|
||||||
|
|
||||||
test('should preserve section states when toggling main settings', async ({ page }) => { |
|
||||||
const settings = page.locator('.leather-legend').nth(1); |
|
||||||
const toggleButton = settings.locator('button').first(); |
|
||||||
|
|
||||||
// Expand settings
|
|
||||||
await toggleButton.click(); |
|
||||||
|
|
||||||
// Collapse some sections
|
|
||||||
const eventTypesHeader = settings.locator('.settings-section-header').filter({ hasText: 'Event Types' }); |
|
||||||
const displayLimitsHeader = settings.locator('.settings-section-header').filter({ hasText: 'Display Limits' }); |
|
||||||
|
|
||||||
await eventTypesHeader.click(); |
|
||||||
await displayLimitsHeader.click(); |
|
||||||
|
|
||||||
// Verify they are collapsed
|
|
||||||
const eventTypesContent = settings.locator('.settings-section').filter({ has: eventTypesHeader }); |
|
||||||
const displayLimitsContent = settings.locator('.settings-section').filter({ has: displayLimitsHeader }); |
|
||||||
|
|
||||||
await expect(eventTypesContent.locator('text="Event Kind Filter"')).not.toBeVisible(); |
|
||||||
await expect(displayLimitsContent.locator('text="Max Publication Indices"')).not.toBeVisible(); |
|
||||||
|
|
||||||
// Toggle main settings
|
|
||||||
await toggleButton.click(); // Collapse
|
|
||||||
await toggleButton.click(); // Expand
|
|
||||||
|
|
||||||
// Sections should maintain their collapsed state
|
|
||||||
await expect(eventTypesContent.locator('text="Event Kind Filter"')).not.toBeVisible(); |
|
||||||
await expect(displayLimitsContent.locator('text="Max Publication Indices"')).not.toBeVisible(); |
|
||||||
|
|
||||||
// Other sections should still be expanded
|
|
||||||
const visualSettingsContent = settings.locator('.settings-section').filter({
|
|
||||||
has: settings.locator('.settings-section-header').filter({ hasText: 'Visual Settings' })
|
|
||||||
}); |
|
||||||
await expect(visualSettingsContent.locator('text="Star Network View"')).toBeVisible(); |
|
||||||
}); |
|
||||||
|
|
||||||
test('should show hover effect on section headers', async ({ page }) => { |
|
||||||
const settings = page.locator('.leather-legend').nth(1); |
|
||||||
const toggleButton = settings.locator('button').first(); |
|
||||||
|
|
||||||
// Expand settings
|
|
||||||
await toggleButton.click(); |
|
||||||
|
|
||||||
const eventTypesHeader = settings.locator('.settings-section-header').filter({ hasText: 'Event Types' }); |
|
||||||
|
|
||||||
// Hover over header
|
|
||||||
await eventTypesHeader.hover(); |
|
||||||
|
|
||||||
// Check for hover styles (background color change)
|
|
||||||
// Note: This is a basic check, actual hover styles depend on CSS
|
|
||||||
await expect(eventTypesHeader).toHaveCSS('cursor', 'pointer'); |
|
||||||
}); |
|
||||||
}); |
|
||||||
|
|
||||||
test.describe('Icon State Changes', () => { |
|
||||||
test('should show correct caret icons for expand/collapse states', async ({ page }) => { |
|
||||||
const legend = page.locator('.leather-legend').first(); |
|
||||||
const settings = page.locator('.leather-legend').nth(1); |
|
||||||
|
|
||||||
// Check main toggle buttons
|
|
||||||
const legendToggle = legend.locator('button').first(); |
|
||||||
const settingsToggle = settings.locator('button').first(); |
|
||||||
|
|
||||||
// Legend starts expanded (shows up caret)
|
|
||||||
await expect(legendToggle.locator('svg')).toHaveAttribute('class', /CaretUpOutline/); |
|
||||||
|
|
||||||
// Click to collapse (should show down caret)
|
|
||||||
await legendToggle.click(); |
|
||||||
await expect(legendToggle.locator('svg')).toHaveAttribute('class', /CaretDownOutline/); |
|
||||||
|
|
||||||
// Settings starts collapsed (shows down caret)
|
|
||||||
await expect(settingsToggle.locator('svg')).toHaveAttribute('class', /CaretDownOutline/); |
|
||||||
|
|
||||||
// Click to expand (should show up caret)
|
|
||||||
await settingsToggle.click(); |
|
||||||
await expect(settingsToggle.locator('svg')).toHaveAttribute('class', /CaretUpOutline/); |
|
||||||
|
|
||||||
// Check section toggles
|
|
||||||
const eventTypesHeader = settings.locator('.settings-section-header').filter({ hasText: 'Event Types' }); |
|
||||||
const eventTypesButton = eventTypesHeader.locator('button'); |
|
||||||
|
|
||||||
// Section starts expanded
|
|
||||||
await expect(eventTypesButton.locator('svg')).toHaveAttribute('class', /CaretUpOutline/); |
|
||||||
|
|
||||||
// Click to collapse
|
|
||||||
await eventTypesHeader.click(); |
|
||||||
await expect(eventTypesButton.locator('svg')).toHaveAttribute('class', /CaretDownOutline/); |
|
||||||
}); |
|
||||||
}); |
|
||||||
|
|
||||||
test.describe('Responsive Behavior', () => { |
|
||||||
test('should maintain functionality on mobile viewport', async ({ page }) => { |
|
||||||
// Set mobile viewport
|
|
||||||
await page.setViewportSize({ width: 375, height: 667 }); |
|
||||||
|
|
||||||
const legend = page.locator('.leather-legend').first(); |
|
||||||
const settings = page.locator('.leather-legend').nth(1); |
|
||||||
|
|
||||||
// Test basic toggle functionality still works
|
|
||||||
const legendToggle = legend.locator('button').first(); |
|
||||||
const settingsToggle = settings.locator('button').first(); |
|
||||||
|
|
||||||
const legendContent = legend.locator('.legend-content'); |
|
||||||
|
|
||||||
// Toggle legend
|
|
||||||
await expect(legendContent).toBeVisible(); |
|
||||||
await legendToggle.click(); |
|
||||||
await expect(legendContent).not.toBeVisible(); |
|
||||||
|
|
||||||
// Expand settings and test section toggle
|
|
||||||
await settingsToggle.click(); |
|
||||||
const eventTypesHeader = settings.locator('.settings-section-header').filter({ hasText: 'Event Types' }); |
|
||||||
const eventTypesContent = settings.locator('.settings-section').filter({ has: eventTypesHeader }); |
|
||||||
|
|
||||||
await expect(eventTypesContent.locator('text="Event Kind Filter"')).toBeVisible(); |
|
||||||
await eventTypesHeader.click(); |
|
||||||
await expect(eventTypesContent.locator('text="Event Kind Filter"')).not.toBeVisible(); |
|
||||||
}); |
|
||||||
}); |
|
||||||
}); |
|
||||||
@ -1,18 +0,0 @@ |
|||||||
import { test, expect } from '@playwright/test'; |
|
||||||
|
|
||||||
test('has title', async ({ page }) => { |
|
||||||
await page.goto('https://playwright.dev/'); |
|
||||||
|
|
||||||
// Expect a title "to contain" a substring.
|
|
||||||
await expect(page).toHaveTitle(/Playwright/); |
|
||||||
}); |
|
||||||
|
|
||||||
test('get started link', async ({ page }) => { |
|
||||||
await page.goto('https://playwright.dev/'); |
|
||||||
|
|
||||||
// Click the get started link.
|
|
||||||
await page.getByRole('link', { name: 'Get started' }).click(); |
|
||||||
|
|
||||||
// Expects page to have a heading with the name of Installation.
|
|
||||||
await expect(page.getByRole('heading', { name: 'Installation' })).toBeVisible(); |
|
||||||
}); |
|
||||||
@ -1,365 +0,0 @@ |
|||||||
import { test, expect } from '@playwright/test'; |
|
||||||
|
|
||||||
// Performance thresholds based on POC targets
|
|
||||||
const PERFORMANCE_TARGETS = { |
|
||||||
visualUpdate: 50, // <50ms for visual updates
|
|
||||||
fullUpdate: 200, // Baseline for full updates
|
|
||||||
positionDrift: 5, // Max pixels of position drift
|
|
||||||
memoryIncrease: 10 // Max % memory increase per update
|
|
||||||
}; |
|
||||||
|
|
||||||
test.describe('Shallow Copy POC Performance Validation', () => { |
|
||||||
// Helper to extract console logs
|
|
||||||
const consoleLogs: string[] = []; |
|
||||||
|
|
||||||
test.beforeEach(async ({ page }) => { |
|
||||||
// Clear logs
|
|
||||||
consoleLogs.length = 0; |
|
||||||
|
|
||||||
// Capture console logs
|
|
||||||
page.on('console', msg => { |
|
||||||
if (msg.type() === 'log' && msg.text().includes('[EventNetwork]')) { |
|
||||||
consoleLogs.push(msg.text()); |
|
||||||
} |
|
||||||
}); |
|
||||||
|
|
||||||
// Navigate to visualization page
|
|
||||||
await page.goto('http://localhost:5175/visualize'); |
|
||||||
|
|
||||||
// Wait for initial load
|
|
||||||
await page.waitForSelector('.network-svg', { timeout: 10000 }); |
|
||||||
await page.waitForTimeout(2000); // Allow graph to stabilize
|
|
||||||
}); |
|
||||||
|
|
||||||
test('star visualization toggle uses visual update path', async ({ page }) => { |
|
||||||
// Enable settings panel
|
|
||||||
const settings = page.locator('.leather-legend').nth(1); |
|
||||||
const settingsToggle = settings.locator('button').first(); |
|
||||||
await settingsToggle.click(); |
|
||||||
|
|
||||||
// Ensure visual settings section is expanded
|
|
||||||
const visualSettingsHeader = settings.locator('.settings-section-header').filter({ hasText: 'Visual Settings' }); |
|
||||||
await visualSettingsHeader.click(); |
|
||||||
|
|
||||||
// Clear previous logs
|
|
||||||
consoleLogs.length = 0; |
|
||||||
|
|
||||||
// Toggle star visualization
|
|
||||||
const starToggle = settings.locator('label').filter({ hasText: 'Star Network View' }).locator('input[type="checkbox"]'); |
|
||||||
await starToggle.click(); |
|
||||||
|
|
||||||
// Wait for update
|
|
||||||
await page.waitForTimeout(100); |
|
||||||
|
|
||||||
// Check logs for update type
|
|
||||||
const updateLogs = consoleLogs.filter(log => log.includes('Update type detected')); |
|
||||||
expect(updateLogs.length).toBeGreaterThan(0); |
|
||||||
|
|
||||||
const lastUpdateLog = updateLogs[updateLogs.length - 1]; |
|
||||||
expect(lastUpdateLog).toContain('kind: "visual"'); |
|
||||||
expect(lastUpdateLog).toContain('star'); |
|
||||||
|
|
||||||
// Check for visual properties update
|
|
||||||
const visualUpdateLogs = consoleLogs.filter(log => log.includes('updateVisualProperties called')); |
|
||||||
expect(visualUpdateLogs.length).toBeGreaterThan(0); |
|
||||||
|
|
||||||
// Extract timing
|
|
||||||
const timingLogs = consoleLogs.filter(log => log.includes('Visual properties updated in')); |
|
||||||
if (timingLogs.length > 0) { |
|
||||||
const match = timingLogs[0].match(/(\d+\.\d+)ms/); |
|
||||||
if (match) { |
|
||||||
const updateTime = parseFloat(match[1]); |
|
||||||
expect(updateTime).toBeLessThan(PERFORMANCE_TARGETS.visualUpdate); |
|
||||||
console.log(`Star toggle update time: ${updateTime}ms`); |
|
||||||
} |
|
||||||
} |
|
||||||
}); |
|
||||||
|
|
||||||
test('tag visibility toggle uses visual update path', async ({ page }) => { |
|
||||||
// Enable settings and tag anchors
|
|
||||||
const settings = page.locator('.leather-legend').nth(1); |
|
||||||
const settingsToggle = settings.locator('button').first(); |
|
||||||
await settingsToggle.click(); |
|
||||||
|
|
||||||
// Enable tag anchors
|
|
||||||
const visualSettingsHeader = settings.locator('.settings-section-header').filter({ hasText: 'Visual Settings' }); |
|
||||||
await visualSettingsHeader.click(); |
|
||||||
|
|
||||||
const tagAnchorsToggle = settings.locator('label').filter({ hasText: 'Show Tag Anchors' }).locator('input[type="checkbox"]'); |
|
||||||
await tagAnchorsToggle.click(); |
|
||||||
|
|
||||||
// Wait for tags to appear
|
|
||||||
await page.waitForTimeout(1000); |
|
||||||
|
|
||||||
const legend = page.locator('.leather-legend').first(); |
|
||||||
const tagSection = legend.locator('.legend-section').filter({ hasText: 'Active Tag Anchors' }); |
|
||||||
|
|
||||||
if (await tagSection.count() > 0) { |
|
||||||
// Expand tag section if needed
|
|
||||||
const tagHeader = tagSection.locator('.legend-section-header'); |
|
||||||
const tagGrid = tagSection.locator('.tag-grid'); |
|
||||||
if (!(await tagGrid.isVisible())) { |
|
||||||
await tagHeader.click(); |
|
||||||
} |
|
||||||
|
|
||||||
// Clear logs
|
|
||||||
consoleLogs.length = 0; |
|
||||||
|
|
||||||
// Toggle first tag
|
|
||||||
const firstTag = tagGrid.locator('.tag-grid-item').first(); |
|
||||||
await firstTag.click(); |
|
||||||
|
|
||||||
// Wait for update
|
|
||||||
await page.waitForTimeout(100); |
|
||||||
|
|
||||||
// Check for visual update
|
|
||||||
const updateLogs = consoleLogs.filter(log => log.includes('Update type detected')); |
|
||||||
expect(updateLogs.length).toBeGreaterThan(0); |
|
||||||
|
|
||||||
const lastUpdateLog = updateLogs[updateLogs.length - 1]; |
|
||||||
expect(lastUpdateLog).toContain('kind: "visual"'); |
|
||||||
expect(lastUpdateLog).toContain('disabledCount'); |
|
||||||
|
|
||||||
// Check timing
|
|
||||||
const timingLogs = consoleLogs.filter(log => log.includes('Visual properties updated in')); |
|
||||||
if (timingLogs.length > 0) { |
|
||||||
const match = timingLogs[0].match(/(\d+\.\d+)ms/); |
|
||||||
if (match) { |
|
||||||
const updateTime = parseFloat(match[1]); |
|
||||||
expect(updateTime).toBeLessThan(PERFORMANCE_TARGETS.visualUpdate); |
|
||||||
console.log(`Tag toggle update time: ${updateTime}ms`); |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
}); |
|
||||||
|
|
||||||
test('position preservation during visual updates', async ({ page }) => { |
|
||||||
// Get initial node positions
|
|
||||||
const getNodePositions = async () => { |
|
||||||
return await page.evaluate(() => { |
|
||||||
const nodes = document.querySelectorAll('.network-svg g.node'); |
|
||||||
const positions: { [id: string]: { x: number; y: number } } = {}; |
|
||||||
|
|
||||||
nodes.forEach((node) => { |
|
||||||
const transform = node.getAttribute('transform'); |
|
||||||
const match = transform?.match(/translate\(([\d.-]+),([\d.-]+)\)/); |
|
||||||
if (match) { |
|
||||||
const nodeId = (node as any).__data__?.id || 'unknown'; |
|
||||||
positions[nodeId] = { |
|
||||||
x: parseFloat(match[1]), |
|
||||||
y: parseFloat(match[2]) |
|
||||||
}; |
|
||||||
} |
|
||||||
}); |
|
||||||
|
|
||||||
return positions; |
|
||||||
}); |
|
||||||
}; |
|
||||||
|
|
||||||
// Capture initial positions
|
|
||||||
const initialPositions = await getNodePositions(); |
|
||||||
const nodeCount = Object.keys(initialPositions).length; |
|
||||||
expect(nodeCount).toBeGreaterThan(0); |
|
||||||
|
|
||||||
// Toggle star visualization
|
|
||||||
const settings = page.locator('.leather-legend').nth(1); |
|
||||||
const settingsToggle = settings.locator('button').first(); |
|
||||||
await settingsToggle.click(); |
|
||||||
|
|
||||||
const visualSettingsHeader = settings.locator('.settings-section-header').filter({ hasText: 'Visual Settings' }); |
|
||||||
await visualSettingsHeader.click(); |
|
||||||
|
|
||||||
const starToggle = settings.locator('label').filter({ hasText: 'Star Network View' }).locator('input[type="checkbox"]'); |
|
||||||
await starToggle.click(); |
|
||||||
|
|
||||||
// Wait for visual update
|
|
||||||
await page.waitForTimeout(500); |
|
||||||
|
|
||||||
// Get positions after update
|
|
||||||
const updatedPositions = await getNodePositions(); |
|
||||||
|
|
||||||
// Check position preservation
|
|
||||||
let maxDrift = 0; |
|
||||||
let driftCount = 0; |
|
||||||
|
|
||||||
Object.keys(initialPositions).forEach(nodeId => { |
|
||||||
if (updatedPositions[nodeId]) { |
|
||||||
const initial = initialPositions[nodeId]; |
|
||||||
const updated = updatedPositions[nodeId]; |
|
||||||
const drift = Math.sqrt( |
|
||||||
Math.pow(updated.x - initial.x, 2) +
|
|
||||||
Math.pow(updated.y - initial.y, 2) |
|
||||||
); |
|
||||||
|
|
||||||
if (drift > PERFORMANCE_TARGETS.positionDrift) { |
|
||||||
driftCount++; |
|
||||||
maxDrift = Math.max(maxDrift, drift); |
|
||||||
} |
|
||||||
} |
|
||||||
}); |
|
||||||
|
|
||||||
// Positions should be mostly preserved (some drift due to force changes is OK)
|
|
||||||
const driftPercentage = (driftCount / nodeCount) * 100; |
|
||||||
expect(driftPercentage).toBeLessThan(20); // Less than 20% of nodes should drift significantly
|
|
||||||
console.log(`Position drift: ${driftCount}/${nodeCount} nodes (${driftPercentage.toFixed(1)}%), max drift: ${maxDrift.toFixed(1)}px`); |
|
||||||
}); |
|
||||||
|
|
||||||
test('simulation maintains momentum', async ({ page }) => { |
|
||||||
// Check simulation alpha values in logs
|
|
||||||
const settings = page.locator('.leather-legend').nth(1); |
|
||||||
const settingsToggle = settings.locator('button').first(); |
|
||||||
await settingsToggle.click(); |
|
||||||
|
|
||||||
const visualSettingsHeader = settings.locator('.settings-section-header').filter({ hasText: 'Visual Settings' }); |
|
||||||
await visualSettingsHeader.click(); |
|
||||||
|
|
||||||
// Clear logs
|
|
||||||
consoleLogs.length = 0; |
|
||||||
|
|
||||||
// Toggle star mode
|
|
||||||
const starToggle = settings.locator('label').filter({ hasText: 'Star Network View' }).locator('input[type="checkbox"]'); |
|
||||||
await starToggle.click(); |
|
||||||
|
|
||||||
await page.waitForTimeout(100); |
|
||||||
|
|
||||||
// Check for gentle restart
|
|
||||||
const alphaLogs = consoleLogs.filter(log => log.includes('simulation restarted with alpha')); |
|
||||||
expect(alphaLogs.length).toBeGreaterThan(0); |
|
||||||
|
|
||||||
// Should use alpha 0.3 for visual updates
|
|
||||||
expect(alphaLogs[0]).toContain('alpha 0.3'); |
|
||||||
}); |
|
||||||
|
|
||||||
test('rapid parameter changes are handled efficiently', async ({ page }) => { |
|
||||||
const settings = page.locator('.leather-legend').nth(1); |
|
||||||
const settingsToggle = settings.locator('button').first(); |
|
||||||
await settingsToggle.click(); |
|
||||||
|
|
||||||
const visualSettingsHeader = settings.locator('.settings-section-header').filter({ hasText: 'Visual Settings' }); |
|
||||||
await visualSettingsHeader.click(); |
|
||||||
|
|
||||||
// Clear logs
|
|
||||||
consoleLogs.length = 0; |
|
||||||
|
|
||||||
// Perform rapid toggles
|
|
||||||
const starToggle = settings.locator('label').filter({ hasText: 'Star Network View' }).locator('input[type="checkbox"]'); |
|
||||||
|
|
||||||
const startTime = Date.now(); |
|
||||||
for (let i = 0; i < 5; i++) { |
|
||||||
await starToggle.click(); |
|
||||||
await page.waitForTimeout(50); // Very short delay
|
|
||||||
} |
|
||||||
const totalTime = Date.now() - startTime; |
|
||||||
|
|
||||||
// Check that all updates completed
|
|
||||||
await page.waitForTimeout(500); |
|
||||||
|
|
||||||
// Count visual updates
|
|
||||||
const visualUpdateCount = consoleLogs.filter(log => log.includes('updateVisualProperties called')).length; |
|
||||||
expect(visualUpdateCount).toBeGreaterThanOrEqual(3); // At least some updates should process
|
|
||||||
|
|
||||||
console.log(`Rapid toggle test: ${visualUpdateCount} visual updates in ${totalTime}ms`); |
|
||||||
}); |
|
||||||
|
|
||||||
test('memory stability during visual updates', async ({ page }) => { |
|
||||||
// Get initial memory usage
|
|
||||||
const getMemoryUsage = async () => { |
|
||||||
return await page.evaluate(() => { |
|
||||||
if ('memory' in performance) { |
|
||||||
return (performance as any).memory.usedJSHeapSize; |
|
||||||
} |
|
||||||
return 0; |
|
||||||
}); |
|
||||||
}; |
|
||||||
|
|
||||||
const initialMemory = await getMemoryUsage(); |
|
||||||
if (initialMemory === 0) { |
|
||||||
test.skip(); |
|
||||||
return; |
|
||||||
} |
|
||||||
|
|
||||||
const settings = page.locator('.leather-legend').nth(1); |
|
||||||
const settingsToggle = settings.locator('button').first(); |
|
||||||
await settingsToggle.click(); |
|
||||||
|
|
||||||
const visualSettingsHeader = settings.locator('.settings-section-header').filter({ hasText: 'Visual Settings' }); |
|
||||||
await visualSettingsHeader.click(); |
|
||||||
|
|
||||||
const starToggle = settings.locator('label').filter({ hasText: 'Star Network View' }).locator('input[type="checkbox"]'); |
|
||||||
|
|
||||||
// Perform multiple toggles
|
|
||||||
for (let i = 0; i < 10; i++) { |
|
||||||
await starToggle.click(); |
|
||||||
await page.waitForTimeout(100); |
|
||||||
} |
|
||||||
|
|
||||||
// Force garbage collection if available
|
|
||||||
await page.evaluate(() => { |
|
||||||
if ('gc' in window) { |
|
||||||
(window as any).gc(); |
|
||||||
} |
|
||||||
}); |
|
||||||
|
|
||||||
await page.waitForTimeout(1000); |
|
||||||
|
|
||||||
const finalMemory = await getMemoryUsage(); |
|
||||||
const memoryIncrease = ((finalMemory - initialMemory) / initialMemory) * 100; |
|
||||||
|
|
||||||
console.log(`Memory usage: Initial ${(initialMemory / 1024 / 1024).toFixed(2)}MB, Final ${(finalMemory / 1024 / 1024).toFixed(2)}MB, Increase: ${memoryIncrease.toFixed(2)}%`); |
|
||||||
|
|
||||||
// Memory increase should be minimal
|
|
||||||
expect(memoryIncrease).toBeLessThan(PERFORMANCE_TARGETS.memoryIncrease); |
|
||||||
}); |
|
||||||
|
|
||||||
test('comparison: visual update vs full update performance', async ({ page }) => { |
|
||||||
const settings = page.locator('.leather-legend').nth(1); |
|
||||||
const settingsToggle = settings.locator('button').first(); |
|
||||||
await settingsToggle.click(); |
|
||||||
|
|
||||||
// Test visual update (star toggle)
|
|
||||||
const visualSettingsHeader = settings.locator('.settings-section-header').filter({ hasText: 'Visual Settings' }); |
|
||||||
await visualSettingsHeader.click(); |
|
||||||
|
|
||||||
consoleLogs.length = 0; |
|
||||||
const starToggle = settings.locator('label').filter({ hasText: 'Star Network View' }).locator('input[type="checkbox"]'); |
|
||||||
await starToggle.click(); |
|
||||||
await page.waitForTimeout(200); |
|
||||||
|
|
||||||
let visualUpdateTime = 0; |
|
||||||
const visualTimingLogs = consoleLogs.filter(log => log.includes('Visual properties updated in')); |
|
||||||
if (visualTimingLogs.length > 0) { |
|
||||||
const match = visualTimingLogs[0].match(/(\d+\.\d+)ms/); |
|
||||||
if (match) { |
|
||||||
visualUpdateTime = parseFloat(match[1]); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
// Test full update (fetch limit change)
|
|
||||||
const initialLoadHeader = settings.locator('.settings-section-header').filter({ hasText: 'Initial Load' }); |
|
||||||
await initialLoadHeader.click(); |
|
||||||
|
|
||||||
consoleLogs.length = 0; |
|
||||||
const fetchLimitInput = settings.locator('input[type="number"]').first(); |
|
||||||
await fetchLimitInput.fill('20'); |
|
||||||
await page.keyboard.press('Enter'); |
|
||||||
await page.waitForTimeout(500); |
|
||||||
|
|
||||||
let fullUpdateTime = 0; |
|
||||||
const fullTimingLogs = consoleLogs.filter(log => log.includes('updateGraph completed in')); |
|
||||||
if (fullTimingLogs.length > 0) { |
|
||||||
const match = fullTimingLogs[0].match(/(\d+\.\d+)ms/); |
|
||||||
if (match) { |
|
||||||
fullUpdateTime = parseFloat(match[1]); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
console.log(`Performance comparison:
|
|
||||||
- Visual update: ${visualUpdateTime.toFixed(2)}ms |
|
||||||
- Full update: ${fullUpdateTime.toFixed(2)}ms |
|
||||||
- Improvement: ${((1 - visualUpdateTime / fullUpdateTime) * 100).toFixed(1)}%`);
|
|
||||||
|
|
||||||
// Visual updates should be significantly faster
|
|
||||||
expect(visualUpdateTime).toBeLessThan(fullUpdateTime * 0.5); // At least 50% faster
|
|
||||||
expect(visualUpdateTime).toBeLessThan(PERFORMANCE_TARGETS.visualUpdate); |
|
||||||
}); |
|
||||||
}); |
|
||||||
@ -1,308 +0,0 @@ |
|||||||
import { test, expect } from '@playwright/test'; |
|
||||||
|
|
||||||
test.describe('Tag Anchor Interactive Features', () => { |
|
||||||
test.beforeEach(async ({ page }) => { |
|
||||||
// Navigate to visualization page
|
|
||||||
await page.goto('/visualize'); |
|
||||||
|
|
||||||
// Wait for visualization to load
|
|
||||||
await page.waitForSelector('.leather-legend', { timeout: 10000 }); |
|
||||||
|
|
||||||
// Enable tag anchors in settings
|
|
||||||
const settings = page.locator('.leather-legend').nth(1); |
|
||||||
const settingsToggle = settings.locator('button').first(); |
|
||||||
|
|
||||||
// Expand settings if needed
|
|
||||||
const settingsContent = settings.locator('.space-y-4'); |
|
||||||
if (!(await settingsContent.isVisible())) { |
|
||||||
await settingsToggle.click(); |
|
||||||
} |
|
||||||
|
|
||||||
// Expand Visual Settings section
|
|
||||||
const visualSettingsHeader = settings.locator('.settings-section-header').filter({ hasText: 'Visual Settings' }); |
|
||||||
const visualSettingsContent = settings.locator('.settings-section').filter({ has: visualSettingsHeader }); |
|
||||||
|
|
||||||
// Check if section is collapsed and expand if needed
|
|
||||||
const starNetworkToggle = visualSettingsContent.locator('text="Star Network View"'); |
|
||||||
if (!(await starNetworkToggle.isVisible())) { |
|
||||||
await visualSettingsHeader.click(); |
|
||||||
} |
|
||||||
|
|
||||||
// Enable tag anchors
|
|
||||||
const tagAnchorsToggle = settings.locator('label').filter({ hasText: 'Show Tag Anchors' }).locator('input[type="checkbox"]'); |
|
||||||
if (!(await tagAnchorsToggle.isChecked())) { |
|
||||||
await tagAnchorsToggle.click(); |
|
||||||
} |
|
||||||
|
|
||||||
// Wait for graph to update
|
|
||||||
await page.waitForTimeout(1000); |
|
||||||
}); |
|
||||||
|
|
||||||
test('should display tag anchors in legend when enabled', async ({ page }) => { |
|
||||||
const legend = page.locator('.leather-legend').first(); |
|
||||||
|
|
||||||
// Check for tag anchors section
|
|
||||||
const tagSection = legend.locator('.legend-section').filter({ hasText: 'Active Tag Anchors' }); |
|
||||||
await expect(tagSection).toBeVisible(); |
|
||||||
|
|
||||||
// Verify tag grid is displayed
|
|
||||||
const tagGrid = tagSection.locator('.tag-grid'); |
|
||||||
await expect(tagGrid).toBeVisible(); |
|
||||||
|
|
||||||
// Should have tag items
|
|
||||||
const tagItems = tagGrid.locator('.tag-grid-item'); |
|
||||||
const count = await tagItems.count(); |
|
||||||
expect(count).toBeGreaterThan(0); |
|
||||||
}); |
|
||||||
|
|
||||||
test('should toggle individual tag anchors on click', async ({ page }) => { |
|
||||||
const legend = page.locator('.leather-legend').first(); |
|
||||||
const tagGrid = legend.locator('.tag-grid'); |
|
||||||
|
|
||||||
// Get first tag anchor
|
|
||||||
const firstTag = tagGrid.locator('.tag-grid-item').first(); |
|
||||||
const tagLabel = await firstTag.locator('.legend-text').textContent(); |
|
||||||
|
|
||||||
// Click to disable
|
|
||||||
await firstTag.click(); |
|
||||||
|
|
||||||
// Should have disabled class
|
|
||||||
await expect(firstTag).toHaveClass(/disabled/); |
|
||||||
|
|
||||||
// Visual indicators should show disabled state
|
|
||||||
const tagCircle = firstTag.locator('.legend-circle'); |
|
||||||
await expect(tagCircle).toHaveCSS('opacity', '0.3'); |
|
||||||
|
|
||||||
const tagText = firstTag.locator('.legend-text'); |
|
||||||
await expect(tagText).toHaveCSS('opacity', '0.5'); |
|
||||||
|
|
||||||
// Click again to enable
|
|
||||||
await firstTag.click(); |
|
||||||
|
|
||||||
// Should not have disabled class
|
|
||||||
await expect(firstTag).not.toHaveClass(/disabled/); |
|
||||||
|
|
||||||
// Visual indicators should show enabled state
|
|
||||||
await expect(tagCircle).toHaveCSS('opacity', '1'); |
|
||||||
await expect(tagText).toHaveCSS('opacity', '1'); |
|
||||||
}); |
|
||||||
|
|
||||||
test('should show correct tooltip on hover', async ({ page }) => { |
|
||||||
const legend = page.locator('.leather-legend').first(); |
|
||||||
const tagGrid = legend.locator('.tag-grid'); |
|
||||||
|
|
||||||
// Get first tag anchor
|
|
||||||
const firstTag = tagGrid.locator('.tag-grid-item').first(); |
|
||||||
|
|
||||||
// Hover over tag
|
|
||||||
await firstTag.hover(); |
|
||||||
|
|
||||||
// Check title attribute
|
|
||||||
const title = await firstTag.getAttribute('title'); |
|
||||||
expect(title).toContain('Click to'); |
|
||||||
|
|
||||||
// Disable the tag
|
|
||||||
await firstTag.click(); |
|
||||||
await firstTag.hover(); |
|
||||||
|
|
||||||
// Title should update
|
|
||||||
const updatedTitle = await firstTag.getAttribute('title'); |
|
||||||
expect(updatedTitle).toContain('Click to enable'); |
|
||||||
}); |
|
||||||
|
|
||||||
test('should maintain disabled state across legend collapse', async ({ page }) => { |
|
||||||
const legend = page.locator('.leather-legend').first(); |
|
||||||
const tagGrid = legend.locator('.tag-grid'); |
|
||||||
|
|
||||||
// Disable some tags
|
|
||||||
const firstTag = tagGrid.locator('.tag-grid-item').first(); |
|
||||||
const secondTag = tagGrid.locator('.tag-grid-item').nth(1); |
|
||||||
|
|
||||||
await firstTag.click(); |
|
||||||
await secondTag.click(); |
|
||||||
|
|
||||||
// Verify disabled
|
|
||||||
await expect(firstTag).toHaveClass(/disabled/); |
|
||||||
await expect(secondTag).toHaveClass(/disabled/); |
|
||||||
|
|
||||||
// Collapse and expand tag section
|
|
||||||
const tagSectionHeader = legend.locator('.legend-section-header').filter({ hasText: 'Active Tag Anchors' }); |
|
||||||
await tagSectionHeader.click(); // Collapse
|
|
||||||
await tagSectionHeader.click(); // Expand
|
|
||||||
|
|
||||||
// Tags should still be disabled
|
|
||||||
await expect(firstTag).toHaveClass(/disabled/); |
|
||||||
await expect(secondTag).toHaveClass(/disabled/); |
|
||||||
}); |
|
||||||
|
|
||||||
test('should handle tag type changes correctly', async ({ page }) => { |
|
||||||
const settings = page.locator('.leather-legend').nth(1); |
|
||||||
const legend = page.locator('.leather-legend').first(); |
|
||||||
|
|
||||||
// Change tag type
|
|
||||||
const tagTypeSelect = settings.locator('#tag-type-select'); |
|
||||||
await tagTypeSelect.selectOption('p'); // Change to People (Pubkeys)
|
|
||||||
|
|
||||||
// Wait for update
|
|
||||||
await page.waitForTimeout(500); |
|
||||||
|
|
||||||
// Check legend updates
|
|
||||||
const tagSection = legend.locator('.legend-section').filter({ hasText: 'Active Tag Anchors' }); |
|
||||||
const sectionTitle = tagSection.locator('.legend-section-title'); |
|
||||||
|
|
||||||
await expect(sectionTitle).toContainText('Active Tag Anchors: p'); |
|
||||||
|
|
||||||
// Tag grid should update with new tags
|
|
||||||
const tagItems = tagSection.locator('.tag-grid-item'); |
|
||||||
const firstTagIcon = tagItems.first().locator('.legend-letter'); |
|
||||||
|
|
||||||
// Should show 'A' for author type
|
|
||||||
await expect(firstTagIcon).toContainText('A'); |
|
||||||
}); |
|
||||||
|
|
||||||
test('should show correct tag type icons', async ({ page }) => { |
|
||||||
const settings = page.locator('.leather-legend').nth(1); |
|
||||||
const legend = page.locator('.leather-legend').first(); |
|
||||||
|
|
||||||
const tagTypes = [ |
|
||||||
{ value: 't', icon: '#' }, |
|
||||||
{ value: 'author', icon: 'A' }, |
|
||||||
{ value: 'p', icon: 'P' }, |
|
||||||
{ value: 'e', icon: 'E' }, |
|
||||||
{ value: 'title', icon: 'T' }, |
|
||||||
{ value: 'summary', icon: 'S' } |
|
||||||
]; |
|
||||||
|
|
||||||
for (const { value, icon } of tagTypes) { |
|
||||||
// Change tag type
|
|
||||||
const tagTypeSelect = settings.locator('#tag-type-select'); |
|
||||||
await tagTypeSelect.selectOption(value); |
|
||||||
|
|
||||||
// Wait for update
|
|
||||||
await page.waitForTimeout(500); |
|
||||||
|
|
||||||
// Check icon
|
|
||||||
const tagGrid = legend.locator('.tag-grid'); |
|
||||||
const tagItems = tagGrid.locator('.tag-grid-item'); |
|
||||||
|
|
||||||
if (await tagItems.count() > 0) { |
|
||||||
const firstTagIcon = tagItems.first().locator('.legend-letter'); |
|
||||||
await expect(firstTagIcon).toContainText(icon); |
|
||||||
} |
|
||||||
} |
|
||||||
}); |
|
||||||
|
|
||||||
test('should handle empty tag lists gracefully', async ({ page }) => { |
|
||||||
const settings = page.locator('.leather-legend').nth(1); |
|
||||||
const legend = page.locator('.leather-legend').first(); |
|
||||||
|
|
||||||
// Try different tag types that might have no results
|
|
||||||
const tagTypeSelect = settings.locator('#tag-type-select'); |
|
||||||
await tagTypeSelect.selectOption('summary'); |
|
||||||
|
|
||||||
// Wait for update
|
|
||||||
await page.waitForTimeout(500); |
|
||||||
|
|
||||||
// Check if tag section exists
|
|
||||||
const tagSection = legend.locator('.legend-section').filter({ hasText: 'Active Tag Anchors' }); |
|
||||||
const tagSectionCount = await tagSection.count(); |
|
||||||
|
|
||||||
if (tagSectionCount === 0) { |
|
||||||
// No tag section should be shown if no tags
|
|
||||||
expect(tagSectionCount).toBe(0); |
|
||||||
} else { |
|
||||||
// If section exists, check for empty state
|
|
||||||
const tagGrid = tagSection.locator('.tag-grid'); |
|
||||||
const tagItems = tagGrid.locator('.tag-grid-item'); |
|
||||||
const itemCount = await tagItems.count(); |
|
||||||
|
|
||||||
// Should handle empty state gracefully
|
|
||||||
expect(itemCount).toBeGreaterThanOrEqual(0); |
|
||||||
} |
|
||||||
}); |
|
||||||
|
|
||||||
test('should update graph when tags are toggled', async ({ page }) => { |
|
||||||
const legend = page.locator('.leather-legend').first(); |
|
||||||
const tagGrid = legend.locator('.tag-grid'); |
|
||||||
|
|
||||||
// Get initial graph state (count visible nodes)
|
|
||||||
const graphContainer = page.locator('svg.network-graph'); |
|
||||||
const initialNodes = await graphContainer.locator('circle').count(); |
|
||||||
|
|
||||||
// Disable a tag
|
|
||||||
const firstTag = tagGrid.locator('.tag-grid-item').first(); |
|
||||||
await firstTag.click(); |
|
||||||
|
|
||||||
// Wait for graph update
|
|
||||||
await page.waitForTimeout(500); |
|
||||||
|
|
||||||
// Graph should update (implementation specific - might hide nodes or change styling)
|
|
||||||
// This is a placeholder assertion - actual behavior depends on implementation
|
|
||||||
const updatedNodes = await graphContainer.locator('circle').count(); |
|
||||||
|
|
||||||
// Nodes might be hidden or styled differently
|
|
||||||
// The exact assertion depends on how disabled tags affect the visualization
|
|
||||||
expect(updatedNodes).toBeGreaterThanOrEqual(0); |
|
||||||
}); |
|
||||||
|
|
||||||
test('should work with keyboard navigation', async ({ page }) => { |
|
||||||
const legend = page.locator('.leather-legend').first(); |
|
||||||
const tagGrid = legend.locator('.tag-grid'); |
|
||||||
|
|
||||||
// Focus first tag
|
|
||||||
const firstTag = tagGrid.locator('.tag-grid-item').first(); |
|
||||||
await firstTag.focus(); |
|
||||||
|
|
||||||
// Press Enter to toggle
|
|
||||||
await page.keyboard.press('Enter'); |
|
||||||
|
|
||||||
// Should be disabled
|
|
||||||
await expect(firstTag).toHaveClass(/disabled/); |
|
||||||
|
|
||||||
// Press Enter again
|
|
||||||
await page.keyboard.press('Enter'); |
|
||||||
|
|
||||||
// Should be enabled
|
|
||||||
await expect(firstTag).not.toHaveClass(/disabled/); |
|
||||||
|
|
||||||
// Tab to next tag
|
|
||||||
await page.keyboard.press('Tab'); |
|
||||||
|
|
||||||
// Should focus next tag
|
|
||||||
const secondTag = tagGrid.locator('.tag-grid-item').nth(1); |
|
||||||
await expect(secondTag).toBeFocused(); |
|
||||||
}); |
|
||||||
|
|
||||||
test('should persist state through tag type changes', async ({ page }) => { |
|
||||||
const settings = page.locator('.leather-legend').nth(1); |
|
||||||
const legend = page.locator('.leather-legend').first(); |
|
||||||
const tagGrid = legend.locator('.tag-grid'); |
|
||||||
|
|
||||||
// Disable some hashtags
|
|
||||||
const firstHashtag = tagGrid.locator('.tag-grid-item').first(); |
|
||||||
await firstHashtag.click(); |
|
||||||
|
|
||||||
// Change to authors
|
|
||||||
const tagTypeSelect = settings.locator('#tag-type-select'); |
|
||||||
await tagTypeSelect.selectOption('author'); |
|
||||||
await page.waitForTimeout(500); |
|
||||||
|
|
||||||
// Disable an author tag
|
|
||||||
const firstAuthor = tagGrid.locator('.tag-grid-item').first(); |
|
||||||
await firstAuthor.click(); |
|
||||||
|
|
||||||
// Switch back to hashtags
|
|
||||||
await tagTypeSelect.selectOption('t'); |
|
||||||
await page.waitForTimeout(500); |
|
||||||
|
|
||||||
// Original hashtag should still be disabled
|
|
||||||
// Note: This assumes state persistence per tag type
|
|
||||||
const hashtagsAgain = tagGrid.locator('.tag-grid-item'); |
|
||||||
if (await hashtagsAgain.count() > 0) { |
|
||||||
// Implementation specific - check if state is preserved
|
|
||||||
const firstHashtagAgain = hashtagsAgain.first(); |
|
||||||
// State might or might not be preserved depending on implementation
|
|
||||||
await expect(firstHashtagAgain).toBeVisible(); |
|
||||||
} |
|
||||||
}); |
|
||||||
}); |
|
||||||
@ -1,150 +0,0 @@ |
|||||||
# Test info |
|
||||||
|
|
||||||
- Name: Shallow Copy POC Performance Validation >> position preservation during visual updates |
|
||||||
- Location: /home/user/Documents/Programming/gc-alexandria/tests/e2e/poc-performance-validation.pw.spec.ts:136:3 |
|
||||||
|
|
||||||
# Error details |
|
||||||
|
|
||||||
``` |
|
||||||
TimeoutError: page.waitForSelector: Timeout 10000ms exceeded. |
|
||||||
Call log: |
|
||||||
- waiting for locator('.network-svg') to be visible |
|
||||||
|
|
||||||
at /home/user/Documents/Programming/gc-alexandria/tests/e2e/poc-performance-validation.pw.spec.ts:30:16 |
|
||||||
``` |
|
||||||
|
|
||||||
# Test source |
|
||||||
|
|
||||||
```ts |
|
||||||
1 | import { test, expect } from '@playwright/test'; |
|
||||||
2 | |
|
||||||
3 | // Performance thresholds based on POC targets |
|
||||||
4 | const PERFORMANCE_TARGETS = { |
|
||||||
5 | visualUpdate: 50, // <50ms for visual updates |
|
||||||
6 | fullUpdate: 200, // Baseline for full updates |
|
||||||
7 | positionDrift: 5, // Max pixels of position drift |
|
||||||
8 | memoryIncrease: 10 // Max % memory increase per update |
|
||||||
9 | }; |
|
||||||
10 | |
|
||||||
11 | test.describe('Shallow Copy POC Performance Validation', () => { |
|
||||||
12 | // Helper to extract console logs |
|
||||||
13 | const consoleLogs: string[] = []; |
|
||||||
14 | |
|
||||||
15 | test.beforeEach(async ({ page }) => { |
|
||||||
16 | // Clear logs |
|
||||||
17 | consoleLogs.length = 0; |
|
||||||
18 | |
|
||||||
19 | // Capture console logs |
|
||||||
20 | page.on('console', msg => { |
|
||||||
21 | if (msg.type() === 'log' && msg.text().includes('[EventNetwork]')) { |
|
||||||
22 | consoleLogs.push(msg.text()); |
|
||||||
23 | } |
|
||||||
24 | }); |
|
||||||
25 | |
|
||||||
26 | // Navigate to visualization page |
|
||||||
27 | await page.goto('http://localhost:5175/visualize'); |
|
||||||
28 | |
|
||||||
29 | // Wait for initial load |
|
||||||
> 30 | await page.waitForSelector('.network-svg', { timeout: 10000 }); |
|
||||||
| ^ TimeoutError: page.waitForSelector: Timeout 10000ms exceeded. |
|
||||||
31 | await page.waitForTimeout(2000); // Allow graph to stabilize |
|
||||||
32 | }); |
|
||||||
33 | |
|
||||||
34 | test('star visualization toggle uses visual update path', async ({ page }) => { |
|
||||||
35 | // Enable settings panel |
|
||||||
36 | const settings = page.locator('.leather-legend').nth(1); |
|
||||||
37 | const settingsToggle = settings.locator('button').first(); |
|
||||||
38 | await settingsToggle.click(); |
|
||||||
39 | |
|
||||||
40 | // Ensure visual settings section is expanded |
|
||||||
41 | const visualSettingsHeader = settings.locator('.settings-section-header').filter({ hasText: 'Visual Settings' }); |
|
||||||
42 | await visualSettingsHeader.click(); |
|
||||||
43 | |
|
||||||
44 | // Clear previous logs |
|
||||||
45 | consoleLogs.length = 0; |
|
||||||
46 | |
|
||||||
47 | // Toggle star visualization |
|
||||||
48 | const starToggle = settings.locator('label').filter({ hasText: 'Star Network View' }).locator('input[type="checkbox"]'); |
|
||||||
49 | await starToggle.click(); |
|
||||||
50 | |
|
||||||
51 | // Wait for update |
|
||||||
52 | await page.waitForTimeout(100); |
|
||||||
53 | |
|
||||||
54 | // Check logs for update type |
|
||||||
55 | const updateLogs = consoleLogs.filter(log => log.includes('Update type detected')); |
|
||||||
56 | expect(updateLogs.length).toBeGreaterThan(0); |
|
||||||
57 | |
|
||||||
58 | const lastUpdateLog = updateLogs[updateLogs.length - 1]; |
|
||||||
59 | expect(lastUpdateLog).toContain('kind: "visual"'); |
|
||||||
60 | expect(lastUpdateLog).toContain('star'); |
|
||||||
61 | |
|
||||||
62 | // Check for visual properties update |
|
||||||
63 | const visualUpdateLogs = consoleLogs.filter(log => log.includes('updateVisualProperties called')); |
|
||||||
64 | expect(visualUpdateLogs.length).toBeGreaterThan(0); |
|
||||||
65 | |
|
||||||
66 | // Extract timing |
|
||||||
67 | const timingLogs = consoleLogs.filter(log => log.includes('Visual properties updated in')); |
|
||||||
68 | if (timingLogs.length > 0) { |
|
||||||
69 | const match = timingLogs[0].match(/(\d+\.\d+)ms/); |
|
||||||
70 | if (match) { |
|
||||||
71 | const updateTime = parseFloat(match[1]); |
|
||||||
72 | expect(updateTime).toBeLessThan(PERFORMANCE_TARGETS.visualUpdate); |
|
||||||
73 | console.log(`Star toggle update time: ${updateTime}ms`); |
|
||||||
74 | } |
|
||||||
75 | } |
|
||||||
76 | }); |
|
||||||
77 | |
|
||||||
78 | test('tag visibility toggle uses visual update path', async ({ page }) => { |
|
||||||
79 | // Enable settings and tag anchors |
|
||||||
80 | const settings = page.locator('.leather-legend').nth(1); |
|
||||||
81 | const settingsToggle = settings.locator('button').first(); |
|
||||||
82 | await settingsToggle.click(); |
|
||||||
83 | |
|
||||||
84 | // Enable tag anchors |
|
||||||
85 | const visualSettingsHeader = settings.locator('.settings-section-header').filter({ hasText: 'Visual Settings' }); |
|
||||||
86 | await visualSettingsHeader.click(); |
|
||||||
87 | |
|
||||||
88 | const tagAnchorsToggle = settings.locator('label').filter({ hasText: 'Show Tag Anchors' }).locator('input[type="checkbox"]'); |
|
||||||
89 | await tagAnchorsToggle.click(); |
|
||||||
90 | |
|
||||||
91 | // Wait for tags to appear |
|
||||||
92 | await page.waitForTimeout(1000); |
|
||||||
93 | |
|
||||||
94 | const legend = page.locator('.leather-legend').first(); |
|
||||||
95 | const tagSection = legend.locator('.legend-section').filter({ hasText: 'Active Tag Anchors' }); |
|
||||||
96 | |
|
||||||
97 | if (await tagSection.count() > 0) { |
|
||||||
98 | // Expand tag section if needed |
|
||||||
99 | const tagHeader = tagSection.locator('.legend-section-header'); |
|
||||||
100 | const tagGrid = tagSection.locator('.tag-grid'); |
|
||||||
101 | if (!(await tagGrid.isVisible())) { |
|
||||||
102 | await tagHeader.click(); |
|
||||||
103 | } |
|
||||||
104 | |
|
||||||
105 | // Clear logs |
|
||||||
106 | consoleLogs.length = 0; |
|
||||||
107 | |
|
||||||
108 | // Toggle first tag |
|
||||||
109 | const firstTag = tagGrid.locator('.tag-grid-item').first(); |
|
||||||
110 | await firstTag.click(); |
|
||||||
111 | |
|
||||||
112 | // Wait for update |
|
||||||
113 | await page.waitForTimeout(100); |
|
||||||
114 | |
|
||||||
115 | // Check for visual update |
|
||||||
116 | const updateLogs = consoleLogs.filter(log => log.includes('Update type detected')); |
|
||||||
117 | expect(updateLogs.length).toBeGreaterThan(0); |
|
||||||
118 | |
|
||||||
119 | const lastUpdateLog = updateLogs[updateLogs.length - 1]; |
|
||||||
120 | expect(lastUpdateLog).toContain('kind: "visual"'); |
|
||||||
121 | expect(lastUpdateLog).toContain('disabledCount'); |
|
||||||
122 | |
|
||||||
123 | // Check timing |
|
||||||
124 | const timingLogs = consoleLogs.filter(log => log.includes('Visual properties updated in')); |
|
||||||
125 | if (timingLogs.length > 0) { |
|
||||||
126 | const match = timingLogs[0].match(/(\d+\.\d+)ms/); |
|
||||||
127 | if (match) { |
|
||||||
128 | const updateTime = parseFloat(match[1]); |
|
||||||
129 | expect(updateTime).toBeLessThan(PERFORMANCE_TARGETS.visualUpdate); |
|
||||||
130 | console.log(`Tag toggle update time: ${updateTime}ms`); |
|
||||||
``` |
|
||||||
@ -1,150 +0,0 @@ |
|||||||
# Test info |
|
||||||
|
|
||||||
- Name: Shallow Copy POC Performance Validation >> rapid parameter changes are handled efficiently |
|
||||||
- Location: /home/user/Documents/Programming/gc-alexandria/tests/e2e/poc-performance-validation.pw.spec.ts:233:3 |
|
||||||
|
|
||||||
# Error details |
|
||||||
|
|
||||||
``` |
|
||||||
TimeoutError: page.waitForSelector: Timeout 10000ms exceeded. |
|
||||||
Call log: |
|
||||||
- waiting for locator('.network-svg') to be visible |
|
||||||
|
|
||||||
at /home/user/Documents/Programming/gc-alexandria/tests/e2e/poc-performance-validation.pw.spec.ts:30:16 |
|
||||||
``` |
|
||||||
|
|
||||||
# Test source |
|
||||||
|
|
||||||
```ts |
|
||||||
1 | import { test, expect } from '@playwright/test'; |
|
||||||
2 | |
|
||||||
3 | // Performance thresholds based on POC targets |
|
||||||
4 | const PERFORMANCE_TARGETS = { |
|
||||||
5 | visualUpdate: 50, // <50ms for visual updates |
|
||||||
6 | fullUpdate: 200, // Baseline for full updates |
|
||||||
7 | positionDrift: 5, // Max pixels of position drift |
|
||||||
8 | memoryIncrease: 10 // Max % memory increase per update |
|
||||||
9 | }; |
|
||||||
10 | |
|
||||||
11 | test.describe('Shallow Copy POC Performance Validation', () => { |
|
||||||
12 | // Helper to extract console logs |
|
||||||
13 | const consoleLogs: string[] = []; |
|
||||||
14 | |
|
||||||
15 | test.beforeEach(async ({ page }) => { |
|
||||||
16 | // Clear logs |
|
||||||
17 | consoleLogs.length = 0; |
|
||||||
18 | |
|
||||||
19 | // Capture console logs |
|
||||||
20 | page.on('console', msg => { |
|
||||||
21 | if (msg.type() === 'log' && msg.text().includes('[EventNetwork]')) { |
|
||||||
22 | consoleLogs.push(msg.text()); |
|
||||||
23 | } |
|
||||||
24 | }); |
|
||||||
25 | |
|
||||||
26 | // Navigate to visualization page |
|
||||||
27 | await page.goto('http://localhost:5175/visualize'); |
|
||||||
28 | |
|
||||||
29 | // Wait for initial load |
|
||||||
> 30 | await page.waitForSelector('.network-svg', { timeout: 10000 }); |
|
||||||
| ^ TimeoutError: page.waitForSelector: Timeout 10000ms exceeded. |
|
||||||
31 | await page.waitForTimeout(2000); // Allow graph to stabilize |
|
||||||
32 | }); |
|
||||||
33 | |
|
||||||
34 | test('star visualization toggle uses visual update path', async ({ page }) => { |
|
||||||
35 | // Enable settings panel |
|
||||||
36 | const settings = page.locator('.leather-legend').nth(1); |
|
||||||
37 | const settingsToggle = settings.locator('button').first(); |
|
||||||
38 | await settingsToggle.click(); |
|
||||||
39 | |
|
||||||
40 | // Ensure visual settings section is expanded |
|
||||||
41 | const visualSettingsHeader = settings.locator('.settings-section-header').filter({ hasText: 'Visual Settings' }); |
|
||||||
42 | await visualSettingsHeader.click(); |
|
||||||
43 | |
|
||||||
44 | // Clear previous logs |
|
||||||
45 | consoleLogs.length = 0; |
|
||||||
46 | |
|
||||||
47 | // Toggle star visualization |
|
||||||
48 | const starToggle = settings.locator('label').filter({ hasText: 'Star Network View' }).locator('input[type="checkbox"]'); |
|
||||||
49 | await starToggle.click(); |
|
||||||
50 | |
|
||||||
51 | // Wait for update |
|
||||||
52 | await page.waitForTimeout(100); |
|
||||||
53 | |
|
||||||
54 | // Check logs for update type |
|
||||||
55 | const updateLogs = consoleLogs.filter(log => log.includes('Update type detected')); |
|
||||||
56 | expect(updateLogs.length).toBeGreaterThan(0); |
|
||||||
57 | |
|
||||||
58 | const lastUpdateLog = updateLogs[updateLogs.length - 1]; |
|
||||||
59 | expect(lastUpdateLog).toContain('kind: "visual"'); |
|
||||||
60 | expect(lastUpdateLog).toContain('star'); |
|
||||||
61 | |
|
||||||
62 | // Check for visual properties update |
|
||||||
63 | const visualUpdateLogs = consoleLogs.filter(log => log.includes('updateVisualProperties called')); |
|
||||||
64 | expect(visualUpdateLogs.length).toBeGreaterThan(0); |
|
||||||
65 | |
|
||||||
66 | // Extract timing |
|
||||||
67 | const timingLogs = consoleLogs.filter(log => log.includes('Visual properties updated in')); |
|
||||||
68 | if (timingLogs.length > 0) { |
|
||||||
69 | const match = timingLogs[0].match(/(\d+\.\d+)ms/); |
|
||||||
70 | if (match) { |
|
||||||
71 | const updateTime = parseFloat(match[1]); |
|
||||||
72 | expect(updateTime).toBeLessThan(PERFORMANCE_TARGETS.visualUpdate); |
|
||||||
73 | console.log(`Star toggle update time: ${updateTime}ms`); |
|
||||||
74 | } |
|
||||||
75 | } |
|
||||||
76 | }); |
|
||||||
77 | |
|
||||||
78 | test('tag visibility toggle uses visual update path', async ({ page }) => { |
|
||||||
79 | // Enable settings and tag anchors |
|
||||||
80 | const settings = page.locator('.leather-legend').nth(1); |
|
||||||
81 | const settingsToggle = settings.locator('button').first(); |
|
||||||
82 | await settingsToggle.click(); |
|
||||||
83 | |
|
||||||
84 | // Enable tag anchors |
|
||||||
85 | const visualSettingsHeader = settings.locator('.settings-section-header').filter({ hasText: 'Visual Settings' }); |
|
||||||
86 | await visualSettingsHeader.click(); |
|
||||||
87 | |
|
||||||
88 | const tagAnchorsToggle = settings.locator('label').filter({ hasText: 'Show Tag Anchors' }).locator('input[type="checkbox"]'); |
|
||||||
89 | await tagAnchorsToggle.click(); |
|
||||||
90 | |
|
||||||
91 | // Wait for tags to appear |
|
||||||
92 | await page.waitForTimeout(1000); |
|
||||||
93 | |
|
||||||
94 | const legend = page.locator('.leather-legend').first(); |
|
||||||
95 | const tagSection = legend.locator('.legend-section').filter({ hasText: 'Active Tag Anchors' }); |
|
||||||
96 | |
|
||||||
97 | if (await tagSection.count() > 0) { |
|
||||||
98 | // Expand tag section if needed |
|
||||||
99 | const tagHeader = tagSection.locator('.legend-section-header'); |
|
||||||
100 | const tagGrid = tagSection.locator('.tag-grid'); |
|
||||||
101 | if (!(await tagGrid.isVisible())) { |
|
||||||
102 | await tagHeader.click(); |
|
||||||
103 | } |
|
||||||
104 | |
|
||||||
105 | // Clear logs |
|
||||||
106 | consoleLogs.length = 0; |
|
||||||
107 | |
|
||||||
108 | // Toggle first tag |
|
||||||
109 | const firstTag = tagGrid.locator('.tag-grid-item').first(); |
|
||||||
110 | await firstTag.click(); |
|
||||||
111 | |
|
||||||
112 | // Wait for update |
|
||||||
113 | await page.waitForTimeout(100); |
|
||||||
114 | |
|
||||||
115 | // Check for visual update |
|
||||||
116 | const updateLogs = consoleLogs.filter(log => log.includes('Update type detected')); |
|
||||||
117 | expect(updateLogs.length).toBeGreaterThan(0); |
|
||||||
118 | |
|
||||||
119 | const lastUpdateLog = updateLogs[updateLogs.length - 1]; |
|
||||||
120 | expect(lastUpdateLog).toContain('kind: "visual"'); |
|
||||||
121 | expect(lastUpdateLog).toContain('disabledCount'); |
|
||||||
122 | |
|
||||||
123 | // Check timing |
|
||||||
124 | const timingLogs = consoleLogs.filter(log => log.includes('Visual properties updated in')); |
|
||||||
125 | if (timingLogs.length > 0) { |
|
||||||
126 | const match = timingLogs[0].match(/(\d+\.\d+)ms/); |
|
||||||
127 | if (match) { |
|
||||||
128 | const updateTime = parseFloat(match[1]); |
|
||||||
129 | expect(updateTime).toBeLessThan(PERFORMANCE_TARGETS.visualUpdate); |
|
||||||
130 | console.log(`Tag toggle update time: ${updateTime}ms`); |
|
||||||
``` |
|
||||||
@ -1,150 +0,0 @@ |
|||||||
# Test info |
|
||||||
|
|
||||||
- Name: Shallow Copy POC Performance Validation >> simulation maintains momentum |
|
||||||
- Location: /home/user/Documents/Programming/gc-alexandria/tests/e2e/poc-performance-validation.pw.spec.ts:207:3 |
|
||||||
|
|
||||||
# Error details |
|
||||||
|
|
||||||
``` |
|
||||||
TimeoutError: page.waitForSelector: Timeout 10000ms exceeded. |
|
||||||
Call log: |
|
||||||
- waiting for locator('.network-svg') to be visible |
|
||||||
|
|
||||||
at /home/user/Documents/Programming/gc-alexandria/tests/e2e/poc-performance-validation.pw.spec.ts:30:16 |
|
||||||
``` |
|
||||||
|
|
||||||
# Test source |
|
||||||
|
|
||||||
```ts |
|
||||||
1 | import { test, expect } from '@playwright/test'; |
|
||||||
2 | |
|
||||||
3 | // Performance thresholds based on POC targets |
|
||||||
4 | const PERFORMANCE_TARGETS = { |
|
||||||
5 | visualUpdate: 50, // <50ms for visual updates |
|
||||||
6 | fullUpdate: 200, // Baseline for full updates |
|
||||||
7 | positionDrift: 5, // Max pixels of position drift |
|
||||||
8 | memoryIncrease: 10 // Max % memory increase per update |
|
||||||
9 | }; |
|
||||||
10 | |
|
||||||
11 | test.describe('Shallow Copy POC Performance Validation', () => { |
|
||||||
12 | // Helper to extract console logs |
|
||||||
13 | const consoleLogs: string[] = []; |
|
||||||
14 | |
|
||||||
15 | test.beforeEach(async ({ page }) => { |
|
||||||
16 | // Clear logs |
|
||||||
17 | consoleLogs.length = 0; |
|
||||||
18 | |
|
||||||
19 | // Capture console logs |
|
||||||
20 | page.on('console', msg => { |
|
||||||
21 | if (msg.type() === 'log' && msg.text().includes('[EventNetwork]')) { |
|
||||||
22 | consoleLogs.push(msg.text()); |
|
||||||
23 | } |
|
||||||
24 | }); |
|
||||||
25 | |
|
||||||
26 | // Navigate to visualization page |
|
||||||
27 | await page.goto('http://localhost:5175/visualize'); |
|
||||||
28 | |
|
||||||
29 | // Wait for initial load |
|
||||||
> 30 | await page.waitForSelector('.network-svg', { timeout: 10000 }); |
|
||||||
| ^ TimeoutError: page.waitForSelector: Timeout 10000ms exceeded. |
|
||||||
31 | await page.waitForTimeout(2000); // Allow graph to stabilize |
|
||||||
32 | }); |
|
||||||
33 | |
|
||||||
34 | test('star visualization toggle uses visual update path', async ({ page }) => { |
|
||||||
35 | // Enable settings panel |
|
||||||
36 | const settings = page.locator('.leather-legend').nth(1); |
|
||||||
37 | const settingsToggle = settings.locator('button').first(); |
|
||||||
38 | await settingsToggle.click(); |
|
||||||
39 | |
|
||||||
40 | // Ensure visual settings section is expanded |
|
||||||
41 | const visualSettingsHeader = settings.locator('.settings-section-header').filter({ hasText: 'Visual Settings' }); |
|
||||||
42 | await visualSettingsHeader.click(); |
|
||||||
43 | |
|
||||||
44 | // Clear previous logs |
|
||||||
45 | consoleLogs.length = 0; |
|
||||||
46 | |
|
||||||
47 | // Toggle star visualization |
|
||||||
48 | const starToggle = settings.locator('label').filter({ hasText: 'Star Network View' }).locator('input[type="checkbox"]'); |
|
||||||
49 | await starToggle.click(); |
|
||||||
50 | |
|
||||||
51 | // Wait for update |
|
||||||
52 | await page.waitForTimeout(100); |
|
||||||
53 | |
|
||||||
54 | // Check logs for update type |
|
||||||
55 | const updateLogs = consoleLogs.filter(log => log.includes('Update type detected')); |
|
||||||
56 | expect(updateLogs.length).toBeGreaterThan(0); |
|
||||||
57 | |
|
||||||
58 | const lastUpdateLog = updateLogs[updateLogs.length - 1]; |
|
||||||
59 | expect(lastUpdateLog).toContain('kind: "visual"'); |
|
||||||
60 | expect(lastUpdateLog).toContain('star'); |
|
||||||
61 | |
|
||||||
62 | // Check for visual properties update |
|
||||||
63 | const visualUpdateLogs = consoleLogs.filter(log => log.includes('updateVisualProperties called')); |
|
||||||
64 | expect(visualUpdateLogs.length).toBeGreaterThan(0); |
|
||||||
65 | |
|
||||||
66 | // Extract timing |
|
||||||
67 | const timingLogs = consoleLogs.filter(log => log.includes('Visual properties updated in')); |
|
||||||
68 | if (timingLogs.length > 0) { |
|
||||||
69 | const match = timingLogs[0].match(/(\d+\.\d+)ms/); |
|
||||||
70 | if (match) { |
|
||||||
71 | const updateTime = parseFloat(match[1]); |
|
||||||
72 | expect(updateTime).toBeLessThan(PERFORMANCE_TARGETS.visualUpdate); |
|
||||||
73 | console.log(`Star toggle update time: ${updateTime}ms`); |
|
||||||
74 | } |
|
||||||
75 | } |
|
||||||
76 | }); |
|
||||||
77 | |
|
||||||
78 | test('tag visibility toggle uses visual update path', async ({ page }) => { |
|
||||||
79 | // Enable settings and tag anchors |
|
||||||
80 | const settings = page.locator('.leather-legend').nth(1); |
|
||||||
81 | const settingsToggle = settings.locator('button').first(); |
|
||||||
82 | await settingsToggle.click(); |
|
||||||
83 | |
|
||||||
84 | // Enable tag anchors |
|
||||||
85 | const visualSettingsHeader = settings.locator('.settings-section-header').filter({ hasText: 'Visual Settings' }); |
|
||||||
86 | await visualSettingsHeader.click(); |
|
||||||
87 | |
|
||||||
88 | const tagAnchorsToggle = settings.locator('label').filter({ hasText: 'Show Tag Anchors' }).locator('input[type="checkbox"]'); |
|
||||||
89 | await tagAnchorsToggle.click(); |
|
||||||
90 | |
|
||||||
91 | // Wait for tags to appear |
|
||||||
92 | await page.waitForTimeout(1000); |
|
||||||
93 | |
|
||||||
94 | const legend = page.locator('.leather-legend').first(); |
|
||||||
95 | const tagSection = legend.locator('.legend-section').filter({ hasText: 'Active Tag Anchors' }); |
|
||||||
96 | |
|
||||||
97 | if (await tagSection.count() > 0) { |
|
||||||
98 | // Expand tag section if needed |
|
||||||
99 | const tagHeader = tagSection.locator('.legend-section-header'); |
|
||||||
100 | const tagGrid = tagSection.locator('.tag-grid'); |
|
||||||
101 | if (!(await tagGrid.isVisible())) { |
|
||||||
102 | await tagHeader.click(); |
|
||||||
103 | } |
|
||||||
104 | |
|
||||||
105 | // Clear logs |
|
||||||
106 | consoleLogs.length = 0; |
|
||||||
107 | |
|
||||||
108 | // Toggle first tag |
|
||||||
109 | const firstTag = tagGrid.locator('.tag-grid-item').first(); |
|
||||||
110 | await firstTag.click(); |
|
||||||
111 | |
|
||||||
112 | // Wait for update |
|
||||||
113 | await page.waitForTimeout(100); |
|
||||||
114 | |
|
||||||
115 | // Check for visual update |
|
||||||
116 | const updateLogs = consoleLogs.filter(log => log.includes('Update type detected')); |
|
||||||
117 | expect(updateLogs.length).toBeGreaterThan(0); |
|
||||||
118 | |
|
||||||
119 | const lastUpdateLog = updateLogs[updateLogs.length - 1]; |
|
||||||
120 | expect(lastUpdateLog).toContain('kind: "visual"'); |
|
||||||
121 | expect(lastUpdateLog).toContain('disabledCount'); |
|
||||||
122 | |
|
||||||
123 | // Check timing |
|
||||||
124 | const timingLogs = consoleLogs.filter(log => log.includes('Visual properties updated in')); |
|
||||||
125 | if (timingLogs.length > 0) { |
|
||||||
126 | const match = timingLogs[0].match(/(\d+\.\d+)ms/); |
|
||||||
127 | if (match) { |
|
||||||
128 | const updateTime = parseFloat(match[1]); |
|
||||||
129 | expect(updateTime).toBeLessThan(PERFORMANCE_TARGETS.visualUpdate); |
|
||||||
130 | console.log(`Tag toggle update time: ${updateTime}ms`); |
|
||||||
``` |
|
||||||
@ -1,150 +0,0 @@ |
|||||||
# Test info |
|
||||||
|
|
||||||
- Name: Shallow Copy POC Performance Validation >> tag visibility toggle uses visual update path |
|
||||||
- Location: /home/user/Documents/Programming/gc-alexandria/tests/e2e/poc-performance-validation.pw.spec.ts:78:3 |
|
||||||
|
|
||||||
# Error details |
|
||||||
|
|
||||||
``` |
|
||||||
TimeoutError: page.waitForSelector: Timeout 10000ms exceeded. |
|
||||||
Call log: |
|
||||||
- waiting for locator('.network-svg') to be visible |
|
||||||
|
|
||||||
at /home/user/Documents/Programming/gc-alexandria/tests/e2e/poc-performance-validation.pw.spec.ts:30:16 |
|
||||||
``` |
|
||||||
|
|
||||||
# Test source |
|
||||||
|
|
||||||
```ts |
|
||||||
1 | import { test, expect } from '@playwright/test'; |
|
||||||
2 | |
|
||||||
3 | // Performance thresholds based on POC targets |
|
||||||
4 | const PERFORMANCE_TARGETS = { |
|
||||||
5 | visualUpdate: 50, // <50ms for visual updates |
|
||||||
6 | fullUpdate: 200, // Baseline for full updates |
|
||||||
7 | positionDrift: 5, // Max pixels of position drift |
|
||||||
8 | memoryIncrease: 10 // Max % memory increase per update |
|
||||||
9 | }; |
|
||||||
10 | |
|
||||||
11 | test.describe('Shallow Copy POC Performance Validation', () => { |
|
||||||
12 | // Helper to extract console logs |
|
||||||
13 | const consoleLogs: string[] = []; |
|
||||||
14 | |
|
||||||
15 | test.beforeEach(async ({ page }) => { |
|
||||||
16 | // Clear logs |
|
||||||
17 | consoleLogs.length = 0; |
|
||||||
18 | |
|
||||||
19 | // Capture console logs |
|
||||||
20 | page.on('console', msg => { |
|
||||||
21 | if (msg.type() === 'log' && msg.text().includes('[EventNetwork]')) { |
|
||||||
22 | consoleLogs.push(msg.text()); |
|
||||||
23 | } |
|
||||||
24 | }); |
|
||||||
25 | |
|
||||||
26 | // Navigate to visualization page |
|
||||||
27 | await page.goto('http://localhost:5175/visualize'); |
|
||||||
28 | |
|
||||||
29 | // Wait for initial load |
|
||||||
> 30 | await page.waitForSelector('.network-svg', { timeout: 10000 }); |
|
||||||
| ^ TimeoutError: page.waitForSelector: Timeout 10000ms exceeded. |
|
||||||
31 | await page.waitForTimeout(2000); // Allow graph to stabilize |
|
||||||
32 | }); |
|
||||||
33 | |
|
||||||
34 | test('star visualization toggle uses visual update path', async ({ page }) => { |
|
||||||
35 | // Enable settings panel |
|
||||||
36 | const settings = page.locator('.leather-legend').nth(1); |
|
||||||
37 | const settingsToggle = settings.locator('button').first(); |
|
||||||
38 | await settingsToggle.click(); |
|
||||||
39 | |
|
||||||
40 | // Ensure visual settings section is expanded |
|
||||||
41 | const visualSettingsHeader = settings.locator('.settings-section-header').filter({ hasText: 'Visual Settings' }); |
|
||||||
42 | await visualSettingsHeader.click(); |
|
||||||
43 | |
|
||||||
44 | // Clear previous logs |
|
||||||
45 | consoleLogs.length = 0; |
|
||||||
46 | |
|
||||||
47 | // Toggle star visualization |
|
||||||
48 | const starToggle = settings.locator('label').filter({ hasText: 'Star Network View' }).locator('input[type="checkbox"]'); |
|
||||||
49 | await starToggle.click(); |
|
||||||
50 | |
|
||||||
51 | // Wait for update |
|
||||||
52 | await page.waitForTimeout(100); |
|
||||||
53 | |
|
||||||
54 | // Check logs for update type |
|
||||||
55 | const updateLogs = consoleLogs.filter(log => log.includes('Update type detected')); |
|
||||||
56 | expect(updateLogs.length).toBeGreaterThan(0); |
|
||||||
57 | |
|
||||||
58 | const lastUpdateLog = updateLogs[updateLogs.length - 1]; |
|
||||||
59 | expect(lastUpdateLog).toContain('kind: "visual"'); |
|
||||||
60 | expect(lastUpdateLog).toContain('star'); |
|
||||||
61 | |
|
||||||
62 | // Check for visual properties update |
|
||||||
63 | const visualUpdateLogs = consoleLogs.filter(log => log.includes('updateVisualProperties called')); |
|
||||||
64 | expect(visualUpdateLogs.length).toBeGreaterThan(0); |
|
||||||
65 | |
|
||||||
66 | // Extract timing |
|
||||||
67 | const timingLogs = consoleLogs.filter(log => log.includes('Visual properties updated in')); |
|
||||||
68 | if (timingLogs.length > 0) { |
|
||||||
69 | const match = timingLogs[0].match(/(\d+\.\d+)ms/); |
|
||||||
70 | if (match) { |
|
||||||
71 | const updateTime = parseFloat(match[1]); |
|
||||||
72 | expect(updateTime).toBeLessThan(PERFORMANCE_TARGETS.visualUpdate); |
|
||||||
73 | console.log(`Star toggle update time: ${updateTime}ms`); |
|
||||||
74 | } |
|
||||||
75 | } |
|
||||||
76 | }); |
|
||||||
77 | |
|
||||||
78 | test('tag visibility toggle uses visual update path', async ({ page }) => { |
|
||||||
79 | // Enable settings and tag anchors |
|
||||||
80 | const settings = page.locator('.leather-legend').nth(1); |
|
||||||
81 | const settingsToggle = settings.locator('button').first(); |
|
||||||
82 | await settingsToggle.click(); |
|
||||||
83 | |
|
||||||
84 | // Enable tag anchors |
|
||||||
85 | const visualSettingsHeader = settings.locator('.settings-section-header').filter({ hasText: 'Visual Settings' }); |
|
||||||
86 | await visualSettingsHeader.click(); |
|
||||||
87 | |
|
||||||
88 | const tagAnchorsToggle = settings.locator('label').filter({ hasText: 'Show Tag Anchors' }).locator('input[type="checkbox"]'); |
|
||||||
89 | await tagAnchorsToggle.click(); |
|
||||||
90 | |
|
||||||
91 | // Wait for tags to appear |
|
||||||
92 | await page.waitForTimeout(1000); |
|
||||||
93 | |
|
||||||
94 | const legend = page.locator('.leather-legend').first(); |
|
||||||
95 | const tagSection = legend.locator('.legend-section').filter({ hasText: 'Active Tag Anchors' }); |
|
||||||
96 | |
|
||||||
97 | if (await tagSection.count() > 0) { |
|
||||||
98 | // Expand tag section if needed |
|
||||||
99 | const tagHeader = tagSection.locator('.legend-section-header'); |
|
||||||
100 | const tagGrid = tagSection.locator('.tag-grid'); |
|
||||||
101 | if (!(await tagGrid.isVisible())) { |
|
||||||
102 | await tagHeader.click(); |
|
||||||
103 | } |
|
||||||
104 | |
|
||||||
105 | // Clear logs |
|
||||||
106 | consoleLogs.length = 0; |
|
||||||
107 | |
|
||||||
108 | // Toggle first tag |
|
||||||
109 | const firstTag = tagGrid.locator('.tag-grid-item').first(); |
|
||||||
110 | await firstTag.click(); |
|
||||||
111 | |
|
||||||
112 | // Wait for update |
|
||||||
113 | await page.waitForTimeout(100); |
|
||||||
114 | |
|
||||||
115 | // Check for visual update |
|
||||||
116 | const updateLogs = consoleLogs.filter(log => log.includes('Update type detected')); |
|
||||||
117 | expect(updateLogs.length).toBeGreaterThan(0); |
|
||||||
118 | |
|
||||||
119 | const lastUpdateLog = updateLogs[updateLogs.length - 1]; |
|
||||||
120 | expect(lastUpdateLog).toContain('kind: "visual"'); |
|
||||||
121 | expect(lastUpdateLog).toContain('disabledCount'); |
|
||||||
122 | |
|
||||||
123 | // Check timing |
|
||||||
124 | const timingLogs = consoleLogs.filter(log => log.includes('Visual properties updated in')); |
|
||||||
125 | if (timingLogs.length > 0) { |
|
||||||
126 | const match = timingLogs[0].match(/(\d+\.\d+)ms/); |
|
||||||
127 | if (match) { |
|
||||||
128 | const updateTime = parseFloat(match[1]); |
|
||||||
129 | expect(updateTime).toBeLessThan(PERFORMANCE_TARGETS.visualUpdate); |
|
||||||
130 | console.log(`Tag toggle update time: ${updateTime}ms`); |
|
||||||
``` |
|
||||||
@ -1,150 +0,0 @@ |
|||||||
# Test info |
|
||||||
|
|
||||||
- Name: Shallow Copy POC Performance Validation >> comparison: visual update vs full update performance |
|
||||||
- Location: /home/user/Documents/Programming/gc-alexandria/tests/e2e/poc-performance-validation.pw.spec.ts:314:3 |
|
||||||
|
|
||||||
# Error details |
|
||||||
|
|
||||||
``` |
|
||||||
TimeoutError: page.waitForSelector: Timeout 10000ms exceeded. |
|
||||||
Call log: |
|
||||||
- waiting for locator('.network-svg') to be visible |
|
||||||
|
|
||||||
at /home/user/Documents/Programming/gc-alexandria/tests/e2e/poc-performance-validation.pw.spec.ts:30:16 |
|
||||||
``` |
|
||||||
|
|
||||||
# Test source |
|
||||||
|
|
||||||
```ts |
|
||||||
1 | import { test, expect } from '@playwright/test'; |
|
||||||
2 | |
|
||||||
3 | // Performance thresholds based on POC targets |
|
||||||
4 | const PERFORMANCE_TARGETS = { |
|
||||||
5 | visualUpdate: 50, // <50ms for visual updates |
|
||||||
6 | fullUpdate: 200, // Baseline for full updates |
|
||||||
7 | positionDrift: 5, // Max pixels of position drift |
|
||||||
8 | memoryIncrease: 10 // Max % memory increase per update |
|
||||||
9 | }; |
|
||||||
10 | |
|
||||||
11 | test.describe('Shallow Copy POC Performance Validation', () => { |
|
||||||
12 | // Helper to extract console logs |
|
||||||
13 | const consoleLogs: string[] = []; |
|
||||||
14 | |
|
||||||
15 | test.beforeEach(async ({ page }) => { |
|
||||||
16 | // Clear logs |
|
||||||
17 | consoleLogs.length = 0; |
|
||||||
18 | |
|
||||||
19 | // Capture console logs |
|
||||||
20 | page.on('console', msg => { |
|
||||||
21 | if (msg.type() === 'log' && msg.text().includes('[EventNetwork]')) { |
|
||||||
22 | consoleLogs.push(msg.text()); |
|
||||||
23 | } |
|
||||||
24 | }); |
|
||||||
25 | |
|
||||||
26 | // Navigate to visualization page |
|
||||||
27 | await page.goto('http://localhost:5175/visualize'); |
|
||||||
28 | |
|
||||||
29 | // Wait for initial load |
|
||||||
> 30 | await page.waitForSelector('.network-svg', { timeout: 10000 }); |
|
||||||
| ^ TimeoutError: page.waitForSelector: Timeout 10000ms exceeded. |
|
||||||
31 | await page.waitForTimeout(2000); // Allow graph to stabilize |
|
||||||
32 | }); |
|
||||||
33 | |
|
||||||
34 | test('star visualization toggle uses visual update path', async ({ page }) => { |
|
||||||
35 | // Enable settings panel |
|
||||||
36 | const settings = page.locator('.leather-legend').nth(1); |
|
||||||
37 | const settingsToggle = settings.locator('button').first(); |
|
||||||
38 | await settingsToggle.click(); |
|
||||||
39 | |
|
||||||
40 | // Ensure visual settings section is expanded |
|
||||||
41 | const visualSettingsHeader = settings.locator('.settings-section-header').filter({ hasText: 'Visual Settings' }); |
|
||||||
42 | await visualSettingsHeader.click(); |
|
||||||
43 | |
|
||||||
44 | // Clear previous logs |
|
||||||
45 | consoleLogs.length = 0; |
|
||||||
46 | |
|
||||||
47 | // Toggle star visualization |
|
||||||
48 | const starToggle = settings.locator('label').filter({ hasText: 'Star Network View' }).locator('input[type="checkbox"]'); |
|
||||||
49 | await starToggle.click(); |
|
||||||
50 | |
|
||||||
51 | // Wait for update |
|
||||||
52 | await page.waitForTimeout(100); |
|
||||||
53 | |
|
||||||
54 | // Check logs for update type |
|
||||||
55 | const updateLogs = consoleLogs.filter(log => log.includes('Update type detected')); |
|
||||||
56 | expect(updateLogs.length).toBeGreaterThan(0); |
|
||||||
57 | |
|
||||||
58 | const lastUpdateLog = updateLogs[updateLogs.length - 1]; |
|
||||||
59 | expect(lastUpdateLog).toContain('kind: "visual"'); |
|
||||||
60 | expect(lastUpdateLog).toContain('star'); |
|
||||||
61 | |
|
||||||
62 | // Check for visual properties update |
|
||||||
63 | const visualUpdateLogs = consoleLogs.filter(log => log.includes('updateVisualProperties called')); |
|
||||||
64 | expect(visualUpdateLogs.length).toBeGreaterThan(0); |
|
||||||
65 | |
|
||||||
66 | // Extract timing |
|
||||||
67 | const timingLogs = consoleLogs.filter(log => log.includes('Visual properties updated in')); |
|
||||||
68 | if (timingLogs.length > 0) { |
|
||||||
69 | const match = timingLogs[0].match(/(\d+\.\d+)ms/); |
|
||||||
70 | if (match) { |
|
||||||
71 | const updateTime = parseFloat(match[1]); |
|
||||||
72 | expect(updateTime).toBeLessThan(PERFORMANCE_TARGETS.visualUpdate); |
|
||||||
73 | console.log(`Star toggle update time: ${updateTime}ms`); |
|
||||||
74 | } |
|
||||||
75 | } |
|
||||||
76 | }); |
|
||||||
77 | |
|
||||||
78 | test('tag visibility toggle uses visual update path', async ({ page }) => { |
|
||||||
79 | // Enable settings and tag anchors |
|
||||||
80 | const settings = page.locator('.leather-legend').nth(1); |
|
||||||
81 | const settingsToggle = settings.locator('button').first(); |
|
||||||
82 | await settingsToggle.click(); |
|
||||||
83 | |
|
||||||
84 | // Enable tag anchors |
|
||||||
85 | const visualSettingsHeader = settings.locator('.settings-section-header').filter({ hasText: 'Visual Settings' }); |
|
||||||
86 | await visualSettingsHeader.click(); |
|
||||||
87 | |
|
||||||
88 | const tagAnchorsToggle = settings.locator('label').filter({ hasText: 'Show Tag Anchors' }).locator('input[type="checkbox"]'); |
|
||||||
89 | await tagAnchorsToggle.click(); |
|
||||||
90 | |
|
||||||
91 | // Wait for tags to appear |
|
||||||
92 | await page.waitForTimeout(1000); |
|
||||||
93 | |
|
||||||
94 | const legend = page.locator('.leather-legend').first(); |
|
||||||
95 | const tagSection = legend.locator('.legend-section').filter({ hasText: 'Active Tag Anchors' }); |
|
||||||
96 | |
|
||||||
97 | if (await tagSection.count() > 0) { |
|
||||||
98 | // Expand tag section if needed |
|
||||||
99 | const tagHeader = tagSection.locator('.legend-section-header'); |
|
||||||
100 | const tagGrid = tagSection.locator('.tag-grid'); |
|
||||||
101 | if (!(await tagGrid.isVisible())) { |
|
||||||
102 | await tagHeader.click(); |
|
||||||
103 | } |
|
||||||
104 | |
|
||||||
105 | // Clear logs |
|
||||||
106 | consoleLogs.length = 0; |
|
||||||
107 | |
|
||||||
108 | // Toggle first tag |
|
||||||
109 | const firstTag = tagGrid.locator('.tag-grid-item').first(); |
|
||||||
110 | await firstTag.click(); |
|
||||||
111 | |
|
||||||
112 | // Wait for update |
|
||||||
113 | await page.waitForTimeout(100); |
|
||||||
114 | |
|
||||||
115 | // Check for visual update |
|
||||||
116 | const updateLogs = consoleLogs.filter(log => log.includes('Update type detected')); |
|
||||||
117 | expect(updateLogs.length).toBeGreaterThan(0); |
|
||||||
118 | |
|
||||||
119 | const lastUpdateLog = updateLogs[updateLogs.length - 1]; |
|
||||||
120 | expect(lastUpdateLog).toContain('kind: "visual"'); |
|
||||||
121 | expect(lastUpdateLog).toContain('disabledCount'); |
|
||||||
122 | |
|
||||||
123 | // Check timing |
|
||||||
124 | const timingLogs = consoleLogs.filter(log => log.includes('Visual properties updated in')); |
|
||||||
125 | if (timingLogs.length > 0) { |
|
||||||
126 | const match = timingLogs[0].match(/(\d+\.\d+)ms/); |
|
||||||
127 | if (match) { |
|
||||||
128 | const updateTime = parseFloat(match[1]); |
|
||||||
129 | expect(updateTime).toBeLessThan(PERFORMANCE_TARGETS.visualUpdate); |
|
||||||
130 | console.log(`Tag toggle update time: ${updateTime}ms`); |
|
||||||
``` |
|
||||||
@ -1,150 +0,0 @@ |
|||||||
# Test info |
|
||||||
|
|
||||||
- Name: Shallow Copy POC Performance Validation >> star visualization toggle uses visual update path |
|
||||||
- Location: /home/user/Documents/Programming/gc-alexandria/tests/e2e/poc-performance-validation.pw.spec.ts:34:3 |
|
||||||
|
|
||||||
# Error details |
|
||||||
|
|
||||||
``` |
|
||||||
TimeoutError: page.waitForSelector: Timeout 10000ms exceeded. |
|
||||||
Call log: |
|
||||||
- waiting for locator('.network-svg') to be visible |
|
||||||
|
|
||||||
at /home/user/Documents/Programming/gc-alexandria/tests/e2e/poc-performance-validation.pw.spec.ts:30:16 |
|
||||||
``` |
|
||||||
|
|
||||||
# Test source |
|
||||||
|
|
||||||
```ts |
|
||||||
1 | import { test, expect } from '@playwright/test'; |
|
||||||
2 | |
|
||||||
3 | // Performance thresholds based on POC targets |
|
||||||
4 | const PERFORMANCE_TARGETS = { |
|
||||||
5 | visualUpdate: 50, // <50ms for visual updates |
|
||||||
6 | fullUpdate: 200, // Baseline for full updates |
|
||||||
7 | positionDrift: 5, // Max pixels of position drift |
|
||||||
8 | memoryIncrease: 10 // Max % memory increase per update |
|
||||||
9 | }; |
|
||||||
10 | |
|
||||||
11 | test.describe('Shallow Copy POC Performance Validation', () => { |
|
||||||
12 | // Helper to extract console logs |
|
||||||
13 | const consoleLogs: string[] = []; |
|
||||||
14 | |
|
||||||
15 | test.beforeEach(async ({ page }) => { |
|
||||||
16 | // Clear logs |
|
||||||
17 | consoleLogs.length = 0; |
|
||||||
18 | |
|
||||||
19 | // Capture console logs |
|
||||||
20 | page.on('console', msg => { |
|
||||||
21 | if (msg.type() === 'log' && msg.text().includes('[EventNetwork]')) { |
|
||||||
22 | consoleLogs.push(msg.text()); |
|
||||||
23 | } |
|
||||||
24 | }); |
|
||||||
25 | |
|
||||||
26 | // Navigate to visualization page |
|
||||||
27 | await page.goto('http://localhost:5175/visualize'); |
|
||||||
28 | |
|
||||||
29 | // Wait for initial load |
|
||||||
> 30 | await page.waitForSelector('.network-svg', { timeout: 10000 }); |
|
||||||
| ^ TimeoutError: page.waitForSelector: Timeout 10000ms exceeded. |
|
||||||
31 | await page.waitForTimeout(2000); // Allow graph to stabilize |
|
||||||
32 | }); |
|
||||||
33 | |
|
||||||
34 | test('star visualization toggle uses visual update path', async ({ page }) => { |
|
||||||
35 | // Enable settings panel |
|
||||||
36 | const settings = page.locator('.leather-legend').nth(1); |
|
||||||
37 | const settingsToggle = settings.locator('button').first(); |
|
||||||
38 | await settingsToggle.click(); |
|
||||||
39 | |
|
||||||
40 | // Ensure visual settings section is expanded |
|
||||||
41 | const visualSettingsHeader = settings.locator('.settings-section-header').filter({ hasText: 'Visual Settings' }); |
|
||||||
42 | await visualSettingsHeader.click(); |
|
||||||
43 | |
|
||||||
44 | // Clear previous logs |
|
||||||
45 | consoleLogs.length = 0; |
|
||||||
46 | |
|
||||||
47 | // Toggle star visualization |
|
||||||
48 | const starToggle = settings.locator('label').filter({ hasText: 'Star Network View' }).locator('input[type="checkbox"]'); |
|
||||||
49 | await starToggle.click(); |
|
||||||
50 | |
|
||||||
51 | // Wait for update |
|
||||||
52 | await page.waitForTimeout(100); |
|
||||||
53 | |
|
||||||
54 | // Check logs for update type |
|
||||||
55 | const updateLogs = consoleLogs.filter(log => log.includes('Update type detected')); |
|
||||||
56 | expect(updateLogs.length).toBeGreaterThan(0); |
|
||||||
57 | |
|
||||||
58 | const lastUpdateLog = updateLogs[updateLogs.length - 1]; |
|
||||||
59 | expect(lastUpdateLog).toContain('kind: "visual"'); |
|
||||||
60 | expect(lastUpdateLog).toContain('star'); |
|
||||||
61 | |
|
||||||
62 | // Check for visual properties update |
|
||||||
63 | const visualUpdateLogs = consoleLogs.filter(log => log.includes('updateVisualProperties called')); |
|
||||||
64 | expect(visualUpdateLogs.length).toBeGreaterThan(0); |
|
||||||
65 | |
|
||||||
66 | // Extract timing |
|
||||||
67 | const timingLogs = consoleLogs.filter(log => log.includes('Visual properties updated in')); |
|
||||||
68 | if (timingLogs.length > 0) { |
|
||||||
69 | const match = timingLogs[0].match(/(\d+\.\d+)ms/); |
|
||||||
70 | if (match) { |
|
||||||
71 | const updateTime = parseFloat(match[1]); |
|
||||||
72 | expect(updateTime).toBeLessThan(PERFORMANCE_TARGETS.visualUpdate); |
|
||||||
73 | console.log(`Star toggle update time: ${updateTime}ms`); |
|
||||||
74 | } |
|
||||||
75 | } |
|
||||||
76 | }); |
|
||||||
77 | |
|
||||||
78 | test('tag visibility toggle uses visual update path', async ({ page }) => { |
|
||||||
79 | // Enable settings and tag anchors |
|
||||||
80 | const settings = page.locator('.leather-legend').nth(1); |
|
||||||
81 | const settingsToggle = settings.locator('button').first(); |
|
||||||
82 | await settingsToggle.click(); |
|
||||||
83 | |
|
||||||
84 | // Enable tag anchors |
|
||||||
85 | const visualSettingsHeader = settings.locator('.settings-section-header').filter({ hasText: 'Visual Settings' }); |
|
||||||
86 | await visualSettingsHeader.click(); |
|
||||||
87 | |
|
||||||
88 | const tagAnchorsToggle = settings.locator('label').filter({ hasText: 'Show Tag Anchors' }).locator('input[type="checkbox"]'); |
|
||||||
89 | await tagAnchorsToggle.click(); |
|
||||||
90 | |
|
||||||
91 | // Wait for tags to appear |
|
||||||
92 | await page.waitForTimeout(1000); |
|
||||||
93 | |
|
||||||
94 | const legend = page.locator('.leather-legend').first(); |
|
||||||
95 | const tagSection = legend.locator('.legend-section').filter({ hasText: 'Active Tag Anchors' }); |
|
||||||
96 | |
|
||||||
97 | if (await tagSection.count() > 0) { |
|
||||||
98 | // Expand tag section if needed |
|
||||||
99 | const tagHeader = tagSection.locator('.legend-section-header'); |
|
||||||
100 | const tagGrid = tagSection.locator('.tag-grid'); |
|
||||||
101 | if (!(await tagGrid.isVisible())) { |
|
||||||
102 | await tagHeader.click(); |
|
||||||
103 | } |
|
||||||
104 | |
|
||||||
105 | // Clear logs |
|
||||||
106 | consoleLogs.length = 0; |
|
||||||
107 | |
|
||||||
108 | // Toggle first tag |
|
||||||
109 | const firstTag = tagGrid.locator('.tag-grid-item').first(); |
|
||||||
110 | await firstTag.click(); |
|
||||||
111 | |
|
||||||
112 | // Wait for update |
|
||||||
113 | await page.waitForTimeout(100); |
|
||||||
114 | |
|
||||||
115 | // Check for visual update |
|
||||||
116 | const updateLogs = consoleLogs.filter(log => log.includes('Update type detected')); |
|
||||||
117 | expect(updateLogs.length).toBeGreaterThan(0); |
|
||||||
118 | |
|
||||||
119 | const lastUpdateLog = updateLogs[updateLogs.length - 1]; |
|
||||||
120 | expect(lastUpdateLog).toContain('kind: "visual"'); |
|
||||||
121 | expect(lastUpdateLog).toContain('disabledCount'); |
|
||||||
122 | |
|
||||||
123 | // Check timing |
|
||||||
124 | const timingLogs = consoleLogs.filter(log => log.includes('Visual properties updated in')); |
|
||||||
125 | if (timingLogs.length > 0) { |
|
||||||
126 | const match = timingLogs[0].match(/(\d+\.\d+)ms/); |
|
||||||
127 | if (match) { |
|
||||||
128 | const updateTime = parseFloat(match[1]); |
|
||||||
129 | expect(updateTime).toBeLessThan(PERFORMANCE_TARGETS.visualUpdate); |
|
||||||
130 | console.log(`Tag toggle update time: ${updateTime}ms`); |
|
||||||
``` |
|
||||||
@ -1,150 +0,0 @@ |
|||||||
# Test info |
|
||||||
|
|
||||||
- Name: Shallow Copy POC Performance Validation >> memory stability during visual updates |
|
||||||
- Location: /home/user/Documents/Programming/gc-alexandria/tests/e2e/poc-performance-validation.pw.spec.ts:264:3 |
|
||||||
|
|
||||||
# Error details |
|
||||||
|
|
||||||
``` |
|
||||||
TimeoutError: page.waitForSelector: Timeout 10000ms exceeded. |
|
||||||
Call log: |
|
||||||
- waiting for locator('.network-svg') to be visible |
|
||||||
|
|
||||||
at /home/user/Documents/Programming/gc-alexandria/tests/e2e/poc-performance-validation.pw.spec.ts:30:16 |
|
||||||
``` |
|
||||||
|
|
||||||
# Test source |
|
||||||
|
|
||||||
```ts |
|
||||||
1 | import { test, expect } from '@playwright/test'; |
|
||||||
2 | |
|
||||||
3 | // Performance thresholds based on POC targets |
|
||||||
4 | const PERFORMANCE_TARGETS = { |
|
||||||
5 | visualUpdate: 50, // <50ms for visual updates |
|
||||||
6 | fullUpdate: 200, // Baseline for full updates |
|
||||||
7 | positionDrift: 5, // Max pixels of position drift |
|
||||||
8 | memoryIncrease: 10 // Max % memory increase per update |
|
||||||
9 | }; |
|
||||||
10 | |
|
||||||
11 | test.describe('Shallow Copy POC Performance Validation', () => { |
|
||||||
12 | // Helper to extract console logs |
|
||||||
13 | const consoleLogs: string[] = []; |
|
||||||
14 | |
|
||||||
15 | test.beforeEach(async ({ page }) => { |
|
||||||
16 | // Clear logs |
|
||||||
17 | consoleLogs.length = 0; |
|
||||||
18 | |
|
||||||
19 | // Capture console logs |
|
||||||
20 | page.on('console', msg => { |
|
||||||
21 | if (msg.type() === 'log' && msg.text().includes('[EventNetwork]')) { |
|
||||||
22 | consoleLogs.push(msg.text()); |
|
||||||
23 | } |
|
||||||
24 | }); |
|
||||||
25 | |
|
||||||
26 | // Navigate to visualization page |
|
||||||
27 | await page.goto('http://localhost:5175/visualize'); |
|
||||||
28 | |
|
||||||
29 | // Wait for initial load |
|
||||||
> 30 | await page.waitForSelector('.network-svg', { timeout: 10000 }); |
|
||||||
| ^ TimeoutError: page.waitForSelector: Timeout 10000ms exceeded. |
|
||||||
31 | await page.waitForTimeout(2000); // Allow graph to stabilize |
|
||||||
32 | }); |
|
||||||
33 | |
|
||||||
34 | test('star visualization toggle uses visual update path', async ({ page }) => { |
|
||||||
35 | // Enable settings panel |
|
||||||
36 | const settings = page.locator('.leather-legend').nth(1); |
|
||||||
37 | const settingsToggle = settings.locator('button').first(); |
|
||||||
38 | await settingsToggle.click(); |
|
||||||
39 | |
|
||||||
40 | // Ensure visual settings section is expanded |
|
||||||
41 | const visualSettingsHeader = settings.locator('.settings-section-header').filter({ hasText: 'Visual Settings' }); |
|
||||||
42 | await visualSettingsHeader.click(); |
|
||||||
43 | |
|
||||||
44 | // Clear previous logs |
|
||||||
45 | consoleLogs.length = 0; |
|
||||||
46 | |
|
||||||
47 | // Toggle star visualization |
|
||||||
48 | const starToggle = settings.locator('label').filter({ hasText: 'Star Network View' }).locator('input[type="checkbox"]'); |
|
||||||
49 | await starToggle.click(); |
|
||||||
50 | |
|
||||||
51 | // Wait for update |
|
||||||
52 | await page.waitForTimeout(100); |
|
||||||
53 | |
|
||||||
54 | // Check logs for update type |
|
||||||
55 | const updateLogs = consoleLogs.filter(log => log.includes('Update type detected')); |
|
||||||
56 | expect(updateLogs.length).toBeGreaterThan(0); |
|
||||||
57 | |
|
||||||
58 | const lastUpdateLog = updateLogs[updateLogs.length - 1]; |
|
||||||
59 | expect(lastUpdateLog).toContain('kind: "visual"'); |
|
||||||
60 | expect(lastUpdateLog).toContain('star'); |
|
||||||
61 | |
|
||||||
62 | // Check for visual properties update |
|
||||||
63 | const visualUpdateLogs = consoleLogs.filter(log => log.includes('updateVisualProperties called')); |
|
||||||
64 | expect(visualUpdateLogs.length).toBeGreaterThan(0); |
|
||||||
65 | |
|
||||||
66 | // Extract timing |
|
||||||
67 | const timingLogs = consoleLogs.filter(log => log.includes('Visual properties updated in')); |
|
||||||
68 | if (timingLogs.length > 0) { |
|
||||||
69 | const match = timingLogs[0].match(/(\d+\.\d+)ms/); |
|
||||||
70 | if (match) { |
|
||||||
71 | const updateTime = parseFloat(match[1]); |
|
||||||
72 | expect(updateTime).toBeLessThan(PERFORMANCE_TARGETS.visualUpdate); |
|
||||||
73 | console.log(`Star toggle update time: ${updateTime}ms`); |
|
||||||
74 | } |
|
||||||
75 | } |
|
||||||
76 | }); |
|
||||||
77 | |
|
||||||
78 | test('tag visibility toggle uses visual update path', async ({ page }) => { |
|
||||||
79 | // Enable settings and tag anchors |
|
||||||
80 | const settings = page.locator('.leather-legend').nth(1); |
|
||||||
81 | const settingsToggle = settings.locator('button').first(); |
|
||||||
82 | await settingsToggle.click(); |
|
||||||
83 | |
|
||||||
84 | // Enable tag anchors |
|
||||||
85 | const visualSettingsHeader = settings.locator('.settings-section-header').filter({ hasText: 'Visual Settings' }); |
|
||||||
86 | await visualSettingsHeader.click(); |
|
||||||
87 | |
|
||||||
88 | const tagAnchorsToggle = settings.locator('label').filter({ hasText: 'Show Tag Anchors' }).locator('input[type="checkbox"]'); |
|
||||||
89 | await tagAnchorsToggle.click(); |
|
||||||
90 | |
|
||||||
91 | // Wait for tags to appear |
|
||||||
92 | await page.waitForTimeout(1000); |
|
||||||
93 | |
|
||||||
94 | const legend = page.locator('.leather-legend').first(); |
|
||||||
95 | const tagSection = legend.locator('.legend-section').filter({ hasText: 'Active Tag Anchors' }); |
|
||||||
96 | |
|
||||||
97 | if (await tagSection.count() > 0) { |
|
||||||
98 | // Expand tag section if needed |
|
||||||
99 | const tagHeader = tagSection.locator('.legend-section-header'); |
|
||||||
100 | const tagGrid = tagSection.locator('.tag-grid'); |
|
||||||
101 | if (!(await tagGrid.isVisible())) { |
|
||||||
102 | await tagHeader.click(); |
|
||||||
103 | } |
|
||||||
104 | |
|
||||||
105 | // Clear logs |
|
||||||
106 | consoleLogs.length = 0; |
|
||||||
107 | |
|
||||||
108 | // Toggle first tag |
|
||||||
109 | const firstTag = tagGrid.locator('.tag-grid-item').first(); |
|
||||||
110 | await firstTag.click(); |
|
||||||
111 | |
|
||||||
112 | // Wait for update |
|
||||||
113 | await page.waitForTimeout(100); |
|
||||||
114 | |
|
||||||
115 | // Check for visual update |
|
||||||
116 | const updateLogs = consoleLogs.filter(log => log.includes('Update type detected')); |
|
||||||
117 | expect(updateLogs.length).toBeGreaterThan(0); |
|
||||||
118 | |
|
||||||
119 | const lastUpdateLog = updateLogs[updateLogs.length - 1]; |
|
||||||
120 | expect(lastUpdateLog).toContain('kind: "visual"'); |
|
||||||
121 | expect(lastUpdateLog).toContain('disabledCount'); |
|
||||||
122 | |
|
||||||
123 | // Check timing |
|
||||||
124 | const timingLogs = consoleLogs.filter(log => log.includes('Visual properties updated in')); |
|
||||||
125 | if (timingLogs.length > 0) { |
|
||||||
126 | const match = timingLogs[0].match(/(\d+\.\d+)ms/); |
|
||||||
127 | if (match) { |
|
||||||
128 | const updateTime = parseFloat(match[1]); |
|
||||||
129 | expect(updateTime).toBeLessThan(PERFORMANCE_TARGETS.visualUpdate); |
|
||||||
130 | console.log(`Tag toggle update time: ${updateTime}ms`); |
|
||||||
``` |
|
||||||
@ -1,382 +0,0 @@ |
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'; |
|
||||||
import { writable, get } from 'svelte/store'; |
|
||||||
import { displayLimits } from '$lib/stores/displayLimits'; |
|
||||||
import { visualizationConfig } from '$lib/stores/visualizationConfig'; |
|
||||||
import type { NDKEvent } from '@nostr-dev-kit/ndk'; |
|
||||||
|
|
||||||
// Mock NDK Event for testing
|
|
||||||
function createMockEvent(kind: number, id: string): NDKEvent { |
|
||||||
return { |
|
||||||
id, |
|
||||||
kind, |
|
||||||
pubkey: 'mock-pubkey', |
|
||||||
created_at: Date.now() / 1000, |
|
||||||
content: `Mock content for ${id}`, |
|
||||||
tags: [] |
|
||||||
} as NDKEvent; |
|
||||||
} |
|
||||||
|
|
||||||
describe('Display Limits Integration', () => { |
|
||||||
beforeEach(() => { |
|
||||||
// Reset stores to default values
|
|
||||||
displayLimits.set({ |
|
||||||
max30040: -1, |
|
||||||
max30041: -1, |
|
||||||
fetchIfNotFound: false |
|
||||||
}); |
|
||||||
|
|
||||||
visualizationConfig.setMaxPublicationIndices(-1); |
|
||||||
visualizationConfig.setMaxEventsPerIndex(-1); |
|
||||||
}); |
|
||||||
|
|
||||||
describe('Event Filtering with Limits', () => { |
|
||||||
it('should filter events when limits are set', () => { |
|
||||||
const events = [ |
|
||||||
createMockEvent(30040, 'index1'), |
|
||||||
createMockEvent(30040, 'index2'), |
|
||||||
createMockEvent(30040, 'index3'), |
|
||||||
createMockEvent(30041, 'content1'), |
|
||||||
createMockEvent(30041, 'content2'), |
|
||||||
createMockEvent(30041, 'content3'), |
|
||||||
createMockEvent(30041, 'content4') |
|
||||||
]; |
|
||||||
|
|
||||||
// Apply display limits
|
|
||||||
const limits = get(displayLimits); |
|
||||||
limits.max30040 = 2; |
|
||||||
limits.max30041 = 3; |
|
||||||
|
|
||||||
// Filter function
|
|
||||||
const filterByLimits = (events: NDKEvent[], limits: any) => { |
|
||||||
const kindCounts = new Map<number, number>(); |
|
||||||
|
|
||||||
return events.filter(event => { |
|
||||||
const count = kindCounts.get(event.kind) || 0; |
|
||||||
|
|
||||||
if (event.kind === 30040 && limits.max30040 !== -1 && count >= limits.max30040) { |
|
||||||
return false; |
|
||||||
} |
|
||||||
if (event.kind === 30041 && limits.max30041 !== -1 && count >= limits.max30041) { |
|
||||||
return false; |
|
||||||
} |
|
||||||
|
|
||||||
kindCounts.set(event.kind, count + 1); |
|
||||||
return true; |
|
||||||
}); |
|
||||||
}; |
|
||||||
|
|
||||||
const filtered = filterByLimits(events, limits); |
|
||||||
|
|
||||||
// Should have 2 index events and 3 content events
|
|
||||||
expect(filtered.filter(e => e.kind === 30040)).toHaveLength(2); |
|
||||||
expect(filtered.filter(e => e.kind === 30041)).toHaveLength(3); |
|
||||||
expect(filtered).toHaveLength(5); |
|
||||||
}); |
|
||||||
|
|
||||||
it('should respect unlimited (-1) values', () => { |
|
||||||
const events = Array.from({ length: 100 }, (_, i) =>
|
|
||||||
createMockEvent(i % 2 === 0 ? 30040 : 30041, `event${i}`) |
|
||||||
); |
|
||||||
|
|
||||||
// Set one limit, leave other unlimited
|
|
||||||
displayLimits.update(limits => ({ |
|
||||||
...limits, |
|
||||||
max30040: 10, |
|
||||||
max30041: -1 |
|
||||||
})); |
|
||||||
|
|
||||||
const limits = get(displayLimits); |
|
||||||
const filtered = events.filter((event, index) => { |
|
||||||
if (event.kind === 30040) { |
|
||||||
const count = events.slice(0, index).filter(e => e.kind === 30040).length; |
|
||||||
return limits.max30040 === -1 || count < limits.max30040; |
|
||||||
} |
|
||||||
return true; // No limit on 30041
|
|
||||||
}); |
|
||||||
|
|
||||||
// Should have exactly 10 kind 30040 events
|
|
||||||
expect(filtered.filter(e => e.kind === 30040)).toHaveLength(10); |
|
||||||
// Should have all 50 kind 30041 events
|
|
||||||
expect(filtered.filter(e => e.kind === 30041)).toHaveLength(50); |
|
||||||
}); |
|
||||||
}); |
|
||||||
|
|
||||||
describe('Publication Index Limits', () => { |
|
||||||
it('should limit publication indices separately from content', () => { |
|
||||||
const config = get(visualizationConfig); |
|
||||||
|
|
||||||
// Create publication structure
|
|
||||||
const publications = [ |
|
||||||
{
|
|
||||||
index: createMockEvent(30040, 'pub1'), |
|
||||||
content: [ |
|
||||||
createMockEvent(30041, 'pub1-content1'), |
|
||||||
createMockEvent(30041, 'pub1-content2'), |
|
||||||
createMockEvent(30041, 'pub1-content3') |
|
||||||
] |
|
||||||
}, |
|
||||||
{ |
|
||||||
index: createMockEvent(30040, 'pub2'), |
|
||||||
content: [ |
|
||||||
createMockEvent(30041, 'pub2-content1'), |
|
||||||
createMockEvent(30041, 'pub2-content2') |
|
||||||
] |
|
||||||
}, |
|
||||||
{ |
|
||||||
index: createMockEvent(30040, 'pub3'), |
|
||||||
content: [ |
|
||||||
createMockEvent(30041, 'pub3-content1') |
|
||||||
] |
|
||||||
} |
|
||||||
]; |
|
||||||
|
|
||||||
// Set limits
|
|
||||||
visualizationConfig.setMaxPublicationIndices(2); |
|
||||||
visualizationConfig.setMaxEventsPerIndex(2); |
|
||||||
|
|
||||||
// Apply limits
|
|
||||||
const limitedPubs = publications |
|
||||||
.slice(0, get(visualizationConfig).maxPublicationIndices === -1
|
|
||||||
? publications.length
|
|
||||||
: get(visualizationConfig).maxPublicationIndices) |
|
||||||
.map(pub => ({ |
|
||||||
index: pub.index, |
|
||||||
content: pub.content.slice(0, get(visualizationConfig).maxEventsPerIndex === -1 |
|
||||||
? pub.content.length |
|
||||||
: get(visualizationConfig).maxEventsPerIndex) |
|
||||||
})); |
|
||||||
|
|
||||||
// Should have 2 publications
|
|
||||||
expect(limitedPubs).toHaveLength(2); |
|
||||||
// First pub should have 2 content events
|
|
||||||
expect(limitedPubs[0].content).toHaveLength(2); |
|
||||||
// Second pub should have 2 content events
|
|
||||||
expect(limitedPubs[1].content).toHaveLength(2); |
|
||||||
}); |
|
||||||
|
|
||||||
it('should handle per-index limits correctly', () => { |
|
||||||
visualizationConfig.setMaxEventsPerIndex(3); |
|
||||||
const maxPerIndex = get(visualizationConfig).maxEventsPerIndex; |
|
||||||
|
|
||||||
const indexEvents = new Map<string, NDKEvent[]>(); |
|
||||||
|
|
||||||
// Simulate grouping events by index
|
|
||||||
const events = [ |
|
||||||
{ indexId: 'idx1', event: createMockEvent(30041, 'c1') }, |
|
||||||
{ indexId: 'idx1', event: createMockEvent(30041, 'c2') }, |
|
||||||
{ indexId: 'idx1', event: createMockEvent(30041, 'c3') }, |
|
||||||
{ indexId: 'idx1', event: createMockEvent(30041, 'c4') }, // Should be filtered
|
|
||||||
{ indexId: 'idx2', event: createMockEvent(30041, 'c5') }, |
|
||||||
{ indexId: 'idx2', event: createMockEvent(30041, 'c6') } |
|
||||||
]; |
|
||||||
|
|
||||||
events.forEach(({ indexId, event }) => { |
|
||||||
const current = indexEvents.get(indexId) || []; |
|
||||||
if (maxPerIndex === -1 || current.length < maxPerIndex) { |
|
||||||
indexEvents.set(indexId, [...current, event]); |
|
||||||
} |
|
||||||
}); |
|
||||||
|
|
||||||
// idx1 should have 3 events
|
|
||||||
expect(indexEvents.get('idx1')).toHaveLength(3); |
|
||||||
// idx2 should have 2 events
|
|
||||||
expect(indexEvents.get('idx2')).toHaveLength(2); |
|
||||||
}); |
|
||||||
}); |
|
||||||
|
|
||||||
describe('Fetch If Not Found Feature', () => { |
|
||||||
it('should identify missing referenced events', () => { |
|
||||||
const availableEvents = new Set(['event1', 'event2', 'event3']); |
|
||||||
const referencedEvents = ['event1', 'event2', 'event4', 'event5']; |
|
||||||
|
|
||||||
displayLimits.update(limits => ({ |
|
||||||
...limits, |
|
||||||
fetchIfNotFound: true |
|
||||||
})); |
|
||||||
|
|
||||||
const limits = get(displayLimits); |
|
||||||
const missingEvents = limits.fetchIfNotFound |
|
||||||
? referencedEvents.filter(id => !availableEvents.has(id)) |
|
||||||
: []; |
|
||||||
|
|
||||||
expect(missingEvents).toEqual(['event4', 'event5']); |
|
||||||
}); |
|
||||||
|
|
||||||
it('should not fetch when fetchIfNotFound is false', () => { |
|
||||||
const availableEvents = new Set(['event1']); |
|
||||||
const referencedEvents = ['event1', 'event2', 'event3']; |
|
||||||
|
|
||||||
displayLimits.update(limits => ({ |
|
||||||
...limits, |
|
||||||
fetchIfNotFound: false |
|
||||||
})); |
|
||||||
|
|
||||||
const limits = get(displayLimits); |
|
||||||
const shouldFetch = limits.fetchIfNotFound &&
|
|
||||||
referencedEvents.some(id => !availableEvents.has(id)); |
|
||||||
|
|
||||||
expect(shouldFetch).toBe(false); |
|
||||||
}); |
|
||||||
|
|
||||||
it('should batch fetch requests for missing events', () => { |
|
||||||
const fetchQueue: string[] = []; |
|
||||||
const addToFetchQueue = (ids: string[]) => { |
|
||||||
fetchQueue.push(...ids); |
|
||||||
}; |
|
||||||
|
|
||||||
// Simulate finding missing events
|
|
||||||
const missingEvents = ['event10', 'event11', 'event12']; |
|
||||||
|
|
||||||
displayLimits.update(limits => ({ |
|
||||||
...limits, |
|
||||||
fetchIfNotFound: true |
|
||||||
})); |
|
||||||
|
|
||||||
if (get(displayLimits).fetchIfNotFound) { |
|
||||||
addToFetchQueue(missingEvents); |
|
||||||
} |
|
||||||
|
|
||||||
expect(fetchQueue).toEqual(missingEvents); |
|
||||||
expect(fetchQueue).toHaveLength(3); |
|
||||||
}); |
|
||||||
}); |
|
||||||
|
|
||||||
describe('Integration with Visualization Updates', () => { |
|
||||||
it('should trigger appropriate updates when limits change', () => { |
|
||||||
const updateTypes: string[] = []; |
|
||||||
const mockUpdate = (type: string) => updateTypes.push(type); |
|
||||||
|
|
||||||
// Change publication index limit
|
|
||||||
const oldConfig = get(visualizationConfig); |
|
||||||
visualizationConfig.setMaxPublicationIndices(5); |
|
||||||
|
|
||||||
if (get(visualizationConfig).maxPublicationIndices !== oldConfig.maxPublicationIndices) { |
|
||||||
mockUpdate('filter-indices'); |
|
||||||
} |
|
||||||
|
|
||||||
// Change events per index limit
|
|
||||||
visualizationConfig.setMaxEventsPerIndex(10); |
|
||||||
mockUpdate('filter-content'); |
|
||||||
|
|
||||||
// Toggle fetchIfNotFound
|
|
||||||
displayLimits.update(limits => ({ |
|
||||||
...limits, |
|
||||||
fetchIfNotFound: true |
|
||||||
})); |
|
||||||
mockUpdate('check-missing'); |
|
||||||
|
|
||||||
expect(updateTypes).toContain('filter-indices'); |
|
||||||
expect(updateTypes).toContain('filter-content'); |
|
||||||
expect(updateTypes).toContain('check-missing'); |
|
||||||
}); |
|
||||||
|
|
||||||
it('should preserve existing graph structure when applying limits', () => { |
|
||||||
const graph = { |
|
||||||
nodes: [ |
|
||||||
{ id: 'idx1', type: 'index' }, |
|
||||||
{ id: 'c1', type: 'content' }, |
|
||||||
{ id: 'c2', type: 'content' }, |
|
||||||
{ id: 'c3', type: 'content' } |
|
||||||
], |
|
||||||
links: [ |
|
||||||
{ source: 'idx1', target: 'c1' }, |
|
||||||
{ source: 'idx1', target: 'c2' }, |
|
||||||
{ source: 'idx1', target: 'c3' } |
|
||||||
] |
|
||||||
}; |
|
||||||
|
|
||||||
// Apply content limit
|
|
||||||
visualizationConfig.setMaxEventsPerIndex(2); |
|
||||||
const limit = get(visualizationConfig).maxEventsPerIndex; |
|
||||||
|
|
||||||
// Filter nodes and links based on limit
|
|
||||||
const contentNodes = graph.nodes.filter(n => n.type === 'content'); |
|
||||||
const limitedContentIds = contentNodes.slice(0, limit).map(n => n.id); |
|
||||||
|
|
||||||
const filteredGraph = { |
|
||||||
nodes: graph.nodes.filter(n =>
|
|
||||||
n.type !== 'content' || limitedContentIds.includes(n.id) |
|
||||||
), |
|
||||||
links: graph.links.filter(l =>
|
|
||||||
limitedContentIds.includes(l.target) |
|
||||||
) |
|
||||||
}; |
|
||||||
|
|
||||||
expect(filteredGraph.nodes).toHaveLength(3); // 1 index + 2 content
|
|
||||||
expect(filteredGraph.links).toHaveLength(2); |
|
||||||
}); |
|
||||||
}); |
|
||||||
|
|
||||||
describe('Performance Considerations', () => { |
|
||||||
it('should handle large event sets efficiently', () => { |
|
||||||
const largeEventSet = Array.from({ length: 10000 }, (_, i) =>
|
|
||||||
createMockEvent(i % 2 === 0 ? 30040 : 30041, `event${i}`) |
|
||||||
); |
|
||||||
|
|
||||||
const startTime = performance.now(); |
|
||||||
|
|
||||||
// Apply strict limits
|
|
||||||
displayLimits.update(limits => ({ |
|
||||||
...limits, |
|
||||||
max30040: 50, |
|
||||||
max30041: 100 |
|
||||||
})); |
|
||||||
|
|
||||||
const limits = get(displayLimits); |
|
||||||
const kindCounts = new Map<number, number>(); |
|
||||||
|
|
||||||
const filtered = largeEventSet.filter(event => { |
|
||||||
const count = kindCounts.get(event.kind) || 0; |
|
||||||
|
|
||||||
if (event.kind === 30040 && limits.max30040 !== -1 && count >= limits.max30040) { |
|
||||||
return false; |
|
||||||
} |
|
||||||
if (event.kind === 30041 && limits.max30041 !== -1 && count >= limits.max30041) { |
|
||||||
return false; |
|
||||||
} |
|
||||||
|
|
||||||
kindCounts.set(event.kind, count + 1); |
|
||||||
return true; |
|
||||||
}); |
|
||||||
|
|
||||||
const endTime = performance.now(); |
|
||||||
const filterTime = endTime - startTime; |
|
||||||
|
|
||||||
// Should complete quickly even with large sets
|
|
||||||
expect(filterTime).toBeLessThan(100); // 100ms threshold
|
|
||||||
expect(filtered).toHaveLength(150); // 50 + 100
|
|
||||||
}); |
|
||||||
|
|
||||||
it('should cache limit calculations when possible', () => { |
|
||||||
let calculationCount = 0; |
|
||||||
|
|
||||||
const getCachedLimits = (() => { |
|
||||||
let cache: any = null; |
|
||||||
let cacheKey: string = ''; |
|
||||||
|
|
||||||
return (limits: any) => { |
|
||||||
const key = JSON.stringify(limits); |
|
||||||
if (key !== cacheKey) { |
|
||||||
calculationCount++; |
|
||||||
cache = { ...limits, calculated: true }; |
|
||||||
cacheKey = key; |
|
||||||
} |
|
||||||
return cache; |
|
||||||
}; |
|
||||||
})(); |
|
||||||
|
|
||||||
// First call - should calculate
|
|
||||||
getCachedLimits(get(displayLimits)); |
|
||||||
expect(calculationCount).toBe(1); |
|
||||||
|
|
||||||
// Same limits - should use cache
|
|
||||||
getCachedLimits(get(displayLimits)); |
|
||||||
expect(calculationCount).toBe(1); |
|
||||||
|
|
||||||
// Change limits - should recalculate
|
|
||||||
displayLimits.update(limits => ({ ...limits, max30040: 10 })); |
|
||||||
getCachedLimits(get(displayLimits)); |
|
||||||
expect(calculationCount).toBe(2); |
|
||||||
}); |
|
||||||
}); |
|
||||||
}); |
|
||||||
@ -1,99 +0,0 @@ |
|||||||
import { describe, it, expect } from 'vitest'; |
|
||||||
import { parseBasicmarkup } from '../../src/lib/utils/markup/basicMarkupParser'; |
|
||||||
import { parseAdvancedmarkup } from '../../src/lib/utils/markup/advancedMarkupParser'; |
|
||||||
import { readFileSync } from 'fs'; |
|
||||||
import { join } from 'path'; |
|
||||||
|
|
||||||
const testFilePath = join(__dirname, './markupTestfile.md'); |
|
||||||
const md = readFileSync(testFilePath, 'utf-8'); |
|
||||||
|
|
||||||
describe('Markup Integration Test', () => { |
|
||||||
it('parses markupTestfile.md with the basic parser', async () => { |
|
||||||
const output = await parseBasicmarkup(md); |
|
||||||
// Headers (should be present as text, not <h1> tags)
|
|
||||||
expect(output).toContain('This is a test'); |
|
||||||
expect(output).toContain('============'); |
|
||||||
expect(output).toContain('### Disclaimer'); |
|
||||||
// Unordered list
|
|
||||||
expect(output).toContain('<ul'); |
|
||||||
expect(output).toContain('but'); |
|
||||||
// Ordered list
|
|
||||||
expect(output).toContain('<ol'); |
|
||||||
expect(output).toContain('first'); |
|
||||||
// Nested lists
|
|
||||||
expect(output).toMatch(/<ul[^>]*>.*<ul[^>]*>/s); |
|
||||||
// Blockquotes
|
|
||||||
expect(output).toContain('<blockquote'); |
|
||||||
expect(output).toContain('This is important information'); |
|
||||||
// Inline code
|
|
||||||
expect(output).toContain('<div class="leather min-h-full w-full flex flex-col items-center">'); |
|
||||||
// Images
|
|
||||||
expect(output).toMatch(/<img[^>]+src="https:\/\/upload\.wikimedia\.org\/wikipedia\/commons\/f\/f1\/Heart_coraz%C3%B3n\.svg"/); |
|
||||||
// Links
|
|
||||||
expect(output).toMatch(/<a[^>]+href="https:\/\/github.com\/nostrability\/nostrability\/issues\/146"/); |
|
||||||
// Hashtags
|
|
||||||
expect(output).toContain('text-primary-600'); |
|
||||||
// Nostr identifiers (should be Alexandria links)
|
|
||||||
expect(output).toContain('./events?id=npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z'); |
|
||||||
// Wikilinks
|
|
||||||
expect(output).toContain('wikilink'); |
|
||||||
// YouTube iframe
|
|
||||||
expect(output).toMatch(/<iframe[^>]+youtube/); |
|
||||||
// Tracking token removal: should not contain utm_, fbclid, or gclid in any link
|
|
||||||
expect(output).not.toMatch(/utm_/); |
|
||||||
expect(output).not.toMatch(/fbclid/); |
|
||||||
expect(output).not.toMatch(/gclid/); |
|
||||||
// Horizontal rule (should be present as --- in basic)
|
|
||||||
expect(output).toContain('---'); |
|
||||||
// Footnote references (should be present as [^1] in basic)
|
|
||||||
expect(output).toContain('[^1]'); |
|
||||||
// Table (should be present as | Syntax | Description | in basic)
|
|
||||||
expect(output).toContain('| Syntax | Description |'); |
|
||||||
}); |
|
||||||
|
|
||||||
it('parses markupTestfile.md with the advanced parser', async () => { |
|
||||||
const output = await parseAdvancedmarkup(md); |
|
||||||
// Headers
|
|
||||||
expect(output).toContain('<h1'); |
|
||||||
expect(output).toContain('<h2'); |
|
||||||
expect(output).toContain('Disclaimer'); |
|
||||||
// Unordered list
|
|
||||||
expect(output).toContain('<ul'); |
|
||||||
expect(output).toContain('but'); |
|
||||||
// Ordered list
|
|
||||||
expect(output).toContain('<ol'); |
|
||||||
expect(output).toContain('first'); |
|
||||||
// Nested lists
|
|
||||||
expect(output).toMatch(/<ul[^>]*>.*<ul[^>]*>/s); |
|
||||||
// Blockquotes
|
|
||||||
expect(output).toContain('<blockquote'); |
|
||||||
expect(output).toContain('This is important information'); |
|
||||||
// Inline code
|
|
||||||
expect(output).toMatch(/<code[^>]*>.*leather min-h-full w-full flex flex-col items-center.*<\/code>/s); |
|
||||||
// Images
|
|
||||||
expect(output).toMatch(/<img[^>]+src="https:\/\/upload\.wikimedia\.org\/wikipedia\/commons\/f\/f1\/Heart_coraz%C3%B3n\.svg"/); |
|
||||||
// Links
|
|
||||||
expect(output).toMatch(/<a[^>]+href="https:\/\/github.com\/nostrability\/nostrability\/issues\/146"/); |
|
||||||
// Hashtags
|
|
||||||
expect(output).toContain('text-primary-600'); |
|
||||||
// Nostr identifiers (should be Alexandria links)
|
|
||||||
expect(output).toContain('./events?id=npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z'); |
|
||||||
// Wikilinks
|
|
||||||
expect(output).toContain('wikilink'); |
|
||||||
// YouTube iframe
|
|
||||||
expect(output).toMatch(/<iframe[^>]+youtube/); |
|
||||||
// Tracking token removal: should not contain utm_, fbclid, or gclid in any link
|
|
||||||
expect(output).not.toMatch(/utm_/); |
|
||||||
expect(output).not.toMatch(/fbclid/); |
|
||||||
expect(output).not.toMatch(/gclid/); |
|
||||||
// Horizontal rule
|
|
||||||
expect(output).toContain('<hr'); |
|
||||||
// Footnote references and section
|
|
||||||
expect(output).toContain('Footnotes'); |
|
||||||
expect(output).toMatch(/<li id=\"fn-1\">/); |
|
||||||
// Table
|
|
||||||
expect(output).toContain('<table'); |
|
||||||
// Code blocks
|
|
||||||
expect(output).toContain('<pre'); |
|
||||||
}); |
|
||||||
});
|
|
||||||
@ -1,244 +0,0 @@ |
|||||||
This is a test |
|
||||||
============ |
|
||||||
|
|
||||||
### Disclaimer |
|
||||||
|
|
||||||
It is _only_ a test, for __sure__. I just wanted to see if the markup renders correctly on the page, even if I use **two asterisks** for bold text, instead of *one asterisk*.[^1] |
|
||||||
|
|
||||||
# H1 |
|
||||||
## H2 |
|
||||||
### H3 |
|
||||||
#### H4 |
|
||||||
##### H5 |
|
||||||
###### H6 |
|
||||||
|
|
||||||
This file is full of ~errors~ opportunities to ~~mess up the formatting~~ check your markup parser. |
|
||||||
|
|
||||||
You can even learn about [[mirepoix]], [[nkbip-03]], or [[roman catholic church|catholics]] |
|
||||||
|
|
||||||
npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z wrote this. That's the same person as this one with a nostr prefix nostr:npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z and nprofile1qydhwumn8ghj7argv4nx7un9wd6zumn0wd68yvfwvdhk6tcpr3mhxue69uhkx6rjd9ehgurfd3kzumn0wd68yvfwvdhk6tcqyr7jprhgeregx7q2j4fgjmjgy0xfm34l63pqvwyf2acsd9q0mynuzp4qva3. That is a different person from npub1s3ht77dq4zqnya8vjun5jp3p44pr794ru36d0ltxu65chljw8xjqd975wz. |
|
||||||
|
|
||||||
> This is important information |
|
||||||
|
|
||||||
> This is multiple |
|
||||||
> lines of |
|
||||||
> important information |
|
||||||
> with a second[^2] footnote. |
|
||||||
[^2]: This is a "Test" of a longer footnote-reference, placed inline, including some punctuation. 1984. |
|
||||||
|
|
||||||
This is a youtube link |
|
||||||
https://www.youtube.com/watch?v=9aqVxNCpx9s |
|
||||||
|
|
||||||
And here is a link with tracking tokens: |
|
||||||
https://arstechnica.com/science/2019/07/new-data-may-extend-norse-occupancy-in-north-america/?fbclid=IwAR1LOW3BebaMLinfkWFtFpzkLFi48jKNF7P6DV2Ux2r3lnT6Lqj6eiiOZNU |
|
||||||
|
|
||||||
This is an unordered list: |
|
||||||
* but |
|
||||||
* not |
|
||||||
* really |
|
||||||
|
|
||||||
This is an unordered list with nesting: |
|
||||||
* but |
|
||||||
* not |
|
||||||
* really |
|
||||||
* but |
|
||||||
* yes, |
|
||||||
* really |
|
||||||
|
|
||||||
## More testing |
|
||||||
|
|
||||||
An ordered list: |
|
||||||
1. first |
|
||||||
2. second |
|
||||||
3. third |
|
||||||
|
|
||||||
Let's nest that: |
|
||||||
1. first |
|
||||||
2. second indented |
|
||||||
3. third |
|
||||||
4. fourth indented |
|
||||||
5. fifth indented even more |
|
||||||
6. sixth under the fourth |
|
||||||
7. seventh under the sixth |
|
||||||
8. eighth under the third |
|
||||||
|
|
||||||
This is ordered and unordered mixed: |
|
||||||
1. first |
|
||||||
2. second indented |
|
||||||
3. third |
|
||||||
* make this a bullet point |
|
||||||
4. fourth indented even more |
|
||||||
* second bullet point |
|
||||||
|
|
||||||
Here is a horizontal rule: |
|
||||||
|
|
||||||
--- |
|
||||||
|
|
||||||
Try embedded a nostr note with nevent: |
|
||||||
|
|
||||||
nostr:nevent1qvzqqqqqqypzplfq3m5v3u5r0q9f255fdeyz8nyac6lagssx8zy4wugxjs8ajf7pqydhwumn8ghj7argv4nx7un9wd6zumn0wd68yvfwvdhk6tcpr3mhxue69uhkx6rjd9ehgurfd3kzumn0wd68yvfwvdhk6tcqyrzdyycehfwyekef75z5wnnygqeps6a4qvc8dunvumzr08g06svgcptkske |
|
||||||
|
|
||||||
Here a note with no prefix |
|
||||||
|
|
||||||
note1cnfpxxd6t3xdk204q4r5uezqxgvxhdgrxpm0ym8xcsme6r75rzxqcj9lmz |
|
||||||
|
|
||||||
Here with a naddr: |
|
||||||
|
|
||||||
nostr:naddr1qvzqqqr4gupzplfq3m5v3u5r0q9f255fdeyz8nyac6lagssx8zy4wugxjs8ajf7pqydhwumn8ghj7argv4nx7un9wd6zumn0wd68yvfwvdhk6tcpr3mhxue69uhkx6rjd9ehgurfd3kzumn0wd68yvfwvdhk6tcqzasj6ar9wd6xv6tvv5kkvmmj94kkzuntv3hhwmsu0ktnz |
|
||||||
|
|
||||||
Here's a nonsense one: |
|
||||||
|
|
||||||
nevent123 |
|
||||||
|
|
||||||
And a nonsense one with a prefix: |
|
||||||
|
|
||||||
nostr:naddrwhatever |
|
||||||
|
|
||||||
And some Nostr addresses that should be preserved and have a internal link appended: |
|
||||||
|
|
||||||
https://lumina.rocks/note/note1sd0hkhxr49jsetkcrjkvf2uls5m8frkue6f5huj8uv4964p2d8fs8dn68z |
|
||||||
|
|
||||||
https://primal.net/e/nevent1qqsqum7j25p9z8vcyn93dsd7edx34w07eqav50qnde3vrfs466q558gdd02yr |
|
||||||
|
|
||||||
https://primal.net/p/nprofile1qqs06gywary09qmcp2249ztwfq3ue8wxhl2yyp3c39thzp55plvj0sgjn9mdk |
|
||||||
|
|
||||||
URL with a tracking parameter, no markup: |
|
||||||
https://example.com?utm_source=newsletter1&utm_medium=email&utm_campaign=sale |
|
||||||
|
|
||||||
Image without markup: |
|
||||||
https://upload.wikimedia.org/wikipedia/commons/f/f1/Heart_coraz%C3%B3n.svg |
|
||||||
|
|
||||||
This is an implementation of [Nostr-flavored markup](https://github.com/nostrability/nostrability/issues/146) for #gitstuff issue notes. |
|
||||||
|
|
||||||
You can even turn Alexandria URLs into embedded events, if they have hexids or bech32 addresses: |
|
||||||
https://next-alexandria.gitcitadel.eu/events?id=nevent1qqstjcyerjx4laxlxc70cwzuxf3u9kkzuhdhgtu8pwrzvh7k5d5zdngpzemhxue69uhhyetvv9ujumn0wd68ytnzv9hxgq3qm3xdppkd0njmrqe2ma8a6ys39zvgp5k8u22mev8xsnqp4nh80srq0ylvuw |
|
||||||
|
|
||||||
But not if they have d-tags: |
|
||||||
https://next-alexandria.gitcitadel.eu/publication?d=relay-test-thecitadel-by-unknown-v-1 |
|
||||||
|
|
||||||
And within a markup tag: [markup link title](https://next-alexandria.gitcitadel.com/publication?id=84ad65f7a321404f55d97c2208dd3686c41724e6c347d3ee53cfe16f67cdfb7c). |
|
||||||
|
|
||||||
And to localhost: http://localhost:4173/publication?id=c36b54991e459221f444612d88ea94ef5bb4a1b93863ef89b1328996746f6d25 |
|
||||||
|
|
||||||
https://next-alexandria.gitcitadel.eu/events?id=nprofile1qqs99d9qw67th0wr5xh05de4s9k0wjvnkxudkgptq8yg83vtulad30gxyk5sf |
|
||||||
|
|
||||||
You can even include code inline, like `<div class="leather min-h-full w-full flex flex-col items-center">` or |
|
||||||
|
|
||||||
``` |
|
||||||
in a code block |
|
||||||
``` |
|
||||||
|
|
||||||
You can even use a multi-line code block, with a json tag. |
|
||||||
|
|
||||||
```json |
|
||||||
{ |
|
||||||
"created_at":1745038670,"content":"# This is a test\n\nIt is _only_ a test. I just wanted to see if the *markup* renders correctly on the page, even if I use **two asterisks** for bold text.[^1]\n\nnpub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z wrote this. That's the same person as nostr:npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z. That is a different person from npub1s3ht77dq4zqnya8vjun5jp3p44pr794ru36d0ltxu65chljw8xjqd975wz.\n\n> This is important information\n\n> This is multiple\n> lines of\n> important information\n> with a second[^2] footnote.\n\n* but\n* not\n* really\n\n## More testing\n\n1. first\n2. second\n3. third\n\nHere is a horizontal rule:\n\n---\n\nThis is an implementation of [Nostr-flavored markup](github.com/nostrability/nostrability/issues/146 ) for #gitstuff issue notes.\n\nYou can even include `code inline` or\n\n```\nin a code block\n```\n\nYou can even use a \n\n```json\nmultiline of json block\n```\n\n\n\n\n[^1]: this is a footnote\n[^2]: so is this","tags":[["subject","test"],["alt","git repository issue: test"],["a","30617:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:Alexandria","","root"],["p","fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1"],["t","gitstuff"]],"kind":1621,"pubkey":"dd664d5e4016433a8cd69f005ae1480804351789b59de5af06276de65633d319","id":"e78a689369511fdb3c36b990380c2d8db2b5e62f13f6b836e93ef5a09611afe8","sig":"7a2b3a6f6f61b6ea04de1fe873e46d40f2a220f02cdae004342430aa1df67647a9589459382f22576c651b3d09811546bbd79564cf472deaff032f137e94a865" |
|
||||||
} |
|
||||||
``` |
|
||||||
|
|
||||||
C or C++: |
|
||||||
```cpp |
|
||||||
bool getBit(int num, int i) { |
|
||||||
return ((num & (1<<i)) != 0); |
|
||||||
} |
|
||||||
``` |
|
||||||
|
|
||||||
Asciidoc: |
|
||||||
```adoc |
|
||||||
= Header 1 |
|
||||||
|
|
||||||
preamble goes here |
|
||||||
|
|
||||||
== Header 2 |
|
||||||
|
|
||||||
some more text |
|
||||||
``` |
|
||||||
|
|
||||||
Gherkin: |
|
||||||
```gherkin |
|
||||||
Feature: Account Holder withdraws cash |
|
||||||
|
|
||||||
Scenario: Account has sufficient funds |
|
||||||
Given The account balance is $100 |
|
||||||
And the card is valid |
|
||||||
And the machine contains enough money |
|
||||||
When the Account Holder requests $20 |
|
||||||
Then the ATM should dispense $20 |
|
||||||
And the account balance should be $80 |
|
||||||
And the card should be returned |
|
||||||
``` |
|
||||||
|
|
||||||
Go: |
|
||||||
```go |
|
||||||
package main |
|
||||||
|
|
||||||
import ( |
|
||||||
"fmt" |
|
||||||
"bufio" |
|
||||||
"os" |
|
||||||
) |
|
||||||
|
|
||||||
func main() { |
|
||||||
scanner := bufio.NewScanner(os.Stdin) |
|
||||||
fmt.Print("Enter text: ") |
|
||||||
scanner.Scan() |
|
||||||
input := scanner.Text() |
|
||||||
fmt.Println("You entered:", input) |
|
||||||
} |
|
||||||
``` |
|
||||||
|
|
||||||
or even markup: |
|
||||||
|
|
||||||
```md |
|
||||||
A H1 Header |
|
||||||
============ |
|
||||||
|
|
||||||
Paragraphs are separated by a blank line. |
|
||||||
|
|
||||||
2nd paragraph. *Italic*, **bold**, and `monospace`. Itemized lists |
|
||||||
look like: |
|
||||||
|
|
||||||
* this one[^some reference text] |
|
||||||
* that one |
|
||||||
* the other one |
|
||||||
|
|
||||||
Note that --- not considering the asterisk --- the actual text |
|
||||||
content starts at 4-columns in. |
|
||||||
|
|
||||||
> Block quotes are |
|
||||||
> written like so. |
|
||||||
> |
|
||||||
> They can span multiple paragraphs, |
|
||||||
> if you like. |
|
||||||
``` |
|
||||||
|
|
||||||
Test out some emojis :heart: and :trophy: |
|
||||||
|
|
||||||
#### Here is an image![^some reference text] |
|
||||||
|
|
||||||
 |
|
||||||
|
|
||||||
### I went ahead and implemented tables, too. |
|
||||||
|
|
||||||
A neat table[^some reference text]: |
|
||||||
|
|
||||||
| Syntax | Description | |
|
||||||
| ----------- | ----------- | |
|
||||||
| Header | Title | |
|
||||||
| Paragraph | Text | |
|
||||||
|
|
||||||
A messy table (should render the same as above): |
|
||||||
|
|
||||||
| Syntax | Description | |
|
||||||
| --- | ----------- | |
|
||||||
| Header | Title | |
|
||||||
| Paragraph | Text | |
|
||||||
|
|
||||||
Here is a table without a header row: |
|
||||||
|
|
||||||
| Sometimes | you don't | |
|
||||||
| need a | header | |
|
||||||
| just | pipes | |
|
||||||
|
|
||||||
[^1]: this is a footnote |
|
||||||
[^some reference text]: this is a footnote that isn't a number |
|
||||||
@ -1,118 +0,0 @@ |
|||||||
import { describe, it, expect } from 'vitest'; |
|
||||||
import { parseAdvancedmarkup } from '../../src/lib/utils/markup/advancedMarkupParser'; |
|
||||||
|
|
||||||
function stripWS(str: string) { |
|
||||||
return str.replace(/\s+/g, ' ').trim(); |
|
||||||
} |
|
||||||
|
|
||||||
describe('Advanced Markup Parser', () => { |
|
||||||
it('parses headers (ATX and Setext)', async () => { |
|
||||||
const input = '# H1\nText\n\nH2\n====\n'; |
|
||||||
const output = await parseAdvancedmarkup(input); |
|
||||||
expect(stripWS(output)).toContain('H1'); |
|
||||||
expect(stripWS(output)).toContain('H2'); |
|
||||||
}); |
|
||||||
|
|
||||||
it('parses bold, italic, and strikethrough', async () => { |
|
||||||
const input = '*bold* **bold** _italic_ __italic__ ~strikethrough~ ~~strikethrough~~'; |
|
||||||
const output = await parseAdvancedmarkup(input); |
|
||||||
expect(output).toContain('<strong>bold</strong>'); |
|
||||||
expect(output).toContain('<em>italic</em>'); |
|
||||||
expect(output).toContain('<del class="line-through">strikethrough</del>'); |
|
||||||
}); |
|
||||||
|
|
||||||
it('parses blockquotes', async () => { |
|
||||||
const input = '> quote'; |
|
||||||
const output = await parseAdvancedmarkup(input); |
|
||||||
expect(output).toContain('<blockquote'); |
|
||||||
expect(output).toContain('quote'); |
|
||||||
}); |
|
||||||
|
|
||||||
it('parses multi-line blockquotes', async () => { |
|
||||||
const input = '> quote\n> quote'; |
|
||||||
const output = await parseAdvancedmarkup(input); |
|
||||||
expect(output).toContain('<blockquote'); |
|
||||||
expect(output).toContain('quote'); |
|
||||||
expect(output).toContain('quote'); |
|
||||||
}); |
|
||||||
|
|
||||||
it('parses unordered lists', async () => { |
|
||||||
const input = '* a\n* b'; |
|
||||||
const output = await parseAdvancedmarkup(input); |
|
||||||
expect(output).toContain('<ul'); |
|
||||||
expect(output).toContain('a'); |
|
||||||
expect(output).toContain('b'); |
|
||||||
}); |
|
||||||
|
|
||||||
it('parses ordered lists', async () => { |
|
||||||
const input = '1. one\n2. two'; |
|
||||||
const output = await parseAdvancedmarkup(input); |
|
||||||
expect(output).toContain('<ol'); |
|
||||||
expect(output).toContain('one'); |
|
||||||
expect(output).toContain('two'); |
|
||||||
}); |
|
||||||
|
|
||||||
it('parses links and images', async () => { |
|
||||||
const input = '[link](https://example.com) '; |
|
||||||
const output = await parseAdvancedmarkup(input); |
|
||||||
expect(output).toContain('<a'); |
|
||||||
expect(output).toContain('<img'); |
|
||||||
}); |
|
||||||
|
|
||||||
it('parses hashtags', async () => { |
|
||||||
const input = '#hashtag'; |
|
||||||
const output = await parseAdvancedmarkup(input); |
|
||||||
expect(output).toContain('text-primary-600'); |
|
||||||
expect(output).toContain('#hashtag'); |
|
||||||
}); |
|
||||||
|
|
||||||
it('parses nostr identifiers', async () => { |
|
||||||
const input = 'npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq'; |
|
||||||
const output = await parseAdvancedmarkup(input); |
|
||||||
expect(output).toContain('./events?id=npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq'); |
|
||||||
}); |
|
||||||
|
|
||||||
it('parses emoji shortcodes', async () => { |
|
||||||
const input = 'hello :smile:'; |
|
||||||
const output = await parseAdvancedmarkup(input); |
|
||||||
expect(output).toMatch(/😄|:smile:/); |
|
||||||
}); |
|
||||||
|
|
||||||
it('parses wikilinks', async () => { |
|
||||||
const input = '[[Test Page|display]]'; |
|
||||||
const output = await parseAdvancedmarkup(input); |
|
||||||
expect(output).toContain('wikilink'); |
|
||||||
expect(output).toContain('display'); |
|
||||||
}); |
|
||||||
|
|
||||||
it('parses tables (with and without headers)', async () => { |
|
||||||
const input = `| Syntax | Description |\n|--------|-------------|\n| Header | Title |\n| Paragraph | Text |\n\n| a | b |\n| c | d |`; |
|
||||||
const output = await parseAdvancedmarkup(input); |
|
||||||
expect(output).toContain('<table'); |
|
||||||
expect(output).toContain('Header'); |
|
||||||
expect(output).toContain('a'); |
|
||||||
}); |
|
||||||
|
|
||||||
it('parses code blocks (with and without language)', async () => { |
|
||||||
const input = '```js\nconsole.log(1);\n```\n```\nno lang\n```'; |
|
||||||
const output = await parseAdvancedmarkup(input); |
|
||||||
const textOnly = output.replace(/<[^>]+>/g, ''); |
|
||||||
expect(output).toContain('<pre'); |
|
||||||
expect(textOnly).toContain('console.log(1);'); |
|
||||||
expect(textOnly).toContain('no lang'); |
|
||||||
}); |
|
||||||
|
|
||||||
it('parses horizontal rules', async () => { |
|
||||||
const input = '---'; |
|
||||||
const output = await parseAdvancedmarkup(input); |
|
||||||
expect(output).toContain('<hr'); |
|
||||||
}); |
|
||||||
|
|
||||||
it('parses footnotes (references and section)', async () => { |
|
||||||
const input = 'Here is a footnote[^1].\n\n[^1]: This is the footnote.'; |
|
||||||
const output = await parseAdvancedmarkup(input); |
|
||||||
expect(output).toContain('Footnotes'); |
|
||||||
expect(output).toContain('This is the footnote'); |
|
||||||
expect(output).toContain('fn-1'); |
|
||||||
}); |
|
||||||
});
|
|
||||||
@ -1,88 +0,0 @@ |
|||||||
import { describe, it, expect } from 'vitest'; |
|
||||||
import { parseBasicmarkup } from '../../src/lib/utils/markup/basicMarkupParser'; |
|
||||||
|
|
||||||
// Helper to strip whitespace for easier comparison
|
|
||||||
function stripWS(str: string) { |
|
||||||
return str.replace(/\s+/g, ' ').trim(); |
|
||||||
} |
|
||||||
|
|
||||||
describe('Basic Markup Parser', () => { |
|
||||||
it('parses ATX and Setext headers', async () => { |
|
||||||
const input = '# H1\nText\n\nH2\n====\n'; |
|
||||||
const output = await parseBasicmarkup(input); |
|
||||||
expect(stripWS(output)).toContain('H1'); |
|
||||||
expect(stripWS(output)).toContain('H2'); |
|
||||||
}); |
|
||||||
|
|
||||||
it('parses bold, italic, and strikethrough', async () => { |
|
||||||
const input = '*bold* **bold** _italic_ __italic__ ~strikethrough~ ~~strikethrough~~'; |
|
||||||
const output = await parseBasicmarkup(input); |
|
||||||
expect(output).toContain('<strong>bold</strong>'); |
|
||||||
expect(output).toContain('<em>italic</em>'); |
|
||||||
expect(output).toContain('<del class="line-through">strikethrough</del>'); |
|
||||||
}); |
|
||||||
|
|
||||||
it('parses blockquotes', async () => { |
|
||||||
const input = '> quote'; |
|
||||||
const output = await parseBasicmarkup(input); |
|
||||||
expect(output).toContain('<blockquote'); |
|
||||||
expect(output).toContain('quote'); |
|
||||||
}); |
|
||||||
|
|
||||||
it('parses multi-line blockquotes', async () => { |
|
||||||
const input = '> quote\n> quote'; |
|
||||||
const output = await parseBasicmarkup(input); |
|
||||||
expect(output).toContain('<blockquote'); |
|
||||||
expect(output).toContain('quote'); |
|
||||||
expect(output).toContain('quote'); |
|
||||||
}); |
|
||||||
|
|
||||||
it('parses unordered lists', async () => { |
|
||||||
const input = '* a\n* b'; |
|
||||||
const output = await parseBasicmarkup(input); |
|
||||||
expect(output).toContain('<ul'); |
|
||||||
expect(output).toContain('a'); |
|
||||||
expect(output).toContain('b'); |
|
||||||
}); |
|
||||||
|
|
||||||
it('parses ordered lists', async () => { |
|
||||||
const input = '1. one\n2. two'; |
|
||||||
const output = await parseBasicmarkup(input); |
|
||||||
expect(output).toContain('<ol'); |
|
||||||
expect(output).toContain('one'); |
|
||||||
expect(output).toContain('two'); |
|
||||||
}); |
|
||||||
|
|
||||||
it('parses links and images', async () => { |
|
||||||
const input = '[link](https://example.com) '; |
|
||||||
const output = await parseBasicmarkup(input); |
|
||||||
expect(output).toContain('<a'); |
|
||||||
expect(output).toContain('<img'); |
|
||||||
}); |
|
||||||
|
|
||||||
it('parses hashtags', async () => { |
|
||||||
const input = '#hashtag'; |
|
||||||
const output = await parseBasicmarkup(input); |
|
||||||
expect(output).toContain('text-primary-600'); |
|
||||||
expect(output).toContain('#hashtag'); |
|
||||||
}); |
|
||||||
|
|
||||||
it('parses nostr identifiers', async () => { |
|
||||||
const input = 'npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq'; |
|
||||||
const output = await parseBasicmarkup(input); |
|
||||||
expect(output).toContain('./events?id=npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq'); |
|
||||||
}); |
|
||||||
|
|
||||||
it('parses emoji shortcodes', async () => { |
|
||||||
const input = 'hello :smile:'; |
|
||||||
const output = await parseBasicmarkup(input); |
|
||||||
expect(output).toMatch(/😄|:smile:/); |
|
||||||
}); |
|
||||||
|
|
||||||
it('parses wikilinks', async () => { |
|
||||||
const input = '[[Test Page|display]]'; |
|
||||||
const output = await parseBasicmarkup(input); |
|
||||||
expect(output).toContain('wikilink'); |
|
||||||
expect(output).toContain('display'); |
|
||||||
}); |
|
||||||
});
|
|
||||||
@ -1,376 +0,0 @@ |
|||||||
import { describe, expect, it, vi } from 'vitest'; |
|
||||||
import { NDKEvent } from '@nostr-dev-kit/ndk'; |
|
||||||
import {
|
|
||||||
createCoordinateMap,
|
|
||||||
extractCoordinateFromATag, |
|
||||||
initializeGraphState
|
|
||||||
} from '$lib/navigator/EventNetwork/utils/networkBuilder'; |
|
||||||
|
|
||||||
// Mock NDKEvent
|
|
||||||
class MockNDKEvent implements Partial<NDKEvent> { |
|
||||||
id: string; |
|
||||||
pubkey: string; |
|
||||||
created_at?: number; |
|
||||||
kind?: number; |
|
||||||
content?: string; |
|
||||||
tags: string[][]; |
|
||||||
|
|
||||||
constructor(params: { id: string; pubkey: string; created_at?: number; kind?: number; content?: string; tags?: string[][] }) { |
|
||||||
this.id = params.id; |
|
||||||
this.pubkey = params.pubkey; |
|
||||||
this.created_at = params.created_at; |
|
||||||
this.kind = params.kind; |
|
||||||
this.content = params.content || ''; |
|
||||||
this.tags = params.tags || []; |
|
||||||
} |
|
||||||
|
|
||||||
getMatchingTags(tagName: string): string[][] { |
|
||||||
return this.tags.filter(tag => tag[0] === tagName); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
// Generate a valid 64-character hex pubkey
|
|
||||||
function generatePubkey(seed: string): string { |
|
||||||
return seed.padEnd(64, '0'); |
|
||||||
} |
|
||||||
|
|
||||||
// Generate a valid 64-character hex event ID
|
|
||||||
function generateEventId(seed: string): string { |
|
||||||
return seed.padEnd(64, '0'); |
|
||||||
} |
|
||||||
|
|
||||||
describe('Coordinate-based Deduplication', () => { |
|
||||||
// Helper to create a mock event with valid IDs
|
|
||||||
function createMockEvent(params: { |
|
||||||
id: string; |
|
||||||
pubkey: string; |
|
||||||
kind?: number; |
|
||||||
created_at?: number; |
|
||||||
tags?: string[][]; |
|
||||||
content?: string; |
|
||||||
}) { |
|
||||||
return new MockNDKEvent({ |
|
||||||
...params, |
|
||||||
id: generateEventId(params.id), |
|
||||||
pubkey: generatePubkey(params.pubkey) |
|
||||||
}) as NDKEvent; |
|
||||||
} |
|
||||||
describe('createCoordinateMap', () => { |
|
||||||
it('should create empty map for non-replaceable events', () => { |
|
||||||
const events = [ |
|
||||||
new MockNDKEvent({ id: '1', pubkey: generatePubkey('pubkey1'), kind: 1 }), |
|
||||||
new MockNDKEvent({ id: '2', pubkey: generatePubkey('pubkey2'), kind: 4 }), |
|
||||||
new MockNDKEvent({ id: '3', pubkey: generatePubkey('pubkey3'), kind: 7 }) |
|
||||||
] as NDKEvent[]; |
|
||||||
|
|
||||||
const coordinateMap = createCoordinateMap(events); |
|
||||||
expect(coordinateMap.size).toBe(0); |
|
||||||
}); |
|
||||||
|
|
||||||
it('should map replaceable events by coordinate', () => { |
|
||||||
const events = [ |
|
||||||
new MockNDKEvent({
|
|
||||||
id: 'event1',
|
|
||||||
pubkey: generatePubkey('author1'),
|
|
||||||
kind: 30040,
|
|
||||||
created_at: 1000, |
|
||||||
tags: [['d', 'publication1']] |
|
||||||
}), |
|
||||||
new MockNDKEvent({
|
|
||||||
id: 'event2',
|
|
||||||
pubkey: generatePubkey('author2'),
|
|
||||||
kind: 30041,
|
|
||||||
created_at: 1001, |
|
||||||
tags: [['d', 'section1']] |
|
||||||
}) |
|
||||||
] as NDKEvent[]; |
|
||||||
|
|
||||||
const coordinateMap = createCoordinateMap(events); |
|
||||||
expect(coordinateMap.size).toBe(2); |
|
||||||
expect(coordinateMap.get(`30040:${generatePubkey('author1')}:publication1`)?.id).toBe('event1'); |
|
||||||
expect(coordinateMap.get(`30041:${generatePubkey('author2')}:section1`)?.id).toBe('event2'); |
|
||||||
}); |
|
||||||
|
|
||||||
it('should keep only the most recent version of duplicate coordinates', () => { |
|
||||||
const events = [ |
|
||||||
new MockNDKEvent({
|
|
||||||
id: 'old_event',
|
|
||||||
pubkey: generatePubkey('author1'),
|
|
||||||
kind: 30040,
|
|
||||||
created_at: 1000, |
|
||||||
tags: [['d', 'publication1']] |
|
||||||
}), |
|
||||||
new MockNDKEvent({
|
|
||||||
id: 'new_event',
|
|
||||||
pubkey: generatePubkey('author1'),
|
|
||||||
kind: 30040,
|
|
||||||
created_at: 2000, |
|
||||||
tags: [['d', 'publication1']] |
|
||||||
}), |
|
||||||
new MockNDKEvent({
|
|
||||||
id: 'older_event',
|
|
||||||
pubkey: generatePubkey('author1'),
|
|
||||||
kind: 30040,
|
|
||||||
created_at: 500, |
|
||||||
tags: [['d', 'publication1']] |
|
||||||
}) |
|
||||||
] as NDKEvent[]; |
|
||||||
|
|
||||||
const coordinateMap = createCoordinateMap(events); |
|
||||||
expect(coordinateMap.size).toBe(1); |
|
||||||
expect(coordinateMap.get(`30040:${generatePubkey('author1')}:publication1`)?.id).toBe('new_event'); |
|
||||||
}); |
|
||||||
|
|
||||||
it('should handle missing d-tags gracefully', () => { |
|
||||||
const events = [ |
|
||||||
new MockNDKEvent({
|
|
||||||
id: 'event1',
|
|
||||||
pubkey: generatePubkey('author1'),
|
|
||||||
kind: 30040,
|
|
||||||
created_at: 1000, |
|
||||||
tags: [] |
|
||||||
}), |
|
||||||
new MockNDKEvent({
|
|
||||||
id: 'event2',
|
|
||||||
pubkey: generatePubkey('author1'),
|
|
||||||
kind: 30040,
|
|
||||||
created_at: 2000, |
|
||||||
tags: [['d', 'publication1']] |
|
||||||
}) |
|
||||||
] as NDKEvent[]; |
|
||||||
|
|
||||||
const coordinateMap = createCoordinateMap(events); |
|
||||||
expect(coordinateMap.size).toBe(1); |
|
||||||
expect(coordinateMap.get(`30040:${generatePubkey('author1')}:publication1`)?.id).toBe('event2'); |
|
||||||
}); |
|
||||||
|
|
||||||
it('should handle d-tags containing colons', () => { |
|
||||||
const events = [ |
|
||||||
new MockNDKEvent({
|
|
||||||
id: 'event1',
|
|
||||||
pubkey: generatePubkey('author1'),
|
|
||||||
kind: 30040,
|
|
||||||
created_at: 1000, |
|
||||||
tags: [['d', 'namespace:identifier:version']] |
|
||||||
}) |
|
||||||
] as NDKEvent[]; |
|
||||||
|
|
||||||
const coordinateMap = createCoordinateMap(events); |
|
||||||
expect(coordinateMap.size).toBe(1); |
|
||||||
expect(coordinateMap.get(`30040:${generatePubkey('author1')}:namespace:identifier:version`)?.id).toBe('event1'); |
|
||||||
}); |
|
||||||
|
|
||||||
it('should handle events without timestamps', () => { |
|
||||||
const events = [ |
|
||||||
new MockNDKEvent({
|
|
||||||
id: 'event_with_time',
|
|
||||||
pubkey: generatePubkey('author1'),
|
|
||||||
kind: 30040,
|
|
||||||
created_at: 1000, |
|
||||||
tags: [['d', 'publication1']] |
|
||||||
}), |
|
||||||
new MockNDKEvent({
|
|
||||||
id: 'event_no_time',
|
|
||||||
pubkey: generatePubkey('author1'),
|
|
||||||
kind: 30040, |
|
||||||
tags: [['d', 'publication1']] |
|
||||||
}) |
|
||||||
] as NDKEvent[]; |
|
||||||
|
|
||||||
const coordinateMap = createCoordinateMap(events); |
|
||||||
expect(coordinateMap.size).toBe(1); |
|
||||||
// Should keep the one with timestamp
|
|
||||||
expect(coordinateMap.get(`30040:${generatePubkey('author1')}:publication1`)?.id).toBe('event_with_time'); |
|
||||||
}); |
|
||||||
}); |
|
||||||
|
|
||||||
describe('extractCoordinateFromATag', () => { |
|
||||||
it('should extract valid coordinates from a-tags', () => { |
|
||||||
const tag = ['a', `30040:${generatePubkey('pubkey123')}:dtag123`]; |
|
||||||
const result = extractCoordinateFromATag(tag); |
|
||||||
|
|
||||||
expect(result).toEqual({ |
|
||||||
kind: 30040, |
|
||||||
pubkey: generatePubkey('pubkey123'), |
|
||||||
dTag: 'dtag123' |
|
||||||
}); |
|
||||||
}); |
|
||||||
|
|
||||||
it('should handle d-tags with colons', () => { |
|
||||||
const tag = ['a', `30040:${generatePubkey('pubkey123')}:namespace:identifier:version`]; |
|
||||||
const result = extractCoordinateFromATag(tag); |
|
||||||
|
|
||||||
expect(result).toEqual({ |
|
||||||
kind: 30040, |
|
||||||
pubkey: generatePubkey('pubkey123'), |
|
||||||
dTag: 'namespace:identifier:version' |
|
||||||
}); |
|
||||||
}); |
|
||||||
|
|
||||||
it('should return null for invalid a-tags', () => { |
|
||||||
expect(extractCoordinateFromATag(['a'])).toBeNull(); |
|
||||||
expect(extractCoordinateFromATag(['a', ''])).toBeNull(); |
|
||||||
expect(extractCoordinateFromATag(['a', 'invalid'])).toBeNull(); |
|
||||||
expect(extractCoordinateFromATag(['a', 'invalid:format'])).toBeNull(); |
|
||||||
expect(extractCoordinateFromATag(['a', 'notanumber:pubkey:dtag'])).toBeNull(); |
|
||||||
}); |
|
||||||
}); |
|
||||||
|
|
||||||
describe('initializeGraphState deduplication', () => { |
|
||||||
it('should create only one node per coordinate for replaceable events', () => { |
|
||||||
const events = [ |
|
||||||
new MockNDKEvent({
|
|
||||||
id: 'old_version',
|
|
||||||
pubkey: generatePubkey('author1'),
|
|
||||||
kind: 30040,
|
|
||||||
created_at: 1000, |
|
||||||
tags: [['d', 'publication1'], ['title', 'Old Title']] |
|
||||||
}), |
|
||||||
new MockNDKEvent({
|
|
||||||
id: 'new_version',
|
|
||||||
pubkey: generatePubkey('author1'),
|
|
||||||
kind: 30040,
|
|
||||||
created_at: 2000, |
|
||||||
tags: [['d', 'publication1'], ['title', 'New Title']] |
|
||||||
}), |
|
||||||
new MockNDKEvent({
|
|
||||||
id: 'different_pub',
|
|
||||||
pubkey: generatePubkey('author1'),
|
|
||||||
kind: 30040,
|
|
||||||
created_at: 1500, |
|
||||||
tags: [['d', 'publication2'], ['title', 'Different Publication']] |
|
||||||
}) |
|
||||||
] as NDKEvent[]; |
|
||||||
|
|
||||||
const graphState = initializeGraphState(events); |
|
||||||
|
|
||||||
// Should have only 2 nodes (one for each unique coordinate)
|
|
||||||
expect(graphState.nodeMap.size).toBe(2); |
|
||||||
expect(graphState.nodeMap.has('new_version')).toBe(true); |
|
||||||
expect(graphState.nodeMap.has('different_pub')).toBe(true); |
|
||||||
expect(graphState.nodeMap.has('old_version')).toBe(false); |
|
||||||
}); |
|
||||||
|
|
||||||
it('should handle mix of replaceable and non-replaceable events', () => { |
|
||||||
const events = [ |
|
||||||
new MockNDKEvent({
|
|
||||||
id: 'regular1',
|
|
||||||
pubkey: generatePubkey('author1'),
|
|
||||||
kind: 1,
|
|
||||||
created_at: 1000 |
|
||||||
}), |
|
||||||
new MockNDKEvent({
|
|
||||||
id: 'regular2',
|
|
||||||
pubkey: generatePubkey('author1'),
|
|
||||||
kind: 1,
|
|
||||||
created_at: 2000 |
|
||||||
}), |
|
||||||
new MockNDKEvent({
|
|
||||||
id: 'replaceable1',
|
|
||||||
pubkey: generatePubkey('author1'),
|
|
||||||
kind: 30040,
|
|
||||||
created_at: 1000, |
|
||||||
tags: [['d', 'publication1']] |
|
||||||
}), |
|
||||||
new MockNDKEvent({
|
|
||||||
id: 'replaceable2',
|
|
||||||
pubkey: generatePubkey('author1'),
|
|
||||||
kind: 30040,
|
|
||||||
created_at: 2000, |
|
||||||
tags: [['d', 'publication1']] |
|
||||||
}) |
|
||||||
] as NDKEvent[]; |
|
||||||
|
|
||||||
const graphState = initializeGraphState(events); |
|
||||||
|
|
||||||
// Should have 3 nodes: 2 regular events + 1 replaceable (latest version)
|
|
||||||
expect(graphState.nodeMap.size).toBe(3); |
|
||||||
expect(graphState.nodeMap.has('regular1')).toBe(true); |
|
||||||
expect(graphState.nodeMap.has('regular2')).toBe(true); |
|
||||||
expect(graphState.nodeMap.has('replaceable2')).toBe(true); |
|
||||||
expect(graphState.nodeMap.has('replaceable1')).toBe(false); |
|
||||||
}); |
|
||||||
|
|
||||||
it('should correctly handle referenced events with coordinates', () => { |
|
||||||
const events = [ |
|
||||||
new MockNDKEvent({
|
|
||||||
id: 'index_old',
|
|
||||||
pubkey: generatePubkey('author1'),
|
|
||||||
kind: 30040,
|
|
||||||
created_at: 1000, |
|
||||||
tags: [['d', 'book1'], ['title', 'Old Book Title']] |
|
||||||
}), |
|
||||||
new MockNDKEvent({
|
|
||||||
id: 'index_new',
|
|
||||||
pubkey: generatePubkey('author1'),
|
|
||||||
kind: 30040,
|
|
||||||
created_at: 2000, |
|
||||||
tags: [['d', 'book1'], ['title', 'New Book Title']] |
|
||||||
}), |
|
||||||
new MockNDKEvent({
|
|
||||||
id: 'chapter1',
|
|
||||||
pubkey: generatePubkey('author1'),
|
|
||||||
kind: 30041,
|
|
||||||
created_at: 1500, |
|
||||||
tags: [ |
|
||||||
['d', 'chapter1'], |
|
||||||
['a', `30040:${generatePubkey('author1')}:book1`, 'relay1'] |
|
||||||
] |
|
||||||
}) |
|
||||||
] as NDKEvent[]; |
|
||||||
|
|
||||||
const graphState = initializeGraphState(events); |
|
||||||
|
|
||||||
// Only the new version of the index should be referenced
|
|
||||||
expect(graphState.referencedIds.has('index_new')).toBe(true); |
|
||||||
expect(graphState.referencedIds.has('index_old')).toBe(false); |
|
||||||
}); |
|
||||||
|
|
||||||
it('should handle edge cases in coordinate generation', () => { |
|
||||||
const events = [ |
|
||||||
// Event with empty d-tag
|
|
||||||
new MockNDKEvent({
|
|
||||||
id: 'empty_dtag',
|
|
||||||
pubkey: generatePubkey('author1'),
|
|
||||||
kind: 30040,
|
|
||||||
created_at: 1000, |
|
||||||
tags: [['d', '']] |
|
||||||
}), |
|
||||||
// Event with no d-tag
|
|
||||||
new MockNDKEvent({
|
|
||||||
id: 'no_dtag',
|
|
||||||
pubkey: generatePubkey('author1'),
|
|
||||||
kind: 30040,
|
|
||||||
created_at: 1001, |
|
||||||
tags: [] |
|
||||||
}), |
|
||||||
// Event with special characters in d-tag
|
|
||||||
new MockNDKEvent({
|
|
||||||
id: 'special_chars',
|
|
||||||
pubkey: generatePubkey('author1'),
|
|
||||||
kind: 30040,
|
|
||||||
created_at: 1002, |
|
||||||
tags: [['d', 'test/path:to@file.txt']] |
|
||||||
}), |
|
||||||
// Non-replaceable event (should always be included)
|
|
||||||
new MockNDKEvent({
|
|
||||||
id: 'non_replaceable',
|
|
||||||
pubkey: generatePubkey('author1'),
|
|
||||||
kind: 1,
|
|
||||||
created_at: 1003 |
|
||||||
}) |
|
||||||
] as NDKEvent[]; |
|
||||||
|
|
||||||
const graphState = initializeGraphState(events); |
|
||||||
|
|
||||||
// Empty d-tag should create a valid coordinate
|
|
||||||
expect(graphState.nodeMap.has('empty_dtag')).toBe(true); |
|
||||||
// No d-tag means no coordinate, but event is still included (not replaceable without coordinate)
|
|
||||||
expect(graphState.nodeMap.has('no_dtag')).toBe(true); |
|
||||||
// Special characters should be preserved
|
|
||||||
expect(graphState.nodeMap.has('special_chars')).toBe(true); |
|
||||||
// Non-replaceable should always be included
|
|
||||||
expect(graphState.nodeMap.has('non_replaceable')).toBe(true); |
|
||||||
}); |
|
||||||
}); |
|
||||||
}); |
|
||||||
@ -1,143 +0,0 @@ |
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'; |
|
||||||
import { generateGraph, generateStarGraph } from '$lib/navigator/EventNetwork/utils/networkBuilder'; |
|
||||||
import { enhanceGraphWithTags } from '$lib/navigator/EventNetwork/utils/tagNetworkBuilder'; |
|
||||||
import type { NDKEvent } from '@nostr-dev-kit/ndk'; |
|
||||||
|
|
||||||
// Mock NDKEvent
|
|
||||||
function createMockEvent(id: string, kind: number, tags: string[][] = []): NDKEvent { |
|
||||||
return { |
|
||||||
id, |
|
||||||
kind, |
|
||||||
pubkey: 'test-pubkey', |
|
||||||
created_at: Date.now() / 1000, |
|
||||||
content: `Content for ${id}`, |
|
||||||
tags, |
|
||||||
getMatchingTags: (tagName: string) => tags.filter(t => t[0] === tagName) |
|
||||||
} as NDKEvent; |
|
||||||
} |
|
||||||
|
|
||||||
describe('Link Rendering Debug Tests', () => { |
|
||||||
describe('Link Generation in Graph Builders', () => { |
|
||||||
it('should generate links in standard graph', () => { |
|
||||||
const events = [ |
|
||||||
createMockEvent('index1', 30040), |
|
||||||
createMockEvent('content1', 30041, [['a', '30040:test-pubkey:index1']]), |
|
||||||
createMockEvent('content2', 30041, [['a', '30040:test-pubkey:index1']]) |
|
||||||
]; |
|
||||||
|
|
||||||
const graph = generateGraph(events, 2); |
|
||||||
|
|
||||||
console.log('Standard graph:', { |
|
||||||
nodes: graph.nodes.map(n => ({ id: n.id, type: n.type })), |
|
||||||
links: graph.links.map(l => ({
|
|
||||||
source: typeof l.source === 'string' ? l.source : l.source.id, |
|
||||||
target: typeof l.target === 'string' ? l.target : l.target.id |
|
||||||
})) |
|
||||||
}); |
|
||||||
|
|
||||||
expect(graph.nodes).toHaveLength(3); |
|
||||||
expect(graph.links).toHaveLength(2); // Two content nodes linking to index
|
|
||||||
}); |
|
||||||
|
|
||||||
it('should generate links in star graph', () => { |
|
||||||
const events = [ |
|
||||||
createMockEvent('index1', 30040), |
|
||||||
createMockEvent('content1', 30041, [['a', '30040:test-pubkey:index1']]), |
|
||||||
createMockEvent('content2', 30041, [['a', '30040:test-pubkey:index1']]) |
|
||||||
]; |
|
||||||
|
|
||||||
const graph = generateStarGraph(events, 2); |
|
||||||
|
|
||||||
console.log('Star graph:', { |
|
||||||
nodes: graph.nodes.map(n => ({ id: n.id, type: n.type })), |
|
||||||
links: graph.links.map(l => ({
|
|
||||||
source: typeof l.source === 'string' ? l.source : l.source.id, |
|
||||||
target: typeof l.target === 'string' ? l.target : l.target.id |
|
||||||
})) |
|
||||||
}); |
|
||||||
|
|
||||||
expect(graph.nodes).toHaveLength(3); |
|
||||||
expect(graph.links).toHaveLength(2); |
|
||||||
}); |
|
||||||
|
|
||||||
it('should generate links with tag anchors', () => { |
|
||||||
const events = [ |
|
||||||
createMockEvent('index1', 30040, [['t', 'bitcoin']]), |
|
||||||
createMockEvent('content1', 30041, [['a', '30040:test-pubkey:index1'], ['t', 'bitcoin']]), |
|
||||||
]; |
|
||||||
|
|
||||||
const baseGraph = generateGraph(events, 2); |
|
||||||
const enhancedGraph = enhanceGraphWithTags(baseGraph, events, 't', 1000, 600); |
|
||||||
|
|
||||||
console.log('Enhanced graph with tags:', { |
|
||||||
nodes: enhancedGraph.nodes.map(n => ({
|
|
||||||
id: n.id,
|
|
||||||
type: n.type, |
|
||||||
isTagAnchor: n.isTagAnchor
|
|
||||||
})), |
|
||||||
links: enhancedGraph.links.map(l => ({
|
|
||||||
source: typeof l.source === 'string' ? l.source : l.source.id, |
|
||||||
target: typeof l.target === 'string' ? l.target : l.target.id |
|
||||||
})) |
|
||||||
}); |
|
||||||
|
|
||||||
// Should have original nodes plus tag anchor
|
|
||||||
expect(enhancedGraph.nodes.length).toBeGreaterThan(baseGraph.nodes.length); |
|
||||||
// Should have original links plus tag connections
|
|
||||||
expect(enhancedGraph.links.length).toBeGreaterThan(baseGraph.links.length); |
|
||||||
}); |
|
||||||
}); |
|
||||||
|
|
||||||
describe('Link Data Structure', () => { |
|
||||||
it('should have proper source and target references', () => { |
|
||||||
const events = [ |
|
||||||
createMockEvent('index1', 30040), |
|
||||||
createMockEvent('content1', 30041, [['a', '30040:test-pubkey:index1']]) |
|
||||||
]; |
|
||||||
|
|
||||||
const graph = generateGraph(events, 2); |
|
||||||
|
|
||||||
graph.links.forEach(link => { |
|
||||||
expect(link.source).toBeDefined(); |
|
||||||
expect(link.target).toBeDefined(); |
|
||||||
|
|
||||||
// Check if source/target are strings (IDs) or objects
|
|
||||||
if (typeof link.source === 'string') { |
|
||||||
const sourceNode = graph.nodes.find(n => n.id === link.source); |
|
||||||
expect(sourceNode).toBeDefined(); |
|
||||||
} else { |
|
||||||
expect(link.source.id).toBeDefined(); |
|
||||||
} |
|
||||||
|
|
||||||
if (typeof link.target === 'string') { |
|
||||||
const targetNode = graph.nodes.find(n => n.id === link.target); |
|
||||||
expect(targetNode).toBeDefined(); |
|
||||||
} else { |
|
||||||
expect(link.target.id).toBeDefined(); |
|
||||||
} |
|
||||||
}); |
|
||||||
}); |
|
||||||
}); |
|
||||||
|
|
||||||
describe('D3 Force Simulation Link Format', () => { |
|
||||||
it('should verify link format matches D3 requirements', () => { |
|
||||||
const events = [ |
|
||||||
createMockEvent('index1', 30040), |
|
||||||
createMockEvent('content1', 30041, [['a', '30040:test-pubkey:index1']]) |
|
||||||
]; |
|
||||||
|
|
||||||
const graph = generateGraph(events, 2); |
|
||||||
|
|
||||||
// D3 expects links to have source/target that reference node objects or IDs
|
|
||||||
graph.links.forEach(link => { |
|
||||||
// For D3, links should initially have string IDs
|
|
||||||
if (typeof link.source === 'string') { |
|
||||||
expect(graph.nodes.some(n => n.id === link.source)).toBe(true); |
|
||||||
} |
|
||||||
if (typeof link.target === 'string') { |
|
||||||
expect(graph.nodes.some(n => n.id === link.target)).toBe(true); |
|
||||||
} |
|
||||||
}); |
|
||||||
}); |
|
||||||
}); |
|
||||||
}); |
|
||||||
@ -1,436 +0,0 @@ |
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; |
|
||||||
import { writable, get } from 'svelte/store'; |
|
||||||
import { tick } from 'svelte'; |
|
||||||
import type { NDKEvent } from '@nostr-dev-kit/ndk'; |
|
||||||
|
|
||||||
// Mock stores and components
|
|
||||||
vi.mock('$lib/stores/visualizationConfig', () => { |
|
||||||
const mockStore = writable({ |
|
||||||
maxPublicationIndices: -1, |
|
||||||
maxEventsPerIndex: -1, |
|
||||||
searchThroughFetched: false |
|
||||||
}); |
|
||||||
|
|
||||||
return { |
|
||||||
visualizationConfig: { |
|
||||||
subscribe: mockStore.subscribe, |
|
||||||
setMaxPublicationIndices: vi.fn((value: number) => { |
|
||||||
mockStore.update(s => ({ ...s, maxPublicationIndices: value })); |
|
||||||
}), |
|
||||||
setMaxEventsPerIndex: vi.fn((value: number) => { |
|
||||||
mockStore.update(s => ({ ...s, maxEventsPerIndex: value })); |
|
||||||
}), |
|
||||||
toggleSearchThroughFetched: vi.fn(() => { |
|
||||||
mockStore.update(s => ({ ...s, searchThroughFetched: !s.searchThroughFetched })); |
|
||||||
}) |
|
||||||
} |
|
||||||
}; |
|
||||||
}); |
|
||||||
|
|
||||||
vi.mock('$lib/stores/displayLimits', () => { |
|
||||||
const mockStore = writable({ |
|
||||||
max30040: -1, |
|
||||||
max30041: -1, |
|
||||||
fetchIfNotFound: false |
|
||||||
}); |
|
||||||
|
|
||||||
return { |
|
||||||
displayLimits: mockStore |
|
||||||
}; |
|
||||||
}); |
|
||||||
|
|
||||||
describe('Extended Visualization Reactivity Tests', () => { |
|
||||||
let updateCount = 0; |
|
||||||
let lastUpdateType: string | null = null; |
|
||||||
let simulationRestarts = 0; |
|
||||||
|
|
||||||
// Mock updateGraph function
|
|
||||||
const mockUpdateGraph = vi.fn((type: string) => { |
|
||||||
updateCount++; |
|
||||||
lastUpdateType = type; |
|
||||||
}); |
|
||||||
|
|
||||||
// Mock simulation restart
|
|
||||||
const mockRestartSimulation = vi.fn(() => { |
|
||||||
simulationRestarts++; |
|
||||||
}); |
|
||||||
|
|
||||||
beforeEach(() => { |
|
||||||
updateCount = 0; |
|
||||||
lastUpdateType = null; |
|
||||||
simulationRestarts = 0; |
|
||||||
vi.clearAllMocks(); |
|
||||||
}); |
|
||||||
|
|
||||||
describe('Parameter Update Paths', () => { |
|
||||||
it('should trigger data fetch for networkFetchLimit changes', async () => { |
|
||||||
const params = { |
|
||||||
networkFetchLimit: 50, |
|
||||||
levelsToRender: 2, |
|
||||||
showTagAnchors: false, |
|
||||||
starVisualization: false, |
|
||||||
tagExpansionDepth: 0 |
|
||||||
}; |
|
||||||
|
|
||||||
// Change networkFetchLimit
|
|
||||||
const oldParams = { ...params }; |
|
||||||
params.networkFetchLimit = 100; |
|
||||||
|
|
||||||
const needsFetch = params.networkFetchLimit !== oldParams.networkFetchLimit; |
|
||||||
expect(needsFetch).toBe(true); |
|
||||||
|
|
||||||
if (needsFetch) { |
|
||||||
mockUpdateGraph('fetch-required'); |
|
||||||
} |
|
||||||
|
|
||||||
expect(mockUpdateGraph).toHaveBeenCalledWith('fetch-required'); |
|
||||||
expect(lastUpdateType).toBe('fetch-required'); |
|
||||||
}); |
|
||||||
|
|
||||||
it('should trigger data fetch for levelsToRender changes', async () => { |
|
||||||
const params = { |
|
||||||
networkFetchLimit: 50, |
|
||||||
levelsToRender: 2, |
|
||||||
showTagAnchors: false, |
|
||||||
starVisualization: false, |
|
||||||
tagExpansionDepth: 0 |
|
||||||
}; |
|
||||||
|
|
||||||
// Change levelsToRender
|
|
||||||
const oldParams = { ...params }; |
|
||||||
params.levelsToRender = 3; |
|
||||||
|
|
||||||
const needsFetch = params.levelsToRender !== oldParams.levelsToRender; |
|
||||||
expect(needsFetch).toBe(true); |
|
||||||
|
|
||||||
if (needsFetch) { |
|
||||||
mockUpdateGraph('fetch-required'); |
|
||||||
} |
|
||||||
|
|
||||||
expect(mockUpdateGraph).toHaveBeenCalledWith('fetch-required'); |
|
||||||
}); |
|
||||||
|
|
||||||
it('should trigger fetch for tagExpansionDepth when > 0', async () => { |
|
||||||
const params = { |
|
||||||
tagExpansionDepth: 0, |
|
||||||
showTagAnchors: true |
|
||||||
}; |
|
||||||
|
|
||||||
// Change to depth > 0
|
|
||||||
const oldParams = { ...params }; |
|
||||||
params.tagExpansionDepth = 1; |
|
||||||
|
|
||||||
const needsFetch = params.tagExpansionDepth > 0 &&
|
|
||||||
params.tagExpansionDepth !== oldParams.tagExpansionDepth; |
|
||||||
expect(needsFetch).toBe(true); |
|
||||||
|
|
||||||
if (needsFetch) { |
|
||||||
mockUpdateGraph('tag-expansion-fetch'); |
|
||||||
} |
|
||||||
|
|
||||||
expect(mockUpdateGraph).toHaveBeenCalledWith('tag-expansion-fetch'); |
|
||||||
}); |
|
||||||
|
|
||||||
it('should not trigger fetch for tagExpansionDepth = 0', async () => { |
|
||||||
const params = { |
|
||||||
tagExpansionDepth: 2, |
|
||||||
showTagAnchors: true |
|
||||||
}; |
|
||||||
|
|
||||||
// Change to depth = 0
|
|
||||||
const oldParams = { ...params }; |
|
||||||
params.tagExpansionDepth = 0; |
|
||||||
|
|
||||||
const needsFetch = params.tagExpansionDepth > 0; |
|
||||||
expect(needsFetch).toBe(false); |
|
||||||
|
|
||||||
if (!needsFetch) { |
|
||||||
mockUpdateGraph('visual-only'); |
|
||||||
} |
|
||||||
|
|
||||||
expect(mockUpdateGraph).toHaveBeenCalledWith('visual-only'); |
|
||||||
}); |
|
||||||
|
|
||||||
it('should handle visual-only parameter changes', async () => { |
|
||||||
const visualParams = [ |
|
||||||
{ param: 'showTagAnchors', oldValue: false, newValue: true }, |
|
||||||
{ param: 'starVisualization', oldValue: false, newValue: true }, |
|
||||||
{ param: 'selectedTagType', oldValue: 't', newValue: 'p' } |
|
||||||
]; |
|
||||||
|
|
||||||
visualParams.forEach(({ param, oldValue, newValue }) => { |
|
||||||
vi.clearAllMocks(); |
|
||||||
|
|
||||||
const needsFetch = false; // Visual parameters never need fetch
|
|
||||||
if (!needsFetch) { |
|
||||||
mockUpdateGraph('visual-only'); |
|
||||||
} |
|
||||||
|
|
||||||
expect(mockUpdateGraph).toHaveBeenCalledWith('visual-only'); |
|
||||||
expect(mockUpdateGraph).toHaveBeenCalledTimes(1); |
|
||||||
}); |
|
||||||
}); |
|
||||||
}); |
|
||||||
|
|
||||||
describe('Display Limits Integration', () => { |
|
||||||
it('should handle maxPublicationIndices changes', async () => { |
|
||||||
const { visualizationConfig } = await import('$lib/stores/visualizationConfig'); |
|
||||||
const { displayLimits } = await import('$lib/stores/displayLimits'); |
|
||||||
|
|
||||||
let configValue: any; |
|
||||||
const unsubscribe = visualizationConfig.subscribe(v => configValue = v); |
|
||||||
|
|
||||||
// Set new limit
|
|
||||||
visualizationConfig.setMaxPublicationIndices(10); |
|
||||||
await tick(); |
|
||||||
|
|
||||||
expect(configValue.maxPublicationIndices).toBe(10); |
|
||||||
|
|
||||||
// This should trigger a visual update (filtering existing data)
|
|
||||||
mockUpdateGraph('filter-existing'); |
|
||||||
expect(mockUpdateGraph).toHaveBeenCalledWith('filter-existing'); |
|
||||||
|
|
||||||
unsubscribe(); |
|
||||||
}); |
|
||||||
|
|
||||||
it('should handle unlimited (-1) values correctly', async () => { |
|
||||||
const { displayLimits } = await import('$lib/stores/displayLimits'); |
|
||||||
|
|
||||||
let limitsValue: any; |
|
||||||
const unsubscribe = displayLimits.subscribe(v => limitsValue = v); |
|
||||||
|
|
||||||
// Set to unlimited
|
|
||||||
displayLimits.update(limits => ({ |
|
||||||
...limits, |
|
||||||
max30040: -1, |
|
||||||
max30041: -1 |
|
||||||
})); |
|
||||||
await tick(); |
|
||||||
|
|
||||||
expect(limitsValue.max30040).toBe(-1); |
|
||||||
expect(limitsValue.max30041).toBe(-1); |
|
||||||
|
|
||||||
// Unlimited should show all events
|
|
||||||
const shouldFilter = limitsValue.max30040 !== -1 || limitsValue.max30041 !== -1; |
|
||||||
expect(shouldFilter).toBe(false); |
|
||||||
|
|
||||||
unsubscribe(); |
|
||||||
}); |
|
||||||
|
|
||||||
it('should handle fetchIfNotFound toggle', async () => { |
|
||||||
const { displayLimits } = await import('$lib/stores/displayLimits'); |
|
||||||
|
|
||||||
let limitsValue: any; |
|
||||||
const unsubscribe = displayLimits.subscribe(v => limitsValue = v); |
|
||||||
|
|
||||||
// Toggle fetchIfNotFound
|
|
||||||
displayLimits.update(limits => ({ |
|
||||||
...limits, |
|
||||||
fetchIfNotFound: true |
|
||||||
})); |
|
||||||
await tick(); |
|
||||||
|
|
||||||
expect(limitsValue.fetchIfNotFound).toBe(true); |
|
||||||
|
|
||||||
// This should potentially trigger fetches for missing events
|
|
||||||
if (limitsValue.fetchIfNotFound) { |
|
||||||
mockUpdateGraph('fetch-missing'); |
|
||||||
} |
|
||||||
|
|
||||||
expect(mockUpdateGraph).toHaveBeenCalledWith('fetch-missing'); |
|
||||||
|
|
||||||
unsubscribe(); |
|
||||||
}); |
|
||||||
}); |
|
||||||
|
|
||||||
describe('State Synchronization', () => { |
|
||||||
it('should maintain consistency between related parameters', async () => { |
|
||||||
let showTagAnchors = false; |
|
||||||
let tagExpansionDepth = 2; |
|
||||||
let selectedTagType = 't'; |
|
||||||
|
|
||||||
// When disabling tag anchors, depth should reset
|
|
||||||
showTagAnchors = false; |
|
||||||
if (!showTagAnchors && tagExpansionDepth > 0) { |
|
||||||
tagExpansionDepth = 0; |
|
||||||
} |
|
||||||
|
|
||||||
expect(tagExpansionDepth).toBe(0); |
|
||||||
|
|
||||||
// When enabling tag anchors, previous values can be restored
|
|
||||||
showTagAnchors = true; |
|
||||||
// selectedTagType should remain unchanged
|
|
||||||
expect(selectedTagType).toBe('t'); |
|
||||||
}); |
|
||||||
|
|
||||||
it('should handle disabled tags state updates', async () => { |
|
||||||
const disabledTags = new Set<string>(); |
|
||||||
const tagAnchors = [ |
|
||||||
{ id: 't-bitcoin', type: 't', label: 'bitcoin' }, |
|
||||||
{ id: 't-nostr', type: 't', label: 'nostr' } |
|
||||||
]; |
|
||||||
|
|
||||||
// Toggle tag state
|
|
||||||
const tagId = 't-bitcoin'; |
|
||||||
if (disabledTags.has(tagId)) { |
|
||||||
disabledTags.delete(tagId); |
|
||||||
} else { |
|
||||||
disabledTags.add(tagId); |
|
||||||
} |
|
||||||
|
|
||||||
expect(disabledTags.has('t-bitcoin')).toBe(true); |
|
||||||
expect(disabledTags.has('t-nostr')).toBe(false); |
|
||||||
|
|
||||||
// Visual update only
|
|
||||||
mockUpdateGraph('tag-filter'); |
|
||||||
expect(mockUpdateGraph).toHaveBeenCalledWith('tag-filter'); |
|
||||||
}); |
|
||||||
}); |
|
||||||
|
|
||||||
describe('Performance and Memory Management', () => { |
|
||||||
it('should debounce rapid parameter changes', async () => { |
|
||||||
const debounceDelay = 100; |
|
||||||
let pendingUpdate: any = null; |
|
||||||
let updateTimer: any = null; |
|
||||||
|
|
||||||
const debouncedUpdate = (type: string) => { |
|
||||||
if (updateTimer) clearTimeout(updateTimer); |
|
||||||
|
|
||||||
pendingUpdate = type; |
|
||||||
updateTimer = setTimeout(() => { |
|
||||||
mockUpdateGraph(pendingUpdate); |
|
||||||
pendingUpdate = null; |
|
||||||
}, debounceDelay); |
|
||||||
}; |
|
||||||
|
|
||||||
// Rapid changes
|
|
||||||
debouncedUpdate('change1'); |
|
||||||
debouncedUpdate('change2'); |
|
||||||
debouncedUpdate('change3'); |
|
||||||
|
|
||||||
// Should not have called update yet
|
|
||||||
expect(mockUpdateGraph).not.toHaveBeenCalled(); |
|
||||||
|
|
||||||
// Wait for debounce
|
|
||||||
await new Promise(resolve => setTimeout(resolve, debounceDelay + 10)); |
|
||||||
|
|
||||||
// Should only call once with last change
|
|
||||||
expect(mockUpdateGraph).toHaveBeenCalledTimes(1); |
|
||||||
expect(mockUpdateGraph).toHaveBeenCalledWith('change3'); |
|
||||||
}); |
|
||||||
|
|
||||||
it('should clean up position cache for removed nodes', () => { |
|
||||||
const positionCache = new Map<string, { x: number; y: number }>(); |
|
||||||
const maxCacheSize = 1000; |
|
||||||
|
|
||||||
// Add positions
|
|
||||||
for (let i = 0; i < 1500; i++) { |
|
||||||
positionCache.set(`node${i}`, { x: i * 10, y: i * 10 }); |
|
||||||
} |
|
||||||
|
|
||||||
// Clean up old entries if cache too large
|
|
||||||
if (positionCache.size > maxCacheSize) { |
|
||||||
const entriesToKeep = Array.from(positionCache.entries()) |
|
||||||
.slice(-maxCacheSize); |
|
||||||
positionCache.clear(); |
|
||||||
entriesToKeep.forEach(([k, v]) => positionCache.set(k, v)); |
|
||||||
} |
|
||||||
|
|
||||||
expect(positionCache.size).toBe(maxCacheSize); |
|
||||||
}); |
|
||||||
|
|
||||||
it('should restart simulation efficiently', () => { |
|
||||||
const needsSimulationRestart = (paramChanged: string) => { |
|
||||||
const restartParams = ['starVisualization', 'showTagAnchors']; |
|
||||||
return restartParams.includes(paramChanged); |
|
||||||
}; |
|
||||||
|
|
||||||
// Test various parameter changes
|
|
||||||
expect(needsSimulationRestart('starVisualization')).toBe(true); |
|
||||||
expect(needsSimulationRestart('showTagAnchors')).toBe(true); |
|
||||||
expect(needsSimulationRestart('selectedTagType')).toBe(false); |
|
||||||
|
|
||||||
// Only restart when necessary
|
|
||||||
if (needsSimulationRestart('starVisualization')) { |
|
||||||
mockRestartSimulation(); |
|
||||||
} |
|
||||||
|
|
||||||
expect(mockRestartSimulation).toHaveBeenCalledTimes(1); |
|
||||||
}); |
|
||||||
}); |
|
||||||
|
|
||||||
describe('Edge Cases and Error Handling', () => { |
|
||||||
it('should handle empty event arrays gracefully', () => { |
|
||||||
const events: NDKEvent[] = []; |
|
||||||
const graph = { nodes: [], links: [] }; |
|
||||||
|
|
||||||
// Should not crash with empty data
|
|
||||||
expect(() => { |
|
||||||
if (events.length === 0) { |
|
||||||
mockUpdateGraph('empty-data'); |
|
||||||
} |
|
||||||
}).not.toThrow(); |
|
||||||
|
|
||||||
expect(mockUpdateGraph).toHaveBeenCalledWith('empty-data'); |
|
||||||
}); |
|
||||||
|
|
||||||
it('should handle parameter validation', () => { |
|
||||||
const validateParams = (params: any) => { |
|
||||||
const errors: string[] = []; |
|
||||||
|
|
||||||
if (params.networkFetchLimit < 1) { |
|
||||||
errors.push('networkFetchLimit must be >= 1'); |
|
||||||
} |
|
||||||
if (params.levelsToRender < 0) { |
|
||||||
errors.push('levelsToRender must be >= 0'); |
|
||||||
} |
|
||||||
if (params.tagExpansionDepth < 0 || params.tagExpansionDepth > 10) { |
|
||||||
errors.push('tagExpansionDepth must be between 0 and 10'); |
|
||||||
} |
|
||||||
|
|
||||||
return errors; |
|
||||||
}; |
|
||||||
|
|
||||||
const invalidParams = { |
|
||||||
networkFetchLimit: 0, |
|
||||||
levelsToRender: -1, |
|
||||||
tagExpansionDepth: 15 |
|
||||||
}; |
|
||||||
|
|
||||||
const errors = validateParams(invalidParams); |
|
||||||
expect(errors).toHaveLength(3); |
|
||||||
expect(errors).toContain('networkFetchLimit must be >= 1'); |
|
||||||
}); |
|
||||||
|
|
||||||
it('should handle concurrent updates safely', async () => { |
|
||||||
let isUpdating = false; |
|
||||||
const updates: string[] = []; |
|
||||||
|
|
||||||
const safeUpdate = async (type: string) => { |
|
||||||
if (isUpdating) { |
|
||||||
// Queue update
|
|
||||||
return new Promise(resolve => { |
|
||||||
setTimeout(() => safeUpdate(type).then(resolve), 10); |
|
||||||
}); |
|
||||||
} |
|
||||||
|
|
||||||
isUpdating = true; |
|
||||||
updates.push(type); |
|
||||||
await new Promise(resolve => setTimeout(resolve, 50)); |
|
||||||
isUpdating = false; |
|
||||||
}; |
|
||||||
|
|
||||||
// Trigger concurrent updates
|
|
||||||
const promises = [ |
|
||||||
safeUpdate('update1'), |
|
||||||
safeUpdate('update2'), |
|
||||||
safeUpdate('update3') |
|
||||||
]; |
|
||||||
|
|
||||||
await Promise.all(promises); |
|
||||||
|
|
||||||
// All updates should complete
|
|
||||||
expect(updates).toHaveLength(3); |
|
||||||
}); |
|
||||||
}); |
|
||||||
}); |
|
||||||
Loading…
Reference in new issue