< Summary

Information
Class: Chronicis.Client.Services.Tree.TreeMutations
Assembly: Chronicis.Client
File(s): /home/runner/work/chronicis/chronicis/src/Chronicis.Client/Services/Tree/TreeMutations.cs
Line coverage
78%
Covered lines: 141
Uncovered lines: 38
Coverable lines: 179
Total lines: 459
Line coverage: 78.7%
Branch coverage
73%
Covered branches: 84
Total branches: 115
Branch coverage: 73%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
SetNodeIndex(...)100%11100%
SetRefreshCallback(...)100%11100%
CreateRootArticleAsync()83.33%6690.47%
CreateChildArticleAsync()71.42%141491.66%
DetermineChildArticleType(...)10%431030.76%
DeleteArticleAsync()100%9876.92%
MoveArticleAsync()75%141275%
MoveToVirtualGroupAsync()63.15%281971.05%
MoveToArticleOrRootAsync()92.85%181472.22%
IsDescendantOf(...)83.33%121288.88%
UpdateNodeDisplay(...)100%44100%
UpdateNodeVisibility(...)100%44100%
IsValidArticle(...)100%44100%
CanAcceptChildren(...)50%5466.66%
IsValidDropTarget(...)50%5466.66%

File(s)

/home/runner/work/chronicis/chronicis/src/Chronicis.Client/Services/Tree/TreeMutations.cs

