feat: rebrand to MMD PDF, single corporate theme, and remove original text under an edit
- Services/PdfRedactText.cs: strip text whose origin falls inside a CoverAnnotation from the page content stream at save time, hooked into PdfBurn.DrawAnnotationsIntoDoc. Fixes edited values staying recoverable by text extraction. - Themes/MMD.xaml replaces all thirteen themes; picker and accent strip removed; no dark mode. - Rename KillerPDF -> MMD PDF across code, resources, packaging and locale strings; new icon. - Remove the upstream author credit and the in-app install button.
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
using System.Windows;
|
||||
using MmdPdf.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace MmdPdf.Tests;
|
||||
|
||||
// #169: rotating a page must keep its overlay annotations and turn them with the page.
|
||||
// The mapping runs in render-dim space; a +90 turn takes frame (W, H) to (H, W), so
|
||||
// round-trip tests swap the frame between passes exactly as the live reload does.
|
||||
public sealed class AnnotationRotateTests
|
||||
{
|
||||
private const double W = 800, H = 600;
|
||||
|
||||
[Fact]
|
||||
public void Highlight_QuarterTurn_TurnsWithTheContent()
|
||||
{
|
||||
var ha = new HighlightAnnotation { Bounds = new Rect(10, 20, 100, 40) };
|
||||
AnnotationRotate.Remap([ha], 90, W, H);
|
||||
|
||||
// Corners (10,20)-(110,60) map to (580,10)-(540,110): the region swaps its axes.
|
||||
Assert.Equal(540, ha.Bounds.X, 3);
|
||||
Assert.Equal(10, ha.Bounds.Y, 3);
|
||||
Assert.Equal(40, ha.Bounds.Width, 3);
|
||||
Assert.Equal(100, ha.Bounds.Height, 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Highlight_FourQuarterTurns_IsIdentity()
|
||||
{
|
||||
var start = new Rect(10, 20, 100, 40);
|
||||
var ha = new HighlightAnnotation { Bounds = start };
|
||||
AnnotationRotate.Remap([ha], 90, W, H);
|
||||
AnnotationRotate.Remap([ha], 90, H, W);
|
||||
AnnotationRotate.Remap([ha], 90, W, H);
|
||||
AnnotationRotate.Remap([ha], 90, H, W);
|
||||
Assert.Equal(start, ha.Bounds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Highlight_TurnThenCounterTurn_IsIdentity()
|
||||
{
|
||||
var start = new Rect(33, 44, 55, 66);
|
||||
var ha = new HighlightAnnotation { Bounds = start };
|
||||
AnnotationRotate.Remap([ha], 90, W, H);
|
||||
AnnotationRotate.Remap([ha], -90, H, W);
|
||||
Assert.Equal(start, ha.Bounds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Highlight_TwoQuarterTurns_MatchHalfTurn()
|
||||
{
|
||||
var a = new HighlightAnnotation { Bounds = new Rect(10, 20, 100, 40) };
|
||||
var b = new HighlightAnnotation { Bounds = new Rect(10, 20, 100, 40) };
|
||||
AnnotationRotate.Remap([a], 90, W, H);
|
||||
AnnotationRotate.Remap([a], 90, H, W);
|
||||
AnnotationRotate.Remap([b], 180, W, H);
|
||||
Assert.Equal(b.Bounds, a.Bounds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TextBox_KeepsSize_CenterFollowsThePage()
|
||||
{
|
||||
var ta = new TextAnnotation { Position = new Point(10, 70), Width = 100, Height = 40 };
|
||||
AnnotationRotate.Remap([ta], 90, W, H);
|
||||
|
||||
// Center (60,90) maps to (510,60); the box keeps 100x40 around it.
|
||||
Assert.Equal(460, ta.Position.X, 3);
|
||||
Assert.Equal(40, ta.Position.Y, 3);
|
||||
Assert.Equal(100, ta.Width, 3);
|
||||
Assert.Equal(40, ta.Height, 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UprightTextBoxNearLongEdge_IsClampedInsideRotatedPage()
|
||||
{
|
||||
// Fully inside the old 800x600 page. Keeping this tall box upright after a clockwise turn
|
||||
// would put its bottom at 870 on the new 600x800 page unless the remap clamps it.
|
||||
var ta = new TextAnnotation { Position = new Point(750, 100), Width = 40, Height = 200 };
|
||||
AnnotationRotate.Remap([ta], 90, W, H);
|
||||
|
||||
Assert.Equal(380, ta.Position.X, 3);
|
||||
Assert.Equal(600, ta.Position.Y, 3);
|
||||
Assert.InRange(ta.Position.X + ta.Width, 0, H);
|
||||
Assert.InRange(ta.Position.Y + ta.Height, 0, W);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UprightPlacedItemNearLongEdge_IsClampedInsideRotatedPage()
|
||||
{
|
||||
var image = new ImageAnnotation
|
||||
{
|
||||
Position = new Point(750, 100),
|
||||
SourceWidth = 40,
|
||||
SourceHeight = 200,
|
||||
Scale = 1
|
||||
};
|
||||
|
||||
AnnotationRotate.Remap([image], 90, W, H);
|
||||
|
||||
Assert.Equal(380, image.Position.X, 3);
|
||||
Assert.Equal(600, image.Position.Y, 3);
|
||||
Assert.InRange(image.Position.X + image.SourceWidth * image.Scale, 0, H);
|
||||
Assert.InRange(image.Position.Y + image.SourceHeight * image.Scale, 0, W);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InkPoints_MapPerPoint()
|
||||
{
|
||||
var ia = new InkAnnotation { Points = [new Point(0, 0), new Point(100, 50)] };
|
||||
AnnotationRotate.Remap([ia], 90, W, H);
|
||||
Assert.Equal(new Point(H, 0), ia.Points[0]);
|
||||
Assert.Equal(new Point(H - 50, 100), ia.Points[1]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using System.Windows;
|
||||
using Xunit;
|
||||
|
||||
namespace MmdPdf.Tests
|
||||
{
|
||||
public class FlyoutPlacementTests
|
||||
{
|
||||
[Fact]
|
||||
public void LeftRailUsesBottomLeftContentCorner()
|
||||
{
|
||||
var placement = FlyoutPlacement.PaneCorner(
|
||||
new Size(244, 460), new Size(1200, 700), alignRight: false)[0];
|
||||
|
||||
Assert.Equal(new Point(-16, 260), placement.Point);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RightRailMirrorsFlyoutToBottomRightContentCorner()
|
||||
{
|
||||
var placement = FlyoutPlacement.PaneCorner(
|
||||
new Size(244, 460), new Size(1200, 700), alignRight: true)[0];
|
||||
|
||||
Assert.Equal(new Point(972, 260), placement.Point);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TallFlyoutStaysBelowToolbarOnEitherSide()
|
||||
{
|
||||
var left = FlyoutPlacement.PaneCorner(
|
||||
new Size(244, 800), new Size(1200, 700), alignRight: false)[0];
|
||||
var right = FlyoutPlacement.PaneCorner(
|
||||
new Size(244, 800), new Size(1200, 700), alignRight: true)[0];
|
||||
|
||||
Assert.Equal(0, left.Point.Y);
|
||||
Assert.Equal(0, right.Point.Y);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
using System.IO;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Xml.Linq;
|
||||
using Xunit;
|
||||
|
||||
namespace MmdPdf.Tests;
|
||||
|
||||
public sealed class LocalizationParityTests
|
||||
{
|
||||
private static readonly string StringsDirectory = FindStringsDirectory();
|
||||
private static readonly XNamespace Xaml = "http://schemas.microsoft.com/winfx/2006/xaml";
|
||||
|
||||
[Fact]
|
||||
public void EveryLocaleHasTheEnglishKeysAndPlaceholders()
|
||||
{
|
||||
var english = ReadStrings(Path.Combine(StringsDirectory, "en-US.xaml"));
|
||||
foreach (var file in Directory.GetFiles(StringsDirectory, "*.xaml"))
|
||||
{
|
||||
var localized = ReadStrings(file);
|
||||
Assert.True(english.Keys.OrderBy(x => x).SequenceEqual(localized.Keys.OrderBy(x => x)),
|
||||
$"{Path.GetFileName(file)} does not contain exactly the English resource-key set.");
|
||||
|
||||
foreach (var key in english.Keys)
|
||||
{
|
||||
Assert.False(string.IsNullOrWhiteSpace(localized[key]),
|
||||
$"{Path.GetFileName(file)} has an empty value for {key}.");
|
||||
Assert.True(Placeholders(english[key]).SequenceEqual(Placeholders(localized[key])),
|
||||
$"{Path.GetFileName(file)} has different placeholders for {key}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Issue227SharedSurfacesHaveResourceKeys()
|
||||
{
|
||||
var english = ReadStrings(Path.Combine(StringsDirectory, "en-US.xaml"));
|
||||
string[] required =
|
||||
[
|
||||
"Str_Btn_OK", "Str_Btn_Yes", "Str_Btn_No", "Str_Btn_Cancel",
|
||||
"Str_RecentMissing",
|
||||
"Str_St_CopiedAnnotationOne", "Str_St_CopiedAnnotationMany",
|
||||
"Str_St_PastedAnnotationOne", "Str_St_PastedAnnotationMany",
|
||||
"Str_St_DeletedAnnotationOne", "Str_St_DeletedAnnotationMany",
|
||||
"Str_Search_NoMatches", "Str_Search_Error", "Str_Search_Summary",
|
||||
"Str_Search_PreviousTT", "Str_Search_NextTT", "Str_Search_CloseTT",
|
||||
"Str_Busy_FlattenPage", "Str_Busy_ExportPage", "Str_Busy_CancelHint",
|
||||
"Str_Busy_DownloadingMany"
|
||||
];
|
||||
|
||||
foreach (string key in required)
|
||||
Assert.True(english.ContainsKey(key), $"Missing #227 resource key: {key}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Issue227ReportedEnglishIsNotHardcodedInItsUiPaths()
|
||||
{
|
||||
string root = Directory.GetParent(StringsDirectory)!.FullName;
|
||||
var checks = new Dictionary<string, string[]>
|
||||
{
|
||||
["Controls/KillerDialog.cs"] = ["MakeBtn(\"Yes\"", "MakeBtn(\"No\"", "MakeBtn(\"Cancel\""],
|
||||
["Shell/FileOperations.cs"] = [": \"missing\"", "\"Exporting\"", "\"Flattening\""],
|
||||
["Shell/ContextMenu.cs"] = ["Copied 1 annotation", "Pasted 1 annotation"],
|
||||
["Controls/Viewer/PdfViewer.Annotations.cs"] = ["Deleted selected annotation"],
|
||||
["Shell/Search.cs"] = ["Previous Match (Shift+Enter)", "Next Match (Enter)", "Close (Esc)"],
|
||||
["Features/Search/SearchController.cs"] = ["SetResultText(\"No matches\")", "SetResultText(\"Search error\")"],
|
||||
["Services/OcrLanguages.cs"] = ["(Esc to cancel)"]
|
||||
};
|
||||
|
||||
foreach (var check in checks)
|
||||
{
|
||||
string source = File.ReadAllText(Path.Combine(root, check.Key.Replace('/', Path.DirectorySeparatorChar)));
|
||||
foreach (string text in check.Value)
|
||||
Assert.DoesNotContain(text, source);
|
||||
}
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> ReadStrings(string path) =>
|
||||
XDocument.Load(path).Root!.Elements()
|
||||
.Where(e => e.Attribute(Xaml + "Key") is not null)
|
||||
.ToDictionary(e => e.Attribute(Xaml + "Key")!.Value, e => e.Value);
|
||||
|
||||
private static IEnumerable<string> Placeholders(string value) =>
|
||||
Regex.Matches(value, @"\{\d+(?::[^}]*)?\}").Cast<Match>().Select(m => m.Value).OrderBy(x => x);
|
||||
|
||||
private static string FindStringsDirectory()
|
||||
{
|
||||
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (directory is not null && !Directory.Exists(Path.Combine(directory.FullName, "Strings")))
|
||||
directory = directory.Parent;
|
||||
Assert.NotNull(directory);
|
||||
return Path.Combine(directory!.FullName, "Strings");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net48</TargetFramework>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<!-- Inert on PolySharp 1.15.0, whose targets never make this property compiler-visible.
|
||||
Set here so bumping this project to 1.16.0 cannot reintroduce the CS8336 reserved-name
|
||||
break on net48 that MmdPdf.csproj works around. -->
|
||||
<PolySharpUseEmbeddedAttributeForGeneratedTypes>false</PolySharpUseEmbeddedAttributeForGeneratedTypes>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<UseWPF>true</UseWPF>
|
||||
<!-- Linked data-only model fields are populated by the application, not these focused tests. -->
|
||||
<NoWarn>$(NoWarn);CS0649</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="PolySharp" Version="1.15.0">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="PdfPig" Version="0.1.14" />
|
||||
<PackageReference Include="System.Text.Json" Version="10.0.11" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="..\Models\EditingTypes.cs" Link="Models\EditingTypes.cs" />
|
||||
<Compile Include="..\Services\SignatureStore.cs" Link="Services\SignatureStore.cs" />
|
||||
<Compile Include="..\Services\SearchService.cs" Link="Services\SearchService.cs" />
|
||||
<Compile Include="..\Services\TextRunService.cs" Link="Services\TextRunService.cs" />
|
||||
<Compile Include="..\Services\ProtocolRegistrar.cs" Link="Services\ProtocolRegistrar.cs" />
|
||||
<Compile Include="..\Services\PerspectiveWarp.cs" Link="Services\PerspectiveWarp.cs" />
|
||||
<Compile Include="..\Services\PdfFontStyle.cs" Link="Services\PdfFontStyle.cs" />
|
||||
<Compile Include="..\Services\PdfScrub.cs" Link="Services\PdfScrub.cs" />
|
||||
<Compile Include="..\Services\PdfBurn.cs" Link="Services\PdfBurn.cs" />
|
||||
<Compile Include="..\Services\AnnotationRotate.cs" Link="Services\AnnotationRotate.cs" />
|
||||
<Compile Include="..\Services\WheelPageFlipGate.cs" Link="Services\WheelPageFlipGate.cs" />
|
||||
<Compile Include="..\Services\FontCoverage.cs" Link="Services\FontCoverage.cs" />
|
||||
<Compile Include="..\Services\CmapCoverage.cs" Link="Services\CmapCoverage.cs" />
|
||||
<Compile Include="..\Services\PdfFonts.cs" Link="Services\PdfFonts.cs" />
|
||||
<Compile Include="..\Models\StampModels.cs" Link="Models\StampModels.cs" />
|
||||
<Compile Include="..\Controls\FlyoutPlacement.cs" Link="Controls\FlyoutPlacement.cs" />
|
||||
<!-- Data only, by design: OcrLanguages.cs itself reaches App and OcrNativeBootstrap, which a
|
||||
catalog check has no business dragging in. See OcrCatalogTests. -->
|
||||
<Compile Include="..\Services\OcrCatalog.cs" Link="Services\OcrCatalog.cs" />
|
||||
<!-- Data only, same reasoning as OcrCatalog: the shortcut table is deliberately free of WPF
|
||||
and of MainWindow so the invariants can be checked without standing up a window. -->
|
||||
<Compile Include="..\Services\ShortcutTable.cs" Link="Services\ShortcutTable.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Reference Include="System.Drawing" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\third_party\PdfSharpCore\PdfSharpCore.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,109 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using MmdPdf.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace MmdPdf.Tests;
|
||||
|
||||
// The rule this file exists to enforce: OCR LANGUAGES TRACK INTERFACE LANGUAGES.
|
||||
// If MmdPdf's UI is offered in a language, OCR is offered in it too. There is no such thing as
|
||||
// an interface language whose text the app cannot read.
|
||||
//
|
||||
// Nothing enforced it before, so it drifted: the UI shipped hu-HU while the catalog stayed at
|
||||
// eleven entries, and baobab-ts.com went on telling people OCR covered ten languages and that
|
||||
// Polish and Hungarian "do not require OCR models".
|
||||
//
|
||||
// These read the real Strings folder instead of a second hardcoded list, so adding a locale fails
|
||||
// the build until its model is registered. release.ps1 runs the suite, so it cannot ship broken.
|
||||
public sealed class OcrCatalogTests
|
||||
{
|
||||
// The test binary sits at MmdPdf.Tests\bin\<cfg>\net48; the repo root is the ancestor that
|
||||
// holds Strings\. Walking up beats a pile of ..\..\.. that breaks whenever the layout moves.
|
||||
private static string StringsDir()
|
||||
{
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Strings")))
|
||||
dir = dir.Parent;
|
||||
Assert.True(dir != null, "could not locate the repo's Strings folder from " + AppContext.BaseDirectory);
|
||||
return Path.Combine(dir!.FullName, "Strings");
|
||||
}
|
||||
|
||||
private static string[] ShippedLocales() =>
|
||||
Directory.GetFiles(StringsDir(), "*.xaml")
|
||||
.Select(f => Path.GetFileNameWithoutExtension(f)!)
|
||||
.OrderBy(n => n, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
[Fact]
|
||||
public void TheStringsFolderIsActuallyFound()
|
||||
{
|
||||
// Guards the guard: if the walk-up ever failed silently, every other test here would
|
||||
// compare against an empty list and pass while checking nothing.
|
||||
Assert.NotEmpty(ShippedLocales());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EveryShippedLocaleHasAnOcrModel()
|
||||
{
|
||||
var mapped = OcrCatalog.LocaleToCode.Select(x => x.Locale).ToArray();
|
||||
var missing = ShippedLocales()
|
||||
.Where(l => !mapped.Contains(l, StringComparer.OrdinalIgnoreCase))
|
||||
.ToArray();
|
||||
|
||||
Assert.True(missing.Length == 0,
|
||||
"These locales ship an interface but have no OCR model in OcrCatalog.LocaleToCode: " +
|
||||
string.Join(", ", missing));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoModelIsRegisteredForALocaleThatIsNotShipped()
|
||||
{
|
||||
var locales = ShippedLocales();
|
||||
var stale = OcrCatalog.LocaleToCode
|
||||
.Where(x => !locales.Contains(x.Locale, StringComparer.OrdinalIgnoreCase))
|
||||
.Select(x => x.Locale + " -> " + x.Code)
|
||||
.ToArray();
|
||||
|
||||
Assert.True(stale.Length == 0,
|
||||
"These OCR entries point at locales no longer in Strings\\: " + string.Join(", ", stale));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CatalogAndLocaleMapAgree()
|
||||
{
|
||||
var catalog = OcrCatalog.Languages.Select(x => x.Code).OrderBy(c => c, StringComparer.Ordinal).ToArray();
|
||||
var mapped = OcrCatalog.LocaleToCode.Select(x => x.Code).OrderBy(c => c, StringComparer.Ordinal).ToArray();
|
||||
Assert.Equal(mapped, catalog);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CatalogHasNoDuplicatesAndNoBlanks()
|
||||
{
|
||||
var codes = OcrCatalog.Languages.Select(x => x.Code).ToArray();
|
||||
Assert.Equal(codes.Length, codes.Distinct(StringComparer.Ordinal).Count());
|
||||
Assert.All(OcrCatalog.Languages, e =>
|
||||
{
|
||||
Assert.False(string.IsNullOrWhiteSpace(e.Code));
|
||||
Assert.False(string.IsNullOrWhiteSpace(e.Name));
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OcrLanguageCountEqualsInterfaceLanguageCount()
|
||||
{
|
||||
// The number baobab-ts.com quotes in prose. Pinning it here is what stops the site and
|
||||
// the app from disagreeing again.
|
||||
Assert.Equal(ShippedLocales().Length, OcrCatalog.Languages.Length);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("hu-HU", "hun")]
|
||||
[InlineData("pl-PL", "pol")]
|
||||
public void TheOnesThatWereMissingAreRegistered(string locale, string code)
|
||||
{
|
||||
Assert.Contains(OcrCatalog.LocaleToCode, x =>
|
||||
string.Equals(x.Locale, locale, StringComparison.OrdinalIgnoreCase) && x.Code == code);
|
||||
Assert.Contains(OcrCatalog.Languages, x => x.Code == code);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
using System.Diagnostics;
|
||||
using PdfSharpCore.Pdf;
|
||||
using PdfSharpCore.Pdf.IO;
|
||||
using Xunit;
|
||||
|
||||
namespace MmdPdf.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Regression cover for bookmarks whose /Dest is a *named destination* rather than a literal
|
||||
/// array. wkhtmltopdf writes /Dest /__WKANCHOR_n plus a flat catalog /Dests dictionary, and
|
||||
/// since most HTML-to-PDF invoice generators are wkhtmltopdf underneath, that shape is common.
|
||||
/// PdfSharpCore's PdfOutline.Initialize() used to hit Debug.Assert(false, "See what to do when
|
||||
/// this happened.") on it - a modal dialog in Debug, a silently dead bookmark in Release.
|
||||
/// </summary>
|
||||
public class OutlineDestinationTests
|
||||
{
|
||||
const string DestName = "/__WKANCHOR_2";
|
||||
|
||||
/// <summary>
|
||||
/// Debug.Assert writes to a trace listener and shows a modal box - it never throws, so an
|
||||
/// assert regression would HANG this test rather than fail it. Swap in a listener that
|
||||
/// turns Fail into an exception.
|
||||
/// </summary>
|
||||
sealed class ThrowingListener : TraceListener
|
||||
{
|
||||
public override void Write(string? message) { }
|
||||
public override void WriteLine(string? message) { }
|
||||
public override void Fail(string? message) => throw new Xunit.Sdk.XunitException("Debug.Assert fired: " + message);
|
||||
public override void Fail(string? message, string? detail) => throw new Xunit.Sdk.XunitException("Debug.Assert fired: " + message + " | " + detail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpenOutlines_NamedDestination_DoesNotAssert()
|
||||
{
|
||||
var dir = System.IO.Path.Combine(System.IO.Path.GetTempPath(), Guid.NewGuid().ToString("N"));
|
||||
System.IO.Directory.CreateDirectory(dir);
|
||||
var file = System.IO.Path.Combine(dir, "named_dest.pdf");
|
||||
|
||||
var original = new TraceListener[Trace.Listeners.Count];
|
||||
Trace.Listeners.CopyTo(original, 0);
|
||||
Trace.Listeners.Clear();
|
||||
Trace.Listeners.Add(new ThrowingListener());
|
||||
|
||||
try
|
||||
{
|
||||
WriteNamedDestPdf(file);
|
||||
|
||||
var doc = PdfReader.Open(file, PdfDocumentOpenMode.Modify);
|
||||
var outlines = doc.Outlines; // threw before the fix
|
||||
|
||||
Assert.Single(outlines);
|
||||
Assert.Equal("Tax Invoice", outlines[0].Title);
|
||||
|
||||
// Guard the fixture itself: if a future PdfSharpCore writer rewrote the name into a
|
||||
// literal array on save, this test would still pass while no longer covering the
|
||||
// regression at all.
|
||||
Assert.IsType<PdfName>(outlines[0].Elements.GetValue("/Dest"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Trace.Listeners.Clear();
|
||||
Trace.Listeners.AddRange(original);
|
||||
System.IO.Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a one-page PDF with a single bookmark pointing at a named destination.
|
||||
/// ponytail: the /Dests entry holds the destination array DIRECTLY; the invoice that
|
||||
/// surfaced this held it indirectly. Both take the same /Dest-is-a-PdfName branch, which is
|
||||
/// the regression under test. Make it indirect if that path ever breaks on its own.
|
||||
/// </summary>
|
||||
static void WriteNamedDestPdf(string path)
|
||||
{
|
||||
var doc = new PdfDocument();
|
||||
doc.AddPage();
|
||||
|
||||
// [page /XYZ left top zoom] - integer page number, as wkhtmltopdf emits.
|
||||
var dest = new PdfArray(doc,
|
||||
new PdfInteger(0), new PdfName("/XYZ"),
|
||||
new PdfInteger(0), new PdfInteger(800), new PdfInteger(0));
|
||||
|
||||
var dests = new PdfDictionary(doc);
|
||||
dests.Elements[DestName] = dest;
|
||||
doc.Internals.AddObject(dests);
|
||||
|
||||
var root = new PdfDictionary(doc);
|
||||
root.Elements["/Type"] = new PdfName("/Outlines");
|
||||
doc.Internals.AddObject(root);
|
||||
|
||||
var item = new PdfDictionary(doc);
|
||||
item.Elements["/Title"] = new PdfString("Tax Invoice");
|
||||
item.Elements["/Dest"] = new PdfName(DestName);
|
||||
item.Elements.SetReference("/Parent", root);
|
||||
doc.Internals.AddObject(item);
|
||||
|
||||
root.Elements.SetReference("/First", item);
|
||||
root.Elements.SetReference("/Last", item);
|
||||
|
||||
doc.Internals.Catalog.Elements.SetReference("/Outlines", root);
|
||||
doc.Internals.Catalog.Elements.SetReference("/Dests", dests);
|
||||
|
||||
doc.Save(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Windows;
|
||||
using MmdPdf.Services;
|
||||
using PdfSharpCore.Drawing;
|
||||
using PdfSharpCore.Pdf;
|
||||
using Xunit;
|
||||
|
||||
namespace MmdPdf.Tests;
|
||||
|
||||
// #169: content placed on a rotated page must burn where the user placed it. The burn draws in
|
||||
// the VISUAL frame and maps back through a quarter-turn matrix. Two parts have regressed
|
||||
// independently and each is pinned here by reading the saved content stream:
|
||||
// 1. the scale basis - sx/sy must come from the visual page size, not the raw page box
|
||||
// (a regression squeezes the rect by exactly the page's aspect ratio), and
|
||||
// 2. the quarter-turn matrix reaching both burn paths (annotations AND stamps) - dropping
|
||||
// it leaves the rect numbers right but the content turned 90 degrees on the page.
|
||||
public sealed class PdfBurnRotationTests
|
||||
{
|
||||
// A5-ish landscape map on a portrait MediaBox, the shape from the #169 repro files.
|
||||
private const double BoxW = 842, BoxH = 1191;
|
||||
|
||||
private static string BurnHighlightContent(int nativeRotate, Dictionary<int, int>? rotations,
|
||||
double pageW, double pageH, int renderW, int renderH, Rect bounds)
|
||||
{
|
||||
using var doc = new PdfDocument();
|
||||
doc.Options.NoCompression = true;
|
||||
var page = doc.AddPage();
|
||||
page.MediaBox = new PdfRectangle(new XPoint(0, 0), new XPoint(pageW, pageH));
|
||||
if (nativeRotate != 0) page.Rotate = nativeRotate;
|
||||
|
||||
var annots = new Dictionary<int, List<PageAnnotation>>
|
||||
{
|
||||
[0] = [new HighlightAnnotation { PageIndex = 0, Bounds = bounds, Style = HighlightStyle.Fill }],
|
||||
};
|
||||
var dims = new Dictionary<int, (int w, int h)> { [0] = (renderW, renderH) };
|
||||
PdfBurn.DrawAnnotationsIntoDoc(doc, annots, dims, null, rotations);
|
||||
return SaveToText(doc);
|
||||
}
|
||||
|
||||
private static string SaveToText(PdfDocument doc)
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
doc.Save(ms, false);
|
||||
|
||||
// PdfSharpCore may Flate-compress content streams even when NoCompression is set;
|
||||
// that option governs document structure, not every page stream. Inspect the decoded
|
||||
// page content instead of depending on the writer's storage choice.
|
||||
var content = doc.Pages[0].Contents.CreateSingleContent();
|
||||
return Encoding.GetEncoding("ISO-8859-1").GetString(content.Stream.UnfilteredValue);
|
||||
}
|
||||
|
||||
// Every `x y w h re` operator in the saved file, as (w, h).
|
||||
private static List<(double w, double h)> RectSizes(string pdf)
|
||||
{
|
||||
var list = new List<(double, double)>();
|
||||
foreach (Match m in Regex.Matches(pdf,
|
||||
@"(-?\d+(?:\.\d+)?) (-?\d+(?:\.\d+)?) (-?\d+(?:\.\d+)?) (-?\d+(?:\.\d+)?) re"))
|
||||
{
|
||||
list.Add((double.Parse(m.Groups[3].Value, System.Globalization.CultureInfo.InvariantCulture),
|
||||
double.Parse(m.Groups[4].Value, System.Globalization.CultureInfo.InvariantCulture)));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
// True when the content carries a quarter-turn cm (a and d zero, b and c unit) - the visual-to-page
|
||||
// mapping the rotated burn must emit. The unrotated base transform is axis-aligned and never matches.
|
||||
private static bool HasQuarterTurnCm(string pdf)
|
||||
{
|
||||
foreach (Match m in Regex.Matches(pdf,
|
||||
@"(-?\d+(?:\.\d+)?) (-?\d+(?:\.\d+)?) (-?\d+(?:\.\d+)?) (-?\d+(?:\.\d+)?) (-?\d+(?:\.\d+)?) (-?\d+(?:\.\d+)?) cm"))
|
||||
{
|
||||
double a = double.Parse(m.Groups[1].Value, System.Globalization.CultureInfo.InvariantCulture);
|
||||
double b = double.Parse(m.Groups[2].Value, System.Globalization.CultureInfo.InvariantCulture);
|
||||
double c = double.Parse(m.Groups[3].Value, System.Globalization.CultureInfo.InvariantCulture);
|
||||
double d = double.Parse(m.Groups[4].Value, System.Globalization.CultureInfo.InvariantCulture);
|
||||
if (Math.Abs(a) < 0.001 && Math.Abs(d) < 0.001 &&
|
||||
Math.Abs(Math.Abs(b) - 1) < 0.001 && Math.Abs(Math.Abs(c) - 1) < 0.001)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnrotatedPage_BurnsAtRenderScale_NoTurn()
|
||||
{
|
||||
// Letter page rendered at 2x: a 300x60 canvas rect must burn as 150x30 points.
|
||||
string pdf = BurnHighlightContent(0, null, 612, 792, 1224, 1584, new Rect(100, 200, 300, 60));
|
||||
|
||||
var rects = RectSizes(pdf);
|
||||
var r = Assert.Single(rects);
|
||||
Assert.Equal(150, r.w, 2);
|
||||
Assert.Equal(30, r.h, 2);
|
||||
Assert.False(HasQuarterTurnCm(pdf));
|
||||
}
|
||||
|
||||
// Native /Rotate on a freshly opened file (the 1.7.1 fallback - the rotation map is empty),
|
||||
// and both quarter turns.
|
||||
[Theory]
|
||||
[InlineData(90)]
|
||||
[InlineData(270)]
|
||||
public void NativeRotate_UsesVisualScaleAndTurns(int rotate)
|
||||
{
|
||||
// Visual frame is 1191x842, rendered at 2x. A 300x60 canvas rect must burn as 150x30.
|
||||
// The #169 regression scaled against the raw 842x1191 box instead, which burns exactly
|
||||
// 106.07x42.45 - the aspect-ratio squeeze from terada-d's measurements.
|
||||
string pdf = BurnHighlightContent(rotate, null, BoxW, BoxH, 2382, 1684, new Rect(200, 400, 300, 60));
|
||||
|
||||
var rects = RectSizes(pdf);
|
||||
var r = Assert.Single(rects);
|
||||
Assert.Equal(150, r.w, 2);
|
||||
Assert.Equal(30, r.h, 2);
|
||||
Assert.True(HasQuarterTurnCm(pdf), "rotated burn emitted no quarter-turn cm - content will land turned 90 degrees");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InAppRotation_MapOverridesStrippedPage()
|
||||
{
|
||||
// In-app rotation: the working copy has /Rotate stripped to 0 and the angle lives in the
|
||||
// shell's rotation map. The burn must honor the map exactly as it honors a native /Rotate.
|
||||
var rotations = new Dictionary<int, int> { [0] = 90 };
|
||||
string pdf = BurnHighlightContent(0, rotations, BoxW, BoxH, 2382, 1684, new Rect(200, 400, 300, 60));
|
||||
|
||||
var rects = RectSizes(pdf);
|
||||
var r = Assert.Single(rects);
|
||||
Assert.Equal(150, r.w, 2);
|
||||
Assert.Equal(30, r.h, 2);
|
||||
Assert.True(HasQuarterTurnCm(pdf));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rotate180_ScalesUnswappedAndTurns()
|
||||
{
|
||||
// 180 keeps the axes (no dimension swap) but still needs its half-turn mapping.
|
||||
string pdf = BurnHighlightContent(180, null, BoxW, BoxH, 1684, 2382, new Rect(200, 400, 300, 60));
|
||||
|
||||
var rects = RectSizes(pdf);
|
||||
var r = Assert.Single(rects);
|
||||
Assert.Equal(150, r.w, 2);
|
||||
Assert.Equal(30, r.h, 2);
|
||||
// A half turn is (-1 0 0 -1) pre-flip; composed with the base flip it is axis-aligned,
|
||||
// so assert on the rect numbers plus the annotation surviving - not on the cm shape.
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StampBurn_RotatedPage_GetsTheTurnToo()
|
||||
{
|
||||
// Stamps share the visual-frame helpers; the original #169 gap was the stamp burn never
|
||||
// receiving the angle at all, so preview and output disagreed.
|
||||
using var doc = new PdfDocument();
|
||||
doc.Options.NoCompression = true;
|
||||
var page = doc.AddPage();
|
||||
page.MediaBox = new PdfRectangle(new XPoint(0, 0), new XPoint(BoxW, BoxH));
|
||||
page.Rotate = 90;
|
||||
|
||||
var spec = new StampSpec { NumbersEnabled = true, Format = "{n} / {N}" };
|
||||
PdfBurn.DrawStampsIntoDoc(doc, spec);
|
||||
|
||||
string pdf = SaveToText(doc);
|
||||
Assert.True(HasQuarterTurnCm(pdf), "stamp burn on a rotated page emitted no quarter-turn cm");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using MmdPdf.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace MmdPdf.Tests
|
||||
{
|
||||
public class PdfFontStyleTests
|
||||
{
|
||||
[Theory]
|
||||
// #187: families are normalized to the installed Windows family the PostScript name
|
||||
// means - the raw PS name resolves to nothing in WPF and read as "all formatting lost".
|
||||
[InlineData("ABCDEF+Helvetica-Bold", "Arial", true, false)]
|
||||
[InlineData("Helvetica-Oblique", "Arial", false, true)]
|
||||
[InlineData("TimesNewRomanPS-BoldItalicMT", "Times New Roman", true, true)]
|
||||
[InlineData("Arial-Regular", "Arial", false, false)]
|
||||
[InlineData("ArialMT", "Arial", false, false)]
|
||||
[InlineData("TimesNewRomanPSMT", "Times New Roman", false, false)]
|
||||
[InlineData("CourierNewPS-BoldMT", "Courier New", true, false)]
|
||||
[InlineData("GHIJKL+BookAntiqua", "Book Antiqua", false, false)]
|
||||
public void DetectsFaceStyleFromPdfFontName(string source, string family, bool bold, bool italic)
|
||||
{
|
||||
var detected = PdfFontStyle.FromPdfName(source);
|
||||
|
||||
Assert.Equal(family, detected.Family);
|
||||
Assert.Equal(bold, detected.Bold);
|
||||
Assert.Equal(italic, detected.Italic);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using MmdPdf.Services;
|
||||
using PdfSharpCore.Drawing;
|
||||
using PdfSharpCore.Pdf;
|
||||
using Xunit;
|
||||
|
||||
namespace MmdPdf.Tests;
|
||||
|
||||
public sealed class PdfScrubTests
|
||||
{
|
||||
[Fact]
|
||||
public void ScrubDegenerateCropBoxes_RemovesCropOutsideMediaBox()
|
||||
{
|
||||
using var doc = new PdfDocument();
|
||||
var page = doc.AddPage();
|
||||
page.MediaBox = new PdfRectangle(new XPoint(0, 0), new XPoint(1191, 842));
|
||||
page.CropBox = new PdfRectangle(new XPoint(0, 0), new XPoint(842, 1191));
|
||||
page.Rotate = 90;
|
||||
|
||||
PdfScrub.ScrubDegenerateCropBoxes(doc);
|
||||
|
||||
Assert.Null(page.Elements["/CropBox"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScrubDegenerateCropBoxes_PreservesValidInsetCrop()
|
||||
{
|
||||
using var doc = new PdfDocument();
|
||||
var page = doc.AddPage();
|
||||
page.MediaBox = new PdfRectangle(new XPoint(0, 0), new XPoint(612, 792));
|
||||
page.CropBox = new PdfRectangle(new XPoint(18, 24), new XPoint(594, 768));
|
||||
|
||||
PdfScrub.ScrubDegenerateCropBoxes(doc);
|
||||
|
||||
Assert.NotNull(page.Elements["/CropBox"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using System.Text;
|
||||
using UglyToad.PdfPig;
|
||||
using Xunit;
|
||||
|
||||
namespace MmdPdf.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Pins the PdfPig property that in-place text editing reads its font size from (#163).
|
||||
/// Letter.FontSize is the size as written in the content stream, so a generator that emits
|
||||
/// "/F1 1 Tf" and applies the scale through the text matrix reports 1 no matter how large the
|
||||
/// glyphs actually draw. Detection used that value, which collapsed the replacement text onto
|
||||
/// its lower clamp and read back as 3pt. Letter.PointSize is the size in points and is right
|
||||
/// for both spellings, so these tests fail if a PdfPig upgrade changes either meaning.
|
||||
/// </summary>
|
||||
public class PdfTextSizeTests
|
||||
{
|
||||
// Two runs that draw at the same visual size: the first sized by Tf with an identity text
|
||||
// matrix, the second sized entirely by a 12x text matrix.
|
||||
const string Content =
|
||||
"BT\n/F1 12 Tf\n1 0 0 1 72 700 Tm\n(Conventional) Tj\nET\n" +
|
||||
"BT\n/F1 1 Tf\n12 0 0 12 72 660 Tm\n(Scaled) Tj\nET\n";
|
||||
|
||||
/// <summary>A single-page PDF holding <see cref="Content"/>, built by hand so the two runs
|
||||
/// keep their exact Tf and Tm operands - no library would emit the second one.</summary>
|
||||
static byte[] BuildPdf()
|
||||
{
|
||||
string[] objects =
|
||||
[
|
||||
"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n",
|
||||
"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n",
|
||||
"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] "
|
||||
+ "/Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>\nendobj\n",
|
||||
$"4 0 obj\n<< /Length {Content.Length} >>\nstream\n{Content}\nendstream\nendobj\n",
|
||||
"5 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica "
|
||||
+ "/Encoding /WinAnsiEncoding >>\nendobj\n",
|
||||
];
|
||||
|
||||
var pdf = new StringBuilder("%PDF-1.4\n");
|
||||
var offsets = new int[objects.Length];
|
||||
for (int i = 0; i < objects.Length; i++)
|
||||
{
|
||||
offsets[i] = pdf.Length;
|
||||
pdf.Append(objects[i]);
|
||||
}
|
||||
|
||||
int startXref = pdf.Length;
|
||||
pdf.Append($"xref\n0 {objects.Length + 1}\n0000000000 65535 f \n");
|
||||
foreach (int offset in offsets)
|
||||
pdf.Append($"{offset:D10} 00000 n \n");
|
||||
pdf.Append($"trailer\n<< /Size {objects.Length + 1} /Root 1 0 R >>\n")
|
||||
.Append($"startxref\n{startXref}\n%%EOF\n");
|
||||
|
||||
// The content is pure ASCII, so string length above is also the byte offset.
|
||||
return Encoding.ASCII.GetBytes(pdf.ToString());
|
||||
}
|
||||
|
||||
static (double FontSize, double PointSize) FirstLetterOf(string word)
|
||||
{
|
||||
using var doc = PdfDocument.Open(BuildPdf());
|
||||
var letter = doc.GetPage(1).GetWords().Single(w => w.Text == word).Letters[0];
|
||||
return (letter.FontSize, letter.PointSize);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FontSize_MatchesPoints_OnlyWhenTfCarriesTheScale()
|
||||
{
|
||||
Assert.Equal(12, FirstLetterOf("Conventional").FontSize, 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FontSize_ReportsTheRawOperand_WhenTheTextMatrixCarriesTheScale()
|
||||
{
|
||||
// The glyphs draw at 12pt, but Tf said 1. This is the value that produced #163.
|
||||
Assert.Equal(1, FirstLetterOf("Scaled").FontSize, 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PointSize_IsTheVisualSize_ForBothSpellings()
|
||||
{
|
||||
Assert.Equal(12, FirstLetterOf("Conventional").PointSize, 3);
|
||||
Assert.Equal(12, FirstLetterOf("Scaled").PointSize, 3);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Imaging;
|
||||
using MmdPdf.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace MmdPdf.Tests;
|
||||
|
||||
public sealed class PerspectiveWarpTests
|
||||
{
|
||||
[Fact]
|
||||
public void IdentityCornersPreserveDimensionsAndCornerPixels()
|
||||
{
|
||||
byte[] pixels =
|
||||
{
|
||||
1,2,3,255, 4,5,6,255,
|
||||
7,8,9,255, 10,11,12,255,
|
||||
};
|
||||
var source = BitmapSource.Create(2, 2, 96, 96, PixelFormats.Bgra32, null, pixels, 8);
|
||||
Point[] corners = { new(0,0), new(1,0), new(1,1), new(0,1) };
|
||||
|
||||
var result = PerspectiveWarp.Apply(source, corners);
|
||||
byte[] actual = new byte[16];
|
||||
result.CopyPixels(actual, 8, 0);
|
||||
|
||||
Assert.Equal(2, result.PixelWidth);
|
||||
Assert.Equal(2, result.PixelHeight);
|
||||
Assert.Equal(pixels, actual);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RejectsCollapsedQuadrilateral()
|
||||
{
|
||||
var source = BitmapSource.Create(2, 2, 96, 96, PixelFormats.Bgra32, null, new byte[16], 8);
|
||||
Point[] corners = { new(0,0), new(0,0), new(0,0), new(0,0) };
|
||||
Assert.Throws<System.InvalidOperationException>(() => PerspectiveWarp.Apply(source, corners));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using MmdPdf.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace MmdPdf.Tests;
|
||||
|
||||
public sealed class ProtocolRegistrarTests
|
||||
{
|
||||
[Fact]
|
||||
public void ParsesEncodedHttpsPdfUrl()
|
||||
{
|
||||
Assert.True(ProtocolRegistrar.TryGetTargetUrl(
|
||||
"mmdpdf://open?url=https%3A%2F%2Fexample.com%2Ffile.pdf%3Fx%3D1", out var target));
|
||||
Assert.Equal("https://example.com/file.pdf?x=1", target!.AbsoluteUri);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("mmdpdf://open?url=http%3A%2F%2Fexample.com%2Ffile.pdf")]
|
||||
[InlineData("mmdpdf://open?url=file%3A%2F%2Fc%3A%2Fsecret.pdf")]
|
||||
[InlineData("mmdpdf://wrong?url=https%3A%2F%2Fexample.com%2Ffile.pdf")]
|
||||
[InlineData("https://example.com/file.pdf")]
|
||||
public void RejectsUnsafeOrUnrelatedLaunches(string value)
|
||||
=> Assert.False(ProtocolRegistrar.TryGetTargetUrl(value, out _));
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using MmdPdf.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace MmdPdf.Tests
|
||||
{
|
||||
public class SearchServiceTests
|
||||
{
|
||||
private readonly SearchService _svc = new();
|
||||
|
||||
[Fact]
|
||||
public void Search_EmptyQuery_ReturnsEmpty()
|
||||
{
|
||||
var result = _svc.Search("irrelevant.pdf", "");
|
||||
Assert.Empty(result.ResultPages);
|
||||
Assert.Equal(0, result.TotalHits);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Search_WhitespaceQuery_ReturnsEmpty()
|
||||
{
|
||||
var result = _svc.Search("irrelevant.pdf", " ");
|
||||
Assert.Empty(result.ResultPages);
|
||||
Assert.Equal(0, result.TotalHits);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Search_EmptyFilePath_ReturnsEmpty()
|
||||
{
|
||||
var result = _svc.Search("", "hello");
|
||||
Assert.Empty(result.ResultPages);
|
||||
Assert.Equal(0, result.TotalHits);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Search_MissingFile_ReturnsEmpty()
|
||||
{
|
||||
// Should not throw; non-existent file produces no results.
|
||||
var result = _svc.Search(@"C:\does\not\exist.pdf", "hello");
|
||||
Assert.Empty(result.ResultPages);
|
||||
Assert.Equal(0, result.TotalHits);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SearchResult_PageRects_EmptyByDefault()
|
||||
{
|
||||
var result = new SearchResult();
|
||||
Assert.Empty(result.PageRects);
|
||||
Assert.Empty(result.ResultPages);
|
||||
Assert.Equal(0, result.TotalHits);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Xml.Linq;
|
||||
using MmdPdf;
|
||||
using Xunit;
|
||||
|
||||
namespace MmdPdf.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Invariants of the one shortcut table (Services/ShortcutTable.cs).
|
||||
///
|
||||
/// The table was unified in 1.7.5 from two hand maintained copies that had quietly drifted:
|
||||
/// Alt+M existed in the list and not on the keyboard map, Home and End were captioned
|
||||
/// differently in each view, and Ctrl+B was documented as bold in one section of the list and
|
||||
/// as the sidebar in another while the code did neither consistently. Unifying removes the
|
||||
/// opportunity for that; these tests are what keep it removed.
|
||||
///
|
||||
/// No WPF here on purpose. The table is plain data, which is why the csproj links the single
|
||||
/// file rather than referencing the app, the same arrangement OcrCatalogTests uses.
|
||||
/// </summary>
|
||||
public class ShortcutTableTests
|
||||
{
|
||||
private static readonly string StringsDir =
|
||||
Path.Combine(RepoRoot(), "Strings");
|
||||
|
||||
private static string RepoRoot()
|
||||
{
|
||||
// Walk up from the test binary until the folder holding Strings\en-US.xaml appears.
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "Strings", "en-US.xaml")))
|
||||
dir = dir.Parent;
|
||||
Assert.True(dir != null, "could not locate the repo root from the test output folder");
|
||||
return dir!.FullName;
|
||||
}
|
||||
|
||||
private static HashSet<string> EnglishKeys()
|
||||
{
|
||||
var doc = XDocument.Load(Path.Combine(StringsDir, "en-US.xaml"));
|
||||
XNamespace x = "http://schemas.microsoft.com/winfx/2006/xaml";
|
||||
return doc.Root!.Elements()
|
||||
.Select(e => (string?)e.Attribute(x + "Key"))
|
||||
.Where(k => k != null)
|
||||
.Select(k => k!)
|
||||
.ToHashSet();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// THE one that matters. Two bindings claiming the same key on the same layer means the
|
||||
/// keyboard map silently shows whichever was declared last, which is exactly how Ctrl+B
|
||||
/// managed to mean two different things without anyone noticing.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void NoKeyIsClaimedTwiceOnTheSameLayer()
|
||||
{
|
||||
var duplicates = ShortcutTable.AllCapClaims()
|
||||
.GroupBy(c => c)
|
||||
.Where(g => g.Count() > 1)
|
||||
.Select(g => g.Key)
|
||||
.ToList();
|
||||
|
||||
Assert.True(duplicates.Count == 0,
|
||||
"these keys are claimed by more than one binding: " + string.Join(", ", duplicates));
|
||||
}
|
||||
|
||||
/// <summary>Every cap the table lights has to exist on the drawn board, or the binding is
|
||||
/// invisible: the map only renders ids present in KbRows. Alt+M was missing the other way
|
||||
/// round, absent from the table while the list advertised it.</summary>
|
||||
[Fact]
|
||||
public void EveryCapExistsOnTheDrawnKeyboard()
|
||||
{
|
||||
// KbRows lives in the WPF half, so the ids are mirrored here deliberately: this test is
|
||||
// the thing that fails if the two ever disagree, which is the point.
|
||||
var board = new HashSet<string>
|
||||
{
|
||||
"Esc","F1","F2","F3","F4","F5","F6","F7","F8","F9","F10","F11","F12",
|
||||
"Grave","D1","D2","D3","D4","D5","D6","D7","D8","D9","D0","Minus","Equals","Back",
|
||||
"Ins","Home","PgUp",
|
||||
"Tab","Q","W","E","R","T","Y","U","I","O","P","LBr","RBr","Bslash","Del","End","PgDn",
|
||||
"Caps","A","S","D","F","G","H","J","K","L","Semi","Quote","Enter",
|
||||
"Shift","Z","X","C","V","B","N","M","Comma","Period","Slash","RShift","Up",
|
||||
"Ctrl","Win","Alt","Space","RAlt","Menu","RCtrl","Left","Down","Right",
|
||||
};
|
||||
|
||||
var missing = ShortcutTable.KsAll
|
||||
.SelectMany(b => b.Caps)
|
||||
.Select(c => c.Id)
|
||||
.Distinct()
|
||||
.Where(id => !board.Contains(id))
|
||||
.ToList();
|
||||
|
||||
Assert.True(missing.Count == 0,
|
||||
"these cap ids are not on the drawn keyboard: " + string.Join(", ", missing));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EveryLabelKeyExistsInEnglish()
|
||||
{
|
||||
var english = EnglishKeys();
|
||||
var missing = ShortcutTable.KsAll
|
||||
.SelectMany(b => new[] { b.LabelKey }.Concat(b.Caps.Select(c => c.LabelKey)))
|
||||
.Where(k => k.Length > 0)
|
||||
.Distinct()
|
||||
.Where(k => !english.Contains(k))
|
||||
.ToList();
|
||||
|
||||
Assert.True(missing.Count == 0,
|
||||
"these resource keys are referenced but not defined in en-US.xaml: " + string.Join(", ", missing));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EveryBindingBelongsToADeclaredSection()
|
||||
{
|
||||
var groups = ShortcutTable.KsGroups.Select(g => g.Cat).ToHashSet();
|
||||
var orphans = ShortcutTable.KsAll.Select(b => b.Cat).Distinct()
|
||||
.Where(c => !groups.Contains(c)).ToList();
|
||||
|
||||
Assert.True(orphans.Count == 0,
|
||||
"these categories have bindings but no section: " + string.Join(", ", orphans));
|
||||
}
|
||||
|
||||
/// <summary>A section with no bindings would render as a heading over nothing.</summary>
|
||||
[Fact]
|
||||
public void EverySectionHasAtLeastOneBinding()
|
||||
{
|
||||
var used = ShortcutTable.KsAll.Select(b => b.Cat).ToHashSet();
|
||||
var empty = ShortcutTable.KsGroups.Select(g => g.Cat).Where(c => !used.Contains(c)).ToList();
|
||||
|
||||
Assert.True(empty.Count == 0,
|
||||
"these sections would render empty: " + string.Join(", ", empty));
|
||||
}
|
||||
|
||||
/// <summary>The layer prefix is the only thing separating Shift+F3 from F3. A typo in the
|
||||
/// prefix silently lands the cap on the base layer, so the parse gets its own check.</summary>
|
||||
// Layer passed by name: KbLayer is internal, and an InlineData parameter has to be at least
|
||||
// as accessible as the public test method.
|
||||
[Theory]
|
||||
[InlineData("F3", "Base", "F3")]
|
||||
[InlineData("Ctrl:O", "Ctrl", "O")]
|
||||
[InlineData("CtrlShift:D1", "CtrlShift", "D1")]
|
||||
[InlineData("Shift:F9", "Shift", "F9")]
|
||||
[InlineData("Alt:M", "Alt", "M")]
|
||||
public void CapParsesItsLayerPrefix(string id, string layerName, string bare)
|
||||
{
|
||||
var cap = ShortcutTable.Cap(id);
|
||||
Assert.Equal(layerName, cap.Layer.ToString());
|
||||
Assert.Equal(bare, cap.Id);
|
||||
}
|
||||
|
||||
/// <summary>A cap without its own label inherits the row's; one with a label keeps it. That
|
||||
/// is what lets "Home / End" stay one row while Home and End caption separately.</summary>
|
||||
[Fact]
|
||||
public void CapLabelOverridesTheRowAndOtherwiseInherits()
|
||||
{
|
||||
var map = ShortcutTable.BuildMap();
|
||||
|
||||
Assert.Equal("Str_Kb_FirstPage", map[KbLayer.Base]["Home"].Label);
|
||||
Assert.Equal("Str_Kb_LastPage", map[KbLayer.Base]["End"].Label);
|
||||
Assert.Equal("Str_KS_Open", map[KbLayer.Ctrl]["O"].Label); // inherited
|
||||
}
|
||||
|
||||
/// <summary>The three bindings 1.7.5 changed, pinned so a later edit cannot quietly undo
|
||||
/// them: the sidebar is F9 and Shift+F9, and Ctrl+B is bold rather than the sidebar.</summary>
|
||||
[Fact]
|
||||
public void TheSidebarIsOnF9AndCtrlBIsBold()
|
||||
{
|
||||
var map = ShortcutTable.BuildMap();
|
||||
|
||||
Assert.Equal("Str_KS_ToggleSidebar", map[KbLayer.Base]["F9"].Label);
|
||||
Assert.Equal("Str_KS_SidebarSide", map[KbLayer.Shift]["F9"].Label);
|
||||
Assert.Equal("Str_Lbl_Bold", map[KbLayer.Ctrl]["B"].Label);
|
||||
Assert.False(map[KbLayer.Ctrl].ContainsKey("B") &&
|
||||
map[KbLayer.Ctrl]["B"].Label == "Str_KS_ToggleSidebar",
|
||||
"Ctrl+B must not be the sidebar again");
|
||||
}
|
||||
|
||||
/// <summary>Alt+M was in the list and missing from the map for as long as it existed.</summary>
|
||||
[Fact]
|
||||
public void AltMIsOnTheMap()
|
||||
{
|
||||
Assert.Equal("Str_Toolbar_Hide", ShortcutTable.BuildMap()[KbLayer.Alt]["M"].Label);
|
||||
}
|
||||
|
||||
/// <summary>Gesture-only rows (the wheel ones, Ctrl+Scroll, Shift+Click, Middle drag) have
|
||||
/// no keycap by design. Anything else with no caps is likely an oversight, so the count is
|
||||
/// pinned rather than left to drift.</summary>
|
||||
[Fact]
|
||||
public void OnlyMouseGesturesHaveNoCaps()
|
||||
{
|
||||
// Asserted by label key, not by the Keys text: the key column is tokenized, so pinning
|
||||
// its literal spelling here would just break every time a token is renamed.
|
||||
var capless = ShortcutTable.KsAll.Where(b => b.Caps.Length == 0)
|
||||
.Select(b => b.LabelKey).ToList();
|
||||
|
||||
Assert.Equal(
|
||||
new[] { "Str_KS_ZoomCursor", "Str_KS_PanView", "Str_KS_CycleView",
|
||||
"Str_KS_AppSize", "Str_KS_MultiSelect" }
|
||||
.OrderBy(s => s, StringComparer.Ordinal).ToArray(),
|
||||
capless.OrderBy(s => s, StringComparer.Ordinal).ToArray());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Every token in the key column must be one the renderer knows, or it prints raw to the
|
||||
/// user as "%shft%". The zoom pair is substituted from the keyboard layout rather than the
|
||||
/// locale (Services/KeyLayout.cs), so it is known-good without a resource key.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void EveryKeyTokenIsResolvable()
|
||||
{
|
||||
var known = ShortcutTable.KeyTokens.Select(t => t.Token)
|
||||
.Concat(new[] { "%zin%", "%zout%" })
|
||||
.ToHashSet();
|
||||
|
||||
var unknown = ShortcutTable.KsAll
|
||||
.SelectMany(b => Regex.Matches(b.Keys, "%[a-z]+%").Cast<Match>().Select(m => m.Value))
|
||||
.Distinct()
|
||||
.Where(t => !known.Contains(t))
|
||||
.ToList();
|
||||
|
||||
Assert.True(unknown.Count == 0,
|
||||
"these tokens have no resource key and would render raw: " + string.Join(", ", unknown));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EveryKeyTokenResourceExistsInEnglish()
|
||||
{
|
||||
var english = EnglishKeys();
|
||||
var missing = ShortcutTable.KeyTokens.Select(t => t.Key)
|
||||
.Where(k => !english.Contains(k)).ToList();
|
||||
|
||||
Assert.True(missing.Count == 0,
|
||||
"key-name resources missing from en-US.xaml: " + string.Join(", ", missing));
|
||||
}
|
||||
|
||||
/// <summary>The point of #230: no bare English key name is left sitting in the table. Only
|
||||
/// letters, digits, F-numbers, arrows and the chord punctuation may appear literally.</summary>
|
||||
[Fact]
|
||||
public void NoUntranslatedKeyNamesRemainInTheTable()
|
||||
{
|
||||
var offenders = ShortcutTable.KsAll
|
||||
.Where(b => Regex.IsMatch(Regex.Replace(b.Keys, "%[a-z]+%", ""),
|
||||
@"\b(Ctrl|Alt|Shift|Delete|Enter|Escape|Esc|Menu|Home|End|PgUp|PgDn|Tab|Scroll|Click|Wheel|Space|Middle|drag|or)\b"))
|
||||
.Select(b => b.Keys)
|
||||
.ToList();
|
||||
|
||||
Assert.True(offenders.Count == 0,
|
||||
"these rows still carry hardcoded English key names: " + string.Join(" | ", offenders));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using MmdPdf.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace MmdPdf.Tests
|
||||
{
|
||||
public class SignatureStoreTests
|
||||
{
|
||||
[Fact]
|
||||
public void NewStore_HasEmptySignatures()
|
||||
{
|
||||
var store = new SignatureStore();
|
||||
Assert.Empty(store.Signatures);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Add_IncreasesCount()
|
||||
{
|
||||
var store = new SignatureStore();
|
||||
store.Add(new SavedSignature { Name = "Test" });
|
||||
Assert.Single(store.Signatures);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Remove_DecreasesCount()
|
||||
{
|
||||
var store = new SignatureStore();
|
||||
var sig = new SavedSignature { Name = "Test" };
|
||||
store.Add(sig);
|
||||
store.Remove(sig);
|
||||
Assert.Empty(store.Signatures);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RoundTrip_PersistAndLoad()
|
||||
{
|
||||
var dir = System.IO.Path.Combine(System.IO.Path.GetTempPath(), Guid.NewGuid().ToString("N"));
|
||||
System.IO.Directory.CreateDirectory(dir);
|
||||
var file = System.IO.Path.Combine(dir, "sig_test.json");
|
||||
|
||||
try
|
||||
{
|
||||
var store1 = new SignatureStore(dir, file);
|
||||
store1.Add(new SavedSignature { Name = "Alpha", CanvasWidth = 400, CanvasHeight = 150 });
|
||||
store1.Add(new SavedSignature { Name = "Beta", CanvasWidth = 300, CanvasHeight = 100 });
|
||||
store1.Persist();
|
||||
|
||||
var store2 = new SignatureStore(dir, file);
|
||||
store2.Load();
|
||||
|
||||
Assert.Equal(2, store2.Signatures.Count);
|
||||
Assert.Equal("Alpha", store2.Signatures[0].Name);
|
||||
Assert.Equal("Beta", store2.Signatures[1].Name);
|
||||
}
|
||||
finally
|
||||
{
|
||||
System.IO.Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using PdfSharpCore.Drawing;
|
||||
using PdfSharpCore.Drawing.Layout;
|
||||
using PdfSharpCore.Pdf;
|
||||
using Xunit;
|
||||
|
||||
namespace MmdPdf.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Regression cover for burning multi-line text annotations into a document (#142).
|
||||
/// CreateBlocks emits a Block(BlockType.LineBreak) per newline, and that constructor never sets
|
||||
/// Text. CreateLayout then skips assigning those blocks a Location, so they keep XPoint(0,0) and
|
||||
/// GetLines' GroupBy lumps them in with the first line. The Justify branch of DrawString called
|
||||
/// block.Text.Trim() on them and threw NullReferenceException.
|
||||
/// Only the Justify path was affected - the other alignments join blocks with string.Join, which
|
||||
/// tolerates nulls. The short DrawString overload defaults to Justify, so MmdPdf hit it.
|
||||
/// </summary>
|
||||
public class TextFormatterTests
|
||||
{
|
||||
static void Draw(string content, TextFormatAlignment? alignment = null)
|
||||
{
|
||||
var doc = new PdfDocument();
|
||||
var page = doc.AddPage();
|
||||
var gfx = XGraphics.FromPdfPage(page);
|
||||
var font = new XFont("Segoe UI", 12, XFontStyle.Regular);
|
||||
var rect = new XRect(20, 20, 300, 200);
|
||||
var tf = new XTextFormatter(gfx);
|
||||
|
||||
if (alignment == null)
|
||||
tf.DrawString(content, font, XBrushes.Black, rect);
|
||||
else
|
||||
tf.DrawString(content, font, XBrushes.Black, rect, alignment);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DrawString_SingleLine_DoesNotThrow()
|
||||
{
|
||||
Draw("Hello world");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DrawString_MultiLine_DoesNotThrow()
|
||||
{
|
||||
// The short overload defaults to Justify - this is the exact call MmdPdf makes when
|
||||
// burning a text annotation, and the one that crashed.
|
||||
Draw("Hello\nworld");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DrawString_MultiLineCrLf_DoesNotThrow()
|
||||
{
|
||||
Draw("Hello\r\nworld");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DrawString_MultiLineLeftAligned_DoesNotThrow()
|
||||
{
|
||||
// What the annotation burn now passes explicitly, matching the on-screen TextBlock.
|
||||
Draw("Hello\nworld", new TextFormatAlignment { Horizontal = XParagraphAlignment.Left });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DrawString_ConsecutiveNewlines_DoesNotThrow()
|
||||
{
|
||||
// A blank line produces two adjacent LineBreak blocks, so the line group is entirely
|
||||
// non-drawable. Guards the empty-after-filter path.
|
||||
Draw("Hello\n\n\nworld");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using MmdPdf.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace MmdPdf.Tests;
|
||||
|
||||
public sealed class TextRunServiceTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("English text", false)]
|
||||
[InlineData("متن فارسی", true)]
|
||||
[InlineData("نص عربي", true)]
|
||||
[InlineData("טקסט עברי", true)]
|
||||
[InlineData("1234", false)]
|
||||
public void DetectsLineDirection(string text, bool expected)
|
||||
=> Assert.Equal(expected, TextRunService.IsRightToLeftText(new[] { text }));
|
||||
|
||||
[Fact]
|
||||
public void RightToLeftCaretMovesFromRightEdgeToLeftEdge()
|
||||
{
|
||||
var runs = new PageTextRuns();
|
||||
runs.Chars.Add(new RunChar("א", 90, 100, 0, 0));
|
||||
runs.Chars.Add(new RunChar("ב", 80, 90, 0, 0));
|
||||
runs.Chars.Add(new RunChar("ג", 70, 80, 0, 0));
|
||||
runs.Lines.Add(new RunLine
|
||||
{
|
||||
Start = 0,
|
||||
Count = 3,
|
||||
Top = 20,
|
||||
Bottom = 10,
|
||||
Left = 70,
|
||||
Right = 100,
|
||||
RightToLeft = true,
|
||||
});
|
||||
|
||||
Assert.Equal(0, TextRunService.CaretFromPoint(runs, 101, 15));
|
||||
Assert.Equal(1, TextRunService.CaretFromPoint(runs, 89, 15));
|
||||
Assert.Equal(3, TextRunService.CaretFromPoint(runs, 69, 15));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using MmdPdf.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace MmdPdf.Tests;
|
||||
|
||||
public sealed class WheelPageFlipGateTests
|
||||
{
|
||||
private static readonly DateTime Start = new(2026, 8, 22, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
[Fact]
|
||||
public void TwoQuickNotchesAtEdge_ConfirmPageFlip()
|
||||
{
|
||||
var gate = new WheelPageFlipGate();
|
||||
|
||||
Assert.False(gate.TryConfirm(-120, Start));
|
||||
Assert.True(gate.TryConfirm(-120, Start.AddMilliseconds(100)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MomentumImmediatelyAfterContentScroll_DoesNotFlip()
|
||||
{
|
||||
var gate = new WheelPageFlipGate();
|
||||
gate.NoteContentScroll(Start);
|
||||
|
||||
Assert.False(gate.TryConfirm(-120, Start.AddMilliseconds(50)));
|
||||
Assert.False(gate.TryConfirm(-120, Start.AddMilliseconds(150)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OppositeDirection_RestartsConfirmation()
|
||||
{
|
||||
var gate = new WheelPageFlipGate();
|
||||
|
||||
Assert.False(gate.TryConfirm(-120, Start));
|
||||
Assert.False(gate.TryConfirm(120, Start.AddMilliseconds(100)));
|
||||
Assert.True(gate.TryConfirm(120, Start.AddMilliseconds(200)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SlowNotches_DoNotCombineIntoPageFlip()
|
||||
{
|
||||
var gate = new WheelPageFlipGate();
|
||||
|
||||
Assert.False(gate.TryConfirm(-120, Start));
|
||||
Assert.False(gate.TryConfirm(-120, Start.AddMilliseconds(700)));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user