Skip to content
Merged

Dev #650

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
ee45a38
Allow PowerShell dotnet test commands in settings
TheJoeFin Jun 11, 2026
704a935
Enable word merging regardless of table mode
TheJoeFin Jun 11, 2026
868380c
Refactor word border merging test, add Magick image tests
TheJoeFin Jun 11, 2026
bee0fe5
update packages and add success and loading indicator
TheJoeFin Jun 11, 2026
5d888c2
Reduce flash duration to 300ms and refactor timer init
TheJoeFin Jun 11, 2026
acf9e03
Lazily build WordBorder context menu in code-behind
TheJoeFin Jun 13, 2026
3f9bc42
Format feet/inches output and fix decimal parsing bugs
TheJoeFin Jun 13, 2026
8080c4f
Refactor UndoRedo transaction logic and add tests
TheJoeFin Jun 13, 2026
fd7adf3
Add ImageChangeDetector utility and unit tests
TheJoeFin Jun 13, 2026
0f2b49a
Add ProtocolUtilities for text-grab:// URI handling
TheJoeFin Jun 13, 2026
7c62d02
Enable dynamic spell check, optimize CanExecute, cache OCR
TheJoeFin Jun 13, 2026
2762312
Add live content change detection and memory cleanup to GrabFrame
TheJoeFin Jun 13, 2026
89a25d7
Add text-grab:// protocol support and browser ext test
TheJoeFin Jun 13, 2026
289a52f
Add EtwSpellCheckMode user setting with default "Auto"
TheJoeFin Jun 16, 2026
4ee4809
Add configurable spell check modes to Edit Text Window
TheJoeFin Jun 16, 2026
fcbde33
Skip loading indicator for UI Automation language
TheJoeFin Jun 16, 2026
49f5be1
Add IsTextOnly and ApplyTextOnlyTemplate support
TheJoeFin Jun 17, 2026
1d929d9
Add dynamic "Apply Grab Template" options to Edit menu
TheJoeFin Jun 17, 2026
bd2a7fb
Add template apply & regex quick ref to Find/Replace dialogs
TheJoeFin Jun 17, 2026
2e31f87
update comments
TheJoeFin Jun 20, 2026
27ff3c3
Secure protocol file path validation and tests
TheJoeFin Jun 20, 2026
808db70
Update NCalcAsync and Markdig package versions
TheJoeFin Jun 20, 2026
9bec147
bump version to 1.14.2
TheJoeFin Jun 20, 2026
3f80f00
Merge pull request #649 from TheJoeFin/fixes-and-protocol
TheJoeFin Jun 20, 2026
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
5 changes: 4 additions & 1 deletion .claude/settings.local.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@
"Bash(bin/pdm *)",
"Bash(Get-ChildItem -Path \"D:/source/TheJoeFin/Text-Grab/Text-Grab/\" -Directory)",
"Bash(Select-Object Name)",
"PowerShell(dotnet build *)"
"PowerShell(dotnet build *)",
"PowerShell(dotnet test *)",
"PowerShell(Get-ChildItem *)",
"WebFetch(domain:raw.githubusercontent.com)"
],
"deny": []
}
Expand Down
50 changes: 20 additions & 30 deletions Tests/CalculatorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ public class CalculatorTests
public async Task NCalc_HasBuiltInPi_ReturnsFalse()
{
// Test if NCalc has built-in Pi constant
AsyncExpression expression = new("Pi");
Expression expression = new("Pi");

NCalcParameterNotDefinedException exception = await Assert.ThrowsAsync<NCalcParameterNotDefinedException>(async () => await expression.EvaluateAsync(TestContext.Current.CancellationToken));
Assert.Contains("Pi", exception.Message);
Expand All @@ -21,7 +21,7 @@ public async Task NCalc_HasBuiltInPi_ReturnsFalse()
public async Task NCalc_HasBuiltInE_ReturnsFalse()
{
// Test if NCalc has built-in E constant
AsyncExpression expression = new("E");
Expression expression = new("E");

NCalcParameterNotDefinedException exception = await Assert.ThrowsAsync<NCalcParameterNotDefinedException>(async () => await expression.EvaluateAsync(TestContext.Current.CancellationToken));
Assert.Contains("E", exception.Message);
Expand All @@ -44,16 +44,15 @@ public async Task NCalc_SupportsBasicMathFunctions()

foreach ((string? expr, double expected) in tests)
{
AsyncExpression expression = new(expr);
Expression expression = new(expr);

// Add E parameter for the Log test
if (expr.Contains('E'))
{
expression.EvaluateParameterAsync += (name, args) =>
expression.EvaluateParameter += (name, args) =>
{
if (name == "E")
args.Result = Math.E;
return ValueTask.CompletedTask;
};
}

Expand All @@ -67,12 +66,11 @@ public async Task NCalc_SupportsBasicMathFunctions()
public async Task NCalc_WithCustomPiParameter_Works()
{
// Test that we can add Pi as a parameter
AsyncExpression expression = new("Sin(Pi/2)");
expression.EvaluateParameterAsync += (name, args) =>
Expression expression = new("Sin(Pi/2)");
expression.EvaluateParameter += (name, args) =>
{
if (name == "Pi")
args.Result = Math.PI;
return ValueTask.CompletedTask;
};

double result = Convert.ToDouble(await expression.EvaluateAsync(TestContext.Current.CancellationToken));
Expand All @@ -84,12 +82,11 @@ public async Task NCalc_WithCustomPiParameter_Works()
public async Task NCalc_WithCustomEParameter_Works()
{
// Test that we can add E as a parameter
AsyncExpression expression = new("Log(E, E)"); // Log(value, base) format
expression.EvaluateParameterAsync += (name, args) =>
Expression expression = new("Log(E, E)"); // Log(value, base) format
expression.EvaluateParameter += (name, args) =>
{
if (name == "E")
args.Result = Math.E;
return ValueTask.CompletedTask;
};

double result = Convert.ToDouble(await expression.EvaluateAsync(TestContext.Current.CancellationToken));
Expand All @@ -101,16 +98,15 @@ public async Task NCalc_WithCustomEParameter_Works()
public async Task NCalc_WithMultipleMathConstants_Works()
{
// Test multiple math constants together
AsyncExpression expression = new("Pi * E");
expression.EvaluateParameterAsync += (name, args) =>
Expression expression = new("Pi * E");
expression.EvaluateParameter += (name, args) =>
{
args.Result = name switch
{
"Pi" => Math.PI,
"E" => Math.E,
_ => throw new ArgumentException($"Unknown parameter: {name}")
};
return ValueTask.CompletedTask;
};

double result = Convert.ToDouble(await expression.EvaluateAsync(TestContext.Current.CancellationToken));
Expand Down Expand Up @@ -141,12 +137,11 @@ public void MathConstants_HaveCorrectValues(string constantName, double expected
public async Task NCalc_WithTauConstant_Works()
{
// Test that we can use Tau (2*Pi)
AsyncExpression expression = new("Tau/2");
expression.EvaluateParameterAsync += (name, args) =>
Expression expression = new("Tau/2");
expression.EvaluateParameter += (name, args) =>
{
if (name == "Tau")
args.Result = Math.Tau;
return ValueTask.CompletedTask;
};

double result = Convert.ToDouble(await expression.EvaluateAsync(TestContext.Current.CancellationToken));
Expand All @@ -172,8 +167,8 @@ public async Task NCalc_CaseInsensitive_MathConstants()

foreach ((string? constantName, double expectedValue) in testCases)
{
AsyncExpression expression = new(constantName, ExpressionOptions.IgnoreCaseAtBuiltInFunctions);
expression.EvaluateParameterAsync += (name, args) =>
Expression expression = new(constantName, ExpressionOptions.IgnoreCaseAtBuiltInFunctions);
expression.EvaluateParameter += (name, args) =>
{
args.Result = name.ToLower() switch
{
Expand All @@ -182,7 +177,6 @@ public async Task NCalc_CaseInsensitive_MathConstants()
"tau" => Math.Tau,
_ => throw new ArgumentException($"Unknown parameter: {name}")
};
return ValueTask.CompletedTask;
};

double result = Convert.ToDouble(await expression.EvaluateAsync(TestContext.Current.CancellationToken));
Expand All @@ -195,16 +189,15 @@ public async Task NCalc_CaseInsensitive_MathConstants()
public async Task NCalc_ComplexMathExpression_WithConstants()
{
// Test complex expression using multiple constants
AsyncExpression expression = new("Sin(Pi/6) + Cos(Pi/3) + Log(E, E)"); // Using Log(value, base)
expression.EvaluateParameterAsync += (name, args) =>
Expression expression = new("Sin(Pi/6) + Cos(Pi/3) + Log(E, E)"); // Using Log(value, base)
expression.EvaluateParameter += (name, args) =>
{
args.Result = name switch
{
"Pi" => Math.PI,
"E" => Math.E,
_ => throw new ArgumentException($"Unknown parameter: {name}")
};
return ValueTask.CompletedTask;
};

double result = Convert.ToDouble(await expression.EvaluateAsync(TestContext.Current.CancellationToken));
Expand All @@ -217,17 +210,16 @@ public async Task NCalc_ComplexMathExpression_WithConstants()
public async Task AsyncNCalc_WithMathConstants_Works()
{
// Test async version with constants
AsyncExpression expression = new("Sqrt(Pi * E)", ExpressionOptions.IgnoreCaseAtBuiltInFunctions);
Expression expression = new("Sqrt(Pi * E)", ExpressionOptions.IgnoreCaseAtBuiltInFunctions);

expression.EvaluateParameterAsync += (name, args) =>
expression.EvaluateParameter += (name, args) =>
{
args.Result = name.ToLower() switch
{
"pi" => Math.PI,
"e" => Math.E,
_ => throw new ArgumentException($"Unknown parameter: {name}")
};
return ValueTask.CompletedTask;
};

double result = Convert.ToDouble(await expression.EvaluateAsync(TestContext.Current.CancellationToken));
Expand All @@ -247,9 +239,9 @@ public async Task AsyncNCalc_WithMathConstants_Works()
public async Task MathConstants_Integration_Test(string constantName, double expectedValue)
{
// Test the TryGetMathConstant method logic using realistic expressions
AsyncExpression expression = new(constantName, ExpressionOptions.IgnoreCaseAtBuiltInFunctions);
Expression expression = new(constantName, ExpressionOptions.IgnoreCaseAtBuiltInFunctions);

expression.EvaluateParameterAsync += (name, args) =>
expression.EvaluateParameter += (name, args) =>
{
// Simulate the TryGetMathConstant logic
double value = name.ToLowerInvariant() switch
Expand All @@ -270,8 +262,6 @@ public async Task MathConstants_Integration_Test(string constantName, double exp

if (!double.IsNaN(value))
args.Result = value;

return ValueTask.CompletedTask;
};

double result = Convert.ToDouble(await expression.EvaluateAsync(TestContext.Current.CancellationToken));
Expand Down
30 changes: 30 additions & 0 deletions Tests/ClipboardUtilitiesTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -134,4 +134,34 @@ public void ConvertHtmlToTabSeparated_HandlesRowspan()
Assert.Equal("Tall\tTop", lines[0]);
Assert.Equal("Tall\tBottom", lines[1]);
}

// The Text Grab browser extension's Table mode (including its layout
// reconstruction fallback for non-<table> grids) writes a clean
// <table><tr><td>…</td></tr></table> to the clipboard with <br> for
// newlines and &amp;-style entity escaping, then hands off via
// text-grab://paste-spreadsheet. This pins compatibility with that exact
// output (see Text-Grab-Extension/lib/formats.js -> toCleanHtmlTable).
private const string ExtensionRegionTableCfHtml = """
Version:0.9
StartHTML:00000097
EndHTML:00000260
StartFragment:00000131
EndFragment:00000224
<html><body>
<!--StartFragment--><table><tr><td>Product</td><td>Qty</td><td>Unit price</td></tr><tr><td>USB-C hub</td><td>12</td><td>$24.50</td></tr><tr><td>Monitor<br>arm</td><td>5</td><td>$130 &amp; up</td></tr></table><!--EndFragment-->
</body></html>
""";

[Fact]
public void ConvertHtmlToTabSeparated_ParsesBrowserExtensionRegionTable()
{
string result = ClipboardUtilities.ConvertHtmlToTabSeparated(ExtensionRegionTableCfHtml);

string[] lines = result.Split('\n');
Assert.Equal(3, lines.Length);
Assert.Equal("Product\tQty\tUnit price", lines[0]);
Assert.Equal("USB-C hub\t12\t$24.50", lines[1]);
// <br> collapses to a space; &amp; decodes to &.
Assert.Equal("Monitor arm\t5\t$130 & up", lines[2]);
}
}
108 changes: 108 additions & 0 deletions Tests/EditTextWindowSpellCheckTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
using Text_Grab;

namespace Tests;

public class EditTextWindowSpellCheckTests
{
[Fact]
public void NormalSentence_SpellCheckEnabled()
{
string text = "The quick brown fox jumps over the lazy dog.";
Assert.True(EditTextWindow.ShouldEnableSpellCheck(text));
}

[Fact]
public void EmptyString_SpellCheckEnabled()
{
Assert.True(EditTextWindow.ShouldEnableSpellCheck(string.Empty));
}

[Fact]
public void TextExceedsLengthThreshold_SpellCheckDisabled()
{
string longText = new string('a', 10_001);
Assert.False(EditTextWindow.ShouldEnableSpellCheck(longText));
}

[Fact]
public void TwoLongWords_SpellCheckEnabled()
{
// Only 2 long words — below the threshold of 3
string text = "normal words then SomeVeryLongManifestTokenThatIsOver25Chars and AnotherReallyLongTokenHere123 end";
Assert.True(EditTextWindow.ShouldEnableSpellCheck(text));
}

[Fact]
public void ThreeLongWords_SpellCheckDisabled()
{
// 3 words each >= 25 chars → should disable spell check
string text = "Microsoft.Windows.AppManifest.Version1234 " +
"com.example.application.package.name.v2 " +
"SomeGuidLike_1234567890abcdef1234 " +
"normal short words";
Assert.False(EditTextWindow.ShouldEnableSpellCheck(text));
}

[Fact]
public void AppManifestLikeContent_SpellCheckDisabled()
{
string manifest = """
<?xml version="1.0" encoding="utf-8"?>
<Package xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
xmlns:mp="http://schemas.microsoft.com/appx/2014/phone/manifest"
IgnorableNamespaces="uap mp">
<Identity Name="Microsoft.TextGrab" Publisher="CN=TheJoeFin" Version="4.0.0.0" />
</Package>
""";
Assert.False(EditTextWindow.ShouldEnableSpellCheck(manifest));
}

[Fact]
public void WordExactlyAtLongWordLength_NotCountedAsLong()
{
// Word of exactly 24 chars should NOT count as "very long"
string word24 = new string('x', 24);
string word25 = new string('y', 25);
// Two words of 24 + one of 25 = only one long word → still enabled
string text = $"{word24} {word24} {word25} normal text";
Assert.True(EditTextWindow.ShouldEnableSpellCheck(text));
}

[Fact]
public void GuidTokens_SpellCheckDisabled()
{
// GUIDs are 32+ chars without hyphens when copy-pasted from some apps
string text = "id=a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4 " +
"token=f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2 " +
"hash=1234567890abcdef1234567890abcdef12";
Assert.False(EditTextWindow.ShouldEnableSpellCheck(text));
}

[Fact]
public void AlwaysOnMode_EnabledEvenForContentAutoWouldReject()
{
// Content Auto mode would disable (3+ long tokens), but Always On forces it on
string text = "Microsoft.Windows.AppManifest.Version1234 " +
"com.example.application.package.name.v2 " +
"SomeGuidLike_1234567890abcdef1234";
Assert.False(EditTextWindow.ShouldEnableSpellCheck(SpellCheckMode.Auto, text));
Assert.True(EditTextWindow.ShouldEnableSpellCheck(SpellCheckMode.AlwaysOn, text));
}

[Fact]
public void OffMode_DisabledEvenForNormalText()
{
string text = "The quick brown fox jumps over the lazy dog.";
Assert.True(EditTextWindow.ShouldEnableSpellCheck(SpellCheckMode.Auto, text));
Assert.False(EditTextWindow.ShouldEnableSpellCheck(SpellCheckMode.Off, text));
}

[Fact]
public void AutoMode_MatchesContentHeuristic()
{
string normal = "The quick brown fox.";
string longTokens = new string('a', 10_001);
Assert.True(EditTextWindow.ShouldEnableSpellCheck(SpellCheckMode.Auto, normal));
Assert.False(EditTextWindow.ShouldEnableSpellCheck(SpellCheckMode.Auto, longTokens));
}
}
13 changes: 5 additions & 8 deletions Tests/GrabFrameTableModeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,14 @@ namespace Tests;
public class GrabFrameTableModeTests
{
[Theory]
[InlineData(false, 2, true)]
[InlineData(false, 1, false)]
[InlineData(true, 2, false)]
public void ShouldAllowWordBorderMerging_DisablesMergingInTableMode(
bool isTableModeSelected,
[InlineData(2, true)]
[InlineData(1, false)]
[InlineData(0, false)]
public void ShouldAllowWordBorderMerging_RequiresMultipleSelectedWordBorders(
int selectedWordBorderCount,
bool expected)
{
bool actual = GrabFrame.ShouldAllowWordBorderMerging(
isTableModeSelected,
selectedWordBorderCount);
bool actual = GrabFrame.ShouldAllowWordBorderMerging(selectedWordBorderCount);

Assert.Equal(expected, actual);
}
Expand Down
Loading