#LineLine coverage
 1using Chronicis.Client.Models;
 2using Chronicis.Shared.DTOs;
 3using Chronicis.Shared.Enums;
 4
 5namespace Chronicis.Client.Services.Tree;
 6
 7/// <summary>
 8/// Handles CRUD operations for tree nodes: create, move, delete, and update.
 9/// This component makes API calls and coordinates with TreeUiState for post-mutation state updates.
 10/// </summary>
 11internal sealed class TreeMutations
 12{
 13    private readonly IArticleApiService _articleApi;
 14    private readonly IAppContextService _appContext;
 15    private readonly ILogger _logger;
 16
 17    // Shared node index (owned by TreeStateService, passed in)
 2718    private TreeNodeIndex _nodeIndex = new();
 19
 20    // Callback to trigger tree refresh after mutations
 21    private Func<Task>? _refreshCallback;
 22
 2723    public TreeMutations(
 2724        IArticleApiService articleApi,
 2725        IAppContextService appContext,
 2726        ILogger logger)
 27    {
 2728        _articleApi = articleApi;
 2729        _appContext = appContext;
 2730        _logger = logger;
 2731    }
 32
 33    // ============================================
 34    // Initialization
 35    // ============================================
 36
 37    /// <summary>
 38    /// Sets the node index reference. Called after tree is built.
 39    /// </summary>
 40    public void SetNodeIndex(TreeNodeIndex nodeIndex)
 41    {
 2742        _nodeIndex = nodeIndex;
 2743    }
 44
 45    /// <summary>
 46    /// Sets the callback to invoke when tree needs to be refreshed after a mutation.
 47    /// </summary>
 48    public void SetRefreshCallback(Func<Task> refreshCallback)
 49    {
 2750        _refreshCallback = refreshCallback;
 2751    }
 52
 53    // ============================================
 54    // Create Operations
 55    // ============================================
 56
 57    /// <summary>
 58    /// Creates a new root-level article in the current world.
 59    /// </summary>
 60    /// <returns>The ID of the created article, or null if creation failed.</returns>
 61    public async Task<Guid?> CreateRootArticleAsync()
 62    {
 263        var worldId = _appContext.CurrentWorldId;
 264        if (!worldId.HasValue)
 65        {
 166            _logger.LogWarning("Cannot create root article: no world selected");
 167            return null;
 68        }
 69
 170        var createDto = new ArticleCreateDto
 171        {
 172            Title = string.Empty,
 173            Body = string.Empty,
 174            ParentId = null,
 175            WorldId = worldId,
 176            Type = ArticleType.WikiArticle,
 177            EffectiveDate = DateTime.Now
 178        };
 79
 180        var created = await _articleApi.CreateArticleAsync(createDto);
 181        if (created == null)
 82        {
 083            _logger.LogWarning("Failed to create root article");
 084            return null;
 85        }
 86
 87        // Refresh tree to show new article
 188        if (_refreshCallback != null)
 89        {
 190            await _refreshCallback();
 91        }
 92
 193        return created.Id;
 294    }
 95
 96    /// <summary>
 97    /// Creates a new child article under the specified parent.
 98    /// </summary>
 99    /// <param name="parentId">The parent node ID (can be article, arc, or virtual group).</param>
 100    /// <returns>The ID of the created article, or null if creation failed.</returns>
 101    public async Task<Guid?> CreateChildArticleAsync(Guid parentId)
 102    {
 2103        if (!_nodeIndex.TryGetNode(parentId, out var parentNode) || parentNode == null)
 104        {
 1105            _logger.LogWarning("Cannot create child: parent {ParentId} not found", parentId);
 1106            return null;
 107        }
 108
 109        // Determine article type based on parent
 1110        var articleType = DetermineChildArticleType(parentNode);
 111
 112        // For virtual groups, parent is null (top-level in that category)
 1113        Guid? actualParentId = parentNode.NodeType == TreeNodeType.VirtualGroup ? null : parentId;
 114
 1115        var createDto = new ArticleCreateDto
 1116        {
 1117            Title = string.Empty,
 1118            Body = string.Empty,
 1119            ParentId = actualParentId,
 1120            WorldId = parentNode.WorldId ?? _appContext.CurrentWorldId,
 1121            CampaignId = parentNode.CampaignId,
 1122            ArcId = parentNode.NodeType == TreeNodeType.Arc ? parentNode.Id : parentNode.ArcId,
 1123            Type = articleType,
 1124            EffectiveDate = DateTime.Now
 1125        };
 126
 1127        var created = await _articleApi.CreateArticleAsync(createDto);
 1128        if (created == null)
 129        {
 0130            _logger.LogWarning("Failed to create child article under {ParentId}", parentId);
 0131            return null;
 132        }
 133
 134        // Refresh tree
 1135        if (_refreshCallback != null)
 136        {
 1137            await _refreshCallback();
 138        }
 139
 1140        return created.Id;
 2141    }
 142
 143    /// <summary>
 144    /// Determines the appropriate article type for a child based on its parent node.
 145    /// </summary>
 146    private static ArticleType DetermineChildArticleType(TreeNode parentNode)
 147    {
 1148        return parentNode.NodeType switch
 1149        {
 0150            TreeNodeType.VirtualGroup => parentNode.VirtualGroupType switch
 0151            {
 0152                VirtualGroupType.Wiki => ArticleType.WikiArticle,
 0153                VirtualGroupType.PlayerCharacters => ArticleType.Character,
 0154                VirtualGroupType.Uncategorized => ArticleType.Legacy,
 0155                _ => ArticleType.WikiArticle
 0156            },
 1157            TreeNodeType.Arc => ArticleType.Session,
 0158            TreeNodeType.Article => parentNode.ArticleType ?? ArticleType.WikiArticle,
 0159            _ => ArticleType.WikiArticle
 1160        };
 161    }
 162
 163    // ============================================
 164    // Delete Operations
 165    // ============================================
 166
 167    /// <summary>
 168    /// Deletes an article and all its descendants.
 169    /// </summary>
 170    /// <param name="articleId">The article ID to delete.</param>
 171    /// <returns>True if deletion succeeded.</returns>
 172    public async Task<bool> DeleteArticleAsync(Guid articleId)
 173    {
 3174        if (!_nodeIndex.TryGetNode(articleId, out var node) || node == null)
 175        {
 1176            return false;
 177        }
 178
 2179        if (node.NodeType != TreeNodeType.Article)
 180        {
 1181            _logger.LogWarning("Cannot delete non-article node {NodeId}", articleId);
 1182            return false;
 183        }
 184
 185        try
 186        {
 1187            await _articleApi.DeleteArticleAsync(articleId);
 188
 189            // Refresh tree
 1190            if (_refreshCallback != null)
 191            {
 1192                await _refreshCallback();
 193            }
 194
 1195            return true;
 196        }
 0197        catch (Exception ex)
 198        {
 0199            _logger.LogError(ex, "Failed to delete article {ArticleId}", articleId);
 0200            return false;
 201        }
 3202    }
 203
 204    // ============================================
 205    // Move Operations
 206    // ============================================
 207
 208    /// <summary>
 209    /// Moves an article to a new parent (or to root if newParentId is null).
 210    /// </summary>
 211    /// <param name="articleId">The article to move.</param>
 212    /// <param name="newParentId">The new parent ID, or null for root level.</param>
 213    /// <returns>True if move succeeded.</returns>
 214    public async Task<bool> MoveArticleAsync(Guid articleId, Guid? newParentId)
 215    {
 5216        if (!_nodeIndex.TryGetNode(articleId, out var node) || node == null)
 217        {
 0218            return false;
 219        }
 220
 5221        if (node.NodeType != TreeNodeType.Article)
 222        {
 0223            _logger.LogWarning("Cannot move non-article node");
 0224            return false;
 225        }
 226
 227        // Check if target is a virtual group
 5228        TreeNode? targetNode = null;
 5229        if (newParentId.HasValue)
 230        {
 5231            _nodeIndex.TryGetNode(newParentId.Value, out targetNode);
 232        }
 233
 234        // Handle drop onto virtual group specially
 5235        if (targetNode?.NodeType == TreeNodeType.VirtualGroup)
 236        {
 2237            return await MoveToVirtualGroupAsync(articleId, node, targetNode);
 238        }
 239
 240        // Regular move to another article or root
 3241        return await MoveToArticleOrRootAsync(articleId, node, newParentId, targetNode);
 5242    }
 243
 244    /// <summary>
 245    /// Handles moving an article to a virtual group.
 246    /// </summary>
 247    private async Task<bool> MoveToVirtualGroupAsync(Guid articleId, TreeNode node, TreeNode targetNode)
 248    {
 249        // Campaigns group holds Campaign entities, not articles
 2250        if (targetNode.VirtualGroupType == VirtualGroupType.Campaigns)
 251        {
 1252            _logger.LogWarning("Cannot drop articles into Campaigns group - campaigns are separate entities");
 1253            return false;
 254        }
 255
 256        // Links group holds external links, not articles
 1257        if (targetNode.VirtualGroupType == VirtualGroupType.Links)
 258        {
 0259            _logger.LogWarning("Cannot drop articles into Links group - links are separate entities");
 0260            return false;
 261        }
 262
 263        // Determine new article type based on target group
 1264        var newType = targetNode.VirtualGroupType switch
 1265        {
 1266            VirtualGroupType.Wiki => ArticleType.WikiArticle,
 0267            VirtualGroupType.PlayerCharacters => ArticleType.Character,
 0268            VirtualGroupType.Uncategorized => ArticleType.Legacy,
 0269            _ => node.ArticleType ?? ArticleType.WikiArticle
 1270        };
 271
 272        try
 273        {
 274            // First, move to root level (null parent)
 1275            var moveSuccess = await _articleApi.MoveArticleAsync(articleId, null);
 1276            if (!moveSuccess)
 277            {
 0278                _logger.LogWarning("Failed to move article to root level");
 0279                return false;
 280            }
 281
 282            // Then update the type if it changed
 1283            if (node.ArticleType != newType)
 284            {
 1285                var fullArticle = await _articleApi.GetArticleDetailAsync(articleId);
 1286                if (fullArticle != null)
 287                {
 1288                    var updateDto = new ArticleUpdateDto
 1289                    {
 1290                        Title = fullArticle.Title,
 1291                        Body = fullArticle.Body,
 1292                        Type = newType,
 1293                        IconEmoji = fullArticle.IconEmoji,
 1294                        EffectiveDate = fullArticle.EffectiveDate
 1295                    };
 296
 1297                    var updated = await _articleApi.UpdateArticleAsync(articleId, updateDto);
 1298                    if (updated == null)
 299                    {
 0300                        _logger.LogWarning("Failed to update article type");
 301                        // Move succeeded but type update failed - still refresh
 302                    }
 303                }
 304            }
 305
 1306            if (_refreshCallback != null)
 307            {
 1308                await _refreshCallback();
 309            }
 310
 1311            return true;
 312        }
 0313        catch (Exception ex)
 314        {
 0315            _logger.LogError(ex, "Failed to move article to virtual group");
 0316            return false;
 317        }
 2318    }
 319
 320    /// <summary>
 321    /// Handles moving an article to another article or to root level.
 322    /// </summary>
 323    private async Task<bool> MoveToArticleOrRootAsync(Guid articleId, TreeNode node, Guid? newParentId, TreeNode? target
 324    {
 325        // Prevent moving to self or descendant
 3326        if (newParentId.HasValue)
 327        {
 3328            if (newParentId.Value == articleId)
 329            {
 1330                _logger.LogWarning("Cannot move article to itself");
 1331                return false;
 332            }
 333
 2334            if (IsDescendantOf(newParentId.Value, articleId))
 335            {
 1336                _logger.LogWarning("Cannot move article to its descendant");
 1337                return false;
 338            }
 339
 340            // Ensure target is an article (not a World, Campaign, or Arc)
 1341            if (targetNode != null && targetNode.NodeType != TreeNodeType.Article)
 342            {
 0343                _logger.LogWarning("Cannot move article to non-article node type: {NodeType}", targetNode.NodeType);
 0344                return false;
 345            }
 346        }
 347
 348        try
 349        {
 1350            var success = await _articleApi.MoveArticleAsync(articleId, newParentId);
 351
 1352            if (success && _refreshCallback != null)
 353            {
 1354                await _refreshCallback();
 355            }
 356
 1357            return success;
 358        }
 0359        catch (Exception ex)
 360        {
 0361            _logger.LogError(ex, "Failed to move article {ArticleId}", articleId);
 0362            return false;
 363        }
 3364    }
 365
 366    /// <summary>
 367    /// Checks if a node is a descendant of a potential ancestor.
 368    /// </summary>
 369    public bool IsDescendantOf(Guid nodeId, Guid potentialAncestorId)
 370    {
 6371        if (!_nodeIndex.TryGetNode(nodeId, out var node) || node == null)
 0372            return false;
 373
 6374        var current = node;
 7375        while (current.ParentId.HasValue)
 376        {
 4377            if (current.ParentId.Value == potentialAncestorId)
 3378                return true;
 379
 1380            if (!_nodeIndex.TryGetNode(current.ParentId.Value, out var parent) || parent == null)
 381                break;
 382
 1383            current = parent;
 384        }
 385
 3386        return false;
 387    }
 388
 389    // ============================================
 390    // Update Operations
 391    // ============================================
 392
 393    /// <summary>
 394    /// Updates a node's display properties (title, icon) after an article is saved.
 395    /// This is a local-only update; does not make API calls.
 396    /// </summary>
 397    /// <returns>True if the node was found and updated.</returns>
 398    public bool UpdateNodeDisplay(Guid nodeId, string title, string? iconEmoji)
 399    {
 2400        if (_nodeIndex.TryGetNode(nodeId, out var node) && node != null)
 401        {
 1402            node.Title = title;
 1403            node.IconEmoji = iconEmoji;
 1404            return true;
 405        }
 1406        return false;
 407    }
 408
 409    /// <summary>
 410    /// Updates a node's visibility after privacy is toggled.
 411    /// This is a local-only update; does not make API calls.
 412    /// </summary>
 413    /// <returns>True if the node was found and updated.</returns>
 414    public bool UpdateNodeVisibility(Guid nodeId, ArticleVisibility visibility)
 415    {
 2416        if (_nodeIndex.TryGetNode(nodeId, out var node) && node != null)
 417        {
 1418            node.Visibility = visibility;
 1419            return true;
 420        }
 1421        return false;
 422    }
 423
 424    // ============================================
 425    // Validation Helpers
 426    // ============================================
 427
 428    /// <summary>
 429    /// Checks if a node exists and is an article.
 430    /// </summary>
 431    public bool IsValidArticle(Guid nodeId)
 432    {
 3433        return _nodeIndex.TryGetNode(nodeId, out var node) &&
 3434               node != null &&
 3435               node.NodeType == TreeNodeType.Article;
 436    }
 437
 438    /// <summary>
 439    /// Checks if a node can accept children.
 440    /// </summary>
 441    public bool CanAcceptChildren(Guid nodeId)
 442    {
 2443        if (!_nodeIndex.TryGetNode(nodeId, out var node) || node == null)
 0444            return false;
 445
 2446        return node.CanAddChildren;
 447    }
 448
 449    /// <summary>
 450    /// Checks if a node can be a drop target for drag-and-drop.
 451    /// </summary>
 452    public bool IsValidDropTarget(Guid nodeId)
 453    {
 2454        if (!_nodeIndex.TryGetNode(nodeId, out var node) || node == null)
 0455            return false;
 456
 2457        return node.IsDropTarget;
 458    }
 459}