Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/ExcelMcp.Core/Commands/PowerQuery/IPowerQueryCommands.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,11 @@ namespace Sbroenne.ExcelMcp.Core.Commands;
///
/// TARGET CELL: targetCellAddress places tables without clearing sheet.
/// TIMEOUT: 5 min auto-timeout for refresh/load. For network queries, use timeout=120 or higher.
/// timeout=0 is INVALID - must be greater than zero.
/// timeout=0 or omitted uses the 5 min default.
/// </summary>
[ServiceCategory("powerquery", "PowerQuery")]
[McpTool("powerquery", Title = "Power Query Operations", Destructive = true, Category = "query",
Description = "Power Query M code and data loading. TEST-FIRST WORKFLOW: 1. evaluate (test M code without persisting) 2. create/update (store validated query) 3. refresh/load-to (load data to destination). IF CREATE FAILS: Use evaluate for detailed M engine error. DATETIME: Always include Table.TransformColumnTypes() for explicit column types. DESTINATIONS: worksheet (default), data-model (for DAX), both, connection-only. M-CODE: Auto-formatted via powerqueryformatter.com. TARGET CELL: targetCellAddress places tables without clearing sheet. TIMEOUT: 5 min auto-timeout. For network queries, use timeout=120 or higher. timeout=0 is INVALID.")]
Description = "Power Query M code and data loading. TEST-FIRST WORKFLOW: 1. evaluate (test M code without persisting) 2. create/update (store validated query) 3. refresh/load-to (load data to destination). IF CREATE FAILS: Use evaluate for detailed M engine error. DATETIME: Always include Table.TransformColumnTypes() for explicit column types. DESTINATIONS: worksheet (default), data-model (for DAX), both, connection-only. M-CODE: Auto-formatted via powerqueryformatter.com. TARGET CELL: targetCellAddress places tables without clearing sheet. TIMEOUT: 5 min auto-timeout. For network queries, use timeout=120 or higher. timeout=0 or omitted uses the 5 min default.")]
public interface IPowerQueryCommands
{
/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ public PowerQueryRefreshResult Refresh(IExcelBatch batch, string queryName, Time

if (timeout <= TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(nameof(timeout), "Timeout must be greater than zero.");
timeout = TimeSpan.FromMinutes(5); // Default timeout when not specified
}

using var timeoutCts = new CancellationTokenSource(timeout);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,68 @@ public void Refresh_ValidDataModelQuery_Succeeds()
Assert.False(result.HasErrors);
}

/// <summary>
/// Regression test: Refresh with TimeSpan.Zero timeout should use the default 5-minute timeout
/// instead of throwing ArgumentOutOfRangeException.
///
/// BUG: The source generator produces `args.Timeout ?? default(TimeSpan)` = TimeSpan.Zero
/// when no timeout is supplied (CLI without --timeout, MCP without timeout parameter).
/// The Core method used to throw ArgumentOutOfRangeException("Timeout must be greater than zero.").
/// FIX: TimeSpan.Zero now falls back to 5-minute default.
/// </summary>
[Fact]
public void Refresh_ZeroTimeout_UsesDefaultAndSucceeds()
{
// Arrange - Create a valid worksheet query
var testExcelFile = _fixture.CreateTestFile();
var queryName = "ZeroTimeoutQuery";

var validMCode = @"let
Source = #table({""Name""}, {{""Test""}})
in
Source";

using var batch = ExcelSession.BeginBatch(testExcelFile);

_powerQueryCommands.Create(batch, queryName, validMCode, PowerQueryLoadMode.LoadToTable);
batch.Save();

// Act - Refresh with TimeSpan.Zero (the exact value generated when timeout is omitted)
var result = _powerQueryCommands.Refresh(batch, queryName, TimeSpan.Zero);

// Assert - Should succeed, not throw ArgumentOutOfRangeException
Assert.True(result.Success, $"Refresh failed: {result.ErrorMessage}");
Assert.False(result.HasErrors);
}

/// <summary>
/// Regression test: Refresh with negative timeout should use the default 5-minute timeout.
/// </summary>
[Fact]
public void Refresh_NegativeTimeout_UsesDefaultAndSucceeds()
{
// Arrange - Create a valid worksheet query
var testExcelFile = _fixture.CreateTestFile();
var queryName = "NegativeTimeoutQuery";

var validMCode = @"let
Source = #table({""Name""}, {{""Test""}})
in
Source";

using var batch = ExcelSession.BeginBatch(testExcelFile);

_powerQueryCommands.Create(batch, queryName, validMCode, PowerQueryLoadMode.LoadToTable);
batch.Save();

// Act - Refresh with negative timeout
var result = _powerQueryCommands.Refresh(batch, queryName, TimeSpan.FromSeconds(-1));

// Assert - Should succeed, not throw ArgumentOutOfRangeException
Assert.True(result.Success, $"Refresh failed: {result.ErrorMessage}");
Assert.False(result.HasErrors);
}

/// <summary>
/// Verifies that refresh throws when query is connection-only (no QueryTable or DataModel connection).
/// Connection-only queries have no mechanism to refresh - they're only query definitions.
Expand Down