mirror of
https://github.com/Smaug123/WoofWare.Expect
synced 2025-10-06 13:08:39 +00:00
Compare commits
8 Commits
WoofWare.E
...
WoofWare.E
Author | SHA1 | Date | |
---|---|---|---|
|
d21786ecd4 | ||
|
ad05a9c106 | ||
|
ed352c1b14 | ||
|
e0153ab182 | ||
|
ca74c4816b | ||
|
75899d5668 | ||
|
34a2b460b9 | ||
|
0b64d3dd34 |
22
.envrc
22
.envrc
@@ -1 +1,23 @@
|
||||
use flake
|
||||
DOTNET_PATH=$(readlink "$(which dotnet)")
|
||||
SETTINGS_FILE=$(find . -maxdepth 1 -type f -name '*.sln.DotSettings.user')
|
||||
MSBUILD=$(realpath "$(find "$(dirname "$DOTNET_PATH")/../share/dotnet/sdk" -maxdepth 2 -type f -name MSBuild.dll)")
|
||||
if [ -f "$SETTINGS_FILE" ] ; then
|
||||
xmlstarlet ed --inplace \
|
||||
-N wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation" \
|
||||
-N x="http://schemas.microsoft.com/winfx/2006/xaml" \
|
||||
-N s="clr-namespace:System;assembly=mscorlib" \
|
||||
-N ss="urn:shemas-jetbrains-com:settings-storage-xaml" \
|
||||
--update "//s:String[@x:Key='/Default/Environment/Hierarchy/Build/BuildTool/DotNetCliExePath/@EntryValue']" \
|
||||
--value "$(realpath "$(dirname "$DOTNET_PATH")/../share/dotnet/dotnet")" \
|
||||
"$SETTINGS_FILE"
|
||||
|
||||
xmlstarlet ed --inplace \
|
||||
-N wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation" \
|
||||
-N x="http://schemas.microsoft.com/winfx/2006/xaml" \
|
||||
-N s="clr-namespace:System;assembly=mscorlib" \
|
||||
-N ss="urn:shemas-jetbrains-com:settings-storage-xaml" \
|
||||
--update "//s:String[@x:Key='/Default/Environment/Hierarchy/Build/BuildTool/CustomBuildToolPath/@EntryValue']" \
|
||||
--value "$MSBUILD" \
|
||||
"$SETTINGS_FILE"
|
||||
fi
|
||||
|
14
README.md
14
README.md
@@ -17,7 +17,7 @@ An [expect-testing](https://blog.janestreet.com/the-joy-of-expect-tests/) librar
|
||||
|
||||
The basic mechanism works.
|
||||
Snapshot updating is vibe-coded with Opus 4 and is purely text-based; I didn't want to use the F# compiler services because that's a pretty heavyweight dependency which should be confined to a separate test runner entity.
|
||||
It's not very well tested, and I expect it to be kind of brittle.
|
||||
It's fairly well tested, but you will certainly be able to find ways to break it; try not to be too fancy with your syntax around the `snapshot` statement.
|
||||
|
||||
# How to use
|
||||
|
||||
@@ -44,6 +44,13 @@ let ``This test fails: plain text comparison of ToString`` () =
|
||||
snapshot " 123 "
|
||||
return 123
|
||||
}
|
||||
|
||||
[<Test>]
|
||||
let ``With return! and snapshotThrows, you can see exceptions too`` () =
|
||||
expect {
|
||||
snapshotThrows @"System.Exception: oh no"
|
||||
return! (fun () -> failwith<int> "oh no")
|
||||
}
|
||||
```
|
||||
|
||||
You can adjust the formatting:
|
||||
@@ -180,6 +187,11 @@ Observe the `OneTimeSetUp` which sets global state to enter "bulk update" mode,
|
||||
|
||||
* The snapshot updating mechanism *requires* you to use verbatim string literals. While the test assertions will work correctly if you do `snapshot ("foo" + "bar" + f 3)`, for example, the updating code is liable to do something undefined in that case. Also do not use format strings (`$"blah"`).
|
||||
|
||||
# Output formats
|
||||
|
||||
* The `Diff` module provides a Patience diff and a Myers diff implementation, which you can use to make certain tests much more readable.
|
||||
* The `Dot` module provides `render`, which renders a dot file as ASCII art. You will need `graph-easy` to use this feature.
|
||||
|
||||
# Licence
|
||||
|
||||
MIT.
|
||||
|
@@ -19,12 +19,9 @@ module SimpleTest =
|
||||
let ``Example of a failing test`` () =
|
||||
expect {
|
||||
snapshot
|
||||
@"snapshot mismatch! snapshot at filepath.fs:99 (Example of a failing test) was:
|
||||
@"snapshot mismatch! snapshot at filepath.fs:99 (Example of a failing test) diff:
|
||||
|
||||
- 123
|
||||
|
||||
actual was:
|
||||
|
||||
+ 124"
|
||||
|
||||
return
|
||||
@@ -64,26 +61,21 @@ actual was:
|
||||
// Out of the box, comments in snapshots cause the JSON parser to throw, so the snapshot fails to match...
|
||||
expect {
|
||||
snapshot
|
||||
@"snapshot mismatch! snapshot at file.fs:99 (Custom JSON output) was:
|
||||
@"snapshot mismatch! snapshot at file.fs:99 (Custom JSON output) diff:
|
||||
|
||||
- [JSON failed to parse:] {
|
||||
- // a key here
|
||||
- ""a"":3
|
||||
- }
|
||||
|
||||
actual was:
|
||||
|
||||
- // a key here
|
||||
+ {
|
||||
+ ""a"": 3
|
||||
+ }"
|
||||
""a"": 3
|
||||
}"
|
||||
|
||||
return
|
||||
Assert.Throws<ExpectException> (fun () ->
|
||||
expectWithMockedFilePath ("file.fs", 99) {
|
||||
snapshotJson
|
||||
@"{
|
||||
// a key here
|
||||
""a"":3
|
||||
// a key here
|
||||
""a"": 3
|
||||
}"
|
||||
|
||||
return Map.ofList [ "a", 3 ]
|
||||
|
110
WoofWare.Expect.Test/TestDiff.fs
Normal file
110
WoofWare.Expect.Test/TestDiff.fs
Normal file
@@ -0,0 +1,110 @@
|
||||
namespace WoofWare.Expect.Test
|
||||
|
||||
open WoofWare.Expect
|
||||
open NUnit.Framework
|
||||
|
||||
[<TestFixture>]
|
||||
module TestDiff =
|
||||
|
||||
[<Test>]
|
||||
let ``Basic diff`` () =
|
||||
let textA =
|
||||
[|
|
||||
"The quick brown fox"
|
||||
"jumps over"
|
||||
"the lazy dog"
|
||||
"Some unique line here"
|
||||
"The end"
|
||||
|]
|
||||
|
||||
let textB =
|
||||
[|
|
||||
"The quick brown fox"
|
||||
"Some unique line here"
|
||||
"jumps over"
|
||||
"the lazy dog"
|
||||
"Another line"
|
||||
"The end"
|
||||
|]
|
||||
|
||||
let diff = Diff.patienceLines textA textB
|
||||
|
||||
expect {
|
||||
snapshot
|
||||
@" 0 0 The quick brown fox
|
||||
+ 1 Some unique line here
|
||||
1 2 jumps over
|
||||
2 3 the lazy dog
|
||||
- 3 Some unique line here
|
||||
+ 4 Another line
|
||||
4 5 The end"
|
||||
|
||||
withFormat Diff.formatWithLineNumbers
|
||||
return diff
|
||||
}
|
||||
|
||||
expect {
|
||||
snapshot
|
||||
@" The quick brown fox
|
||||
+ Some unique line here
|
||||
jumps over
|
||||
the lazy dog
|
||||
- Some unique line here
|
||||
+ Another line
|
||||
The end"
|
||||
|
||||
withFormat Diff.format
|
||||
return diff
|
||||
}
|
||||
|
||||
[<Test>]
|
||||
let ``An example from Incremental`` () =
|
||||
let textA =
|
||||
"""digraph G {
|
||||
rankdir = TB
|
||||
bgcolor = transparent
|
||||
n4 [shape=Mrecord label="{{n4|BindMain|height=2}}" "fontname"="Sans Serif"]
|
||||
n3 [shape=Mrecord label="{{n3|BindLhsChange|height=1}}" "fontname"="Sans Serif"]
|
||||
n1 [shape=Mrecord label="{{n1|Const|height=0}}" "fontname"="Sans Serif"]
|
||||
n2 [shape=Mrecord label="{{n2|Const|height=0}}" "fontname"="Sans Serif"]
|
||||
n3 -> n4
|
||||
n2 -> n4
|
||||
n1 -> n3
|
||||
}"""
|
||||
|
||||
let textB =
|
||||
"""digraph G {
|
||||
rankdir = TB
|
||||
bgcolor = transparent
|
||||
n4 [shape=box label="{{n4|BindMain|height=2}}" ]
|
||||
n3 [shape=box label="{{n3|BindLhsChange|height=1}}" ]
|
||||
n1 [shape=box label="{{n1|Const|height=0}}" ]
|
||||
n2 [shape=box label="{{n2|Const|height=0}}" ]
|
||||
n3 -> n4
|
||||
n2 -> n4
|
||||
n1 -> n3
|
||||
}"""
|
||||
|
||||
let diff = Diff.patience textA textB
|
||||
|
||||
expect {
|
||||
snapshot
|
||||
@" digraph G {
|
||||
rankdir = TB
|
||||
bgcolor = transparent
|
||||
- n4 [shape=Mrecord label=""{{n4|BindMain|height=2}}"" ""fontname""=""Sans Serif""]
|
||||
- n3 [shape=Mrecord label=""{{n3|BindLhsChange|height=1}}"" ""fontname""=""Sans Serif""]
|
||||
- n1 [shape=Mrecord label=""{{n1|Const|height=0}}"" ""fontname""=""Sans Serif""]
|
||||
- n2 [shape=Mrecord label=""{{n2|Const|height=0}}"" ""fontname""=""Sans Serif""]
|
||||
+ n4 [shape=box label=""{{n4|BindMain|height=2}}"" ]
|
||||
+ n3 [shape=box label=""{{n3|BindLhsChange|height=1}}"" ]
|
||||
+ n1 [shape=box label=""{{n1|Const|height=0}}"" ]
|
||||
+ n2 [shape=box label=""{{n2|Const|height=0}}"" ]
|
||||
n3 -> n4
|
||||
n2 -> n4
|
||||
n1 -> n3
|
||||
}"
|
||||
|
||||
withFormat Diff.format
|
||||
return diff
|
||||
}
|
110
WoofWare.Expect.Test/TestDot.fs
Normal file
110
WoofWare.Expect.Test/TestDot.fs
Normal file
@@ -0,0 +1,110 @@
|
||||
namespace WoofWare.Expect.Test
|
||||
|
||||
#nowarn 0044 // This construct is deprecated
|
||||
|
||||
open System
|
||||
open FsUnitTyped
|
||||
open WoofWare.Expect
|
||||
open NUnit.Framework
|
||||
open System.IO.Abstractions
|
||||
open System.IO.Abstractions.TestingHelpers
|
||||
|
||||
[<TestFixture>]
|
||||
module TestDot =
|
||||
let toFs (fs : IFileSystem) : Dot.IFileSystem =
|
||||
{ new Dot.IFileSystem with
|
||||
member _.DeleteFile s = fs.File.Delete s
|
||||
member _.WriteFile path contents = fs.File.WriteAllText (path, contents)
|
||||
member _.GetTempFileName () = fs.Path.GetTempFileName ()
|
||||
}
|
||||
|
||||
[<Test ; Explicit "requires graph-easy dependency">]
|
||||
let ``Basic dotfile, real graph-easy`` () =
|
||||
let s =
|
||||
"""digraph G {
|
||||
rankdir = TB
|
||||
bgcolor = transparent
|
||||
n2 [shape=box label="{{n2|Map|height=1}}" ]
|
||||
n1 [shape=box label="{{n1|Const|height=0}}" ]
|
||||
n1 -> n2
|
||||
}"""
|
||||
|
||||
expect {
|
||||
snapshot
|
||||
@"
|
||||
┌───────────────────────┐
|
||||
│ {{n1|Const|height=0}} │
|
||||
└───────────────────────┘
|
||||
│
|
||||
│
|
||||
▼
|
||||
┌───────────────────────┐
|
||||
│ {{n2|Map|height=1}} │
|
||||
└───────────────────────┘
|
||||
"
|
||||
|
||||
return Dot.render s
|
||||
}
|
||||
|
||||
[<Test>]
|
||||
let ``Basic dotfile`` () =
|
||||
let fs = MockFileSystem ()
|
||||
|
||||
let contents =
|
||||
"""digraph G {
|
||||
rankdir = TB
|
||||
bgcolor = transparent
|
||||
n2 [shape=box label="{{n2|Map|height=1}}" ]
|
||||
n1 [shape=box label="{{n1|Const|height=0}}" ]
|
||||
n1 -> n2
|
||||
}"""
|
||||
|
||||
let mutable started = false
|
||||
let mutable waited = false
|
||||
let mutable exitCodeObserved = false
|
||||
let mutable disposed = false
|
||||
|
||||
let expected =
|
||||
"┌───────────────────────┐
|
||||
│ {{n1|Const|height=0}} │
|
||||
└───────────────────────┘
|
||||
│
|
||||
│
|
||||
▼
|
||||
┌───────────────────────┐
|
||||
│ {{n2|Map|height=1}} │
|
||||
└───────────────────────┘
|
||||
"
|
||||
|
||||
let pr =
|
||||
{ new Dot.IProcess<IDisposable> with
|
||||
member _.Start _ =
|
||||
started <- true
|
||||
true
|
||||
|
||||
member _.Create exe args =
|
||||
exe |> shouldEqual "graph-easy"
|
||||
|
||||
args.StartsWith ("--as=boxart --from=dot ", StringComparison.Ordinal)
|
||||
|> shouldEqual true
|
||||
|
||||
{ new IDisposable with
|
||||
member _.Dispose () = disposed <- true
|
||||
}
|
||||
|
||||
member _.WaitForExit p = waited <- true
|
||||
member _.ReadStandardOutput _ = expected
|
||||
|
||||
member _.ExitCode _ =
|
||||
exitCodeObserved <- true
|
||||
0
|
||||
}
|
||||
|
||||
Dot.render' pr (toFs fs) "graph-easy" contents
|
||||
|> _.TrimStart()
|
||||
|> shouldEqual expected
|
||||
|
||||
started |> shouldEqual true
|
||||
waited |> shouldEqual true
|
||||
exitCodeObserved |> shouldEqual true
|
||||
disposed |> shouldEqual true
|
14
WoofWare.Expect.Test/TestExceptionThrowing.fs
Normal file
14
WoofWare.Expect.Test/TestExceptionThrowing.fs
Normal file
@@ -0,0 +1,14 @@
|
||||
namespace WoofWare.Expect.Test
|
||||
|
||||
open NUnit.Framework
|
||||
open WoofWare.Expect
|
||||
|
||||
[<TestFixture>]
|
||||
module TestExceptionThrowing =
|
||||
|
||||
[<Test>]
|
||||
let ``Can throw an exception`` () =
|
||||
expect {
|
||||
snapshotThrows @"System.Exception: oh no"
|
||||
return! (fun () -> failwith<int> "oh no")
|
||||
}
|
@@ -13,6 +13,9 @@
|
||||
<Compile Include="Assembly.fs" />
|
||||
<Compile Include="BulkUpdateExample.fs" />
|
||||
<Compile Include="SimpleTest.fs" />
|
||||
<Compile Include="TestDiff.fs" />
|
||||
<Compile Include="TestDot.fs" />
|
||||
<Compile Include="TestExceptionThrowing.fs" />
|
||||
<Compile Include="TestSurface.fs" />
|
||||
<Compile Include="TestSnapshotFinding\TestSnapshotFinding.fs" />
|
||||
<Compile Include="TestSnapshotFinding\TestUnicodeCharacters.fs" />
|
||||
@@ -38,6 +41,9 @@
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1"/>
|
||||
<PackageReference Include="NUnit" Version="4.3.2"/>
|
||||
<PackageReference Include="NUnit3TestAdapter" Version="5.0.0"/>
|
||||
<!-- TODO: when ApiSurface accepts https://github.com/G-Research/ApiSurface/pull/116, upgrade these -->
|
||||
<PackageReference Include="System.IO.Abstractions" Version="4.2.13" />
|
||||
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="4.2.13" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
@@ -23,8 +23,6 @@ type Mode =
|
||||
/// <param name="applyChanges">When running the tests, instead of throwing an exception on failure, update the snapshot.</param>
|
||||
/// <param name="sourceOverride">Override the file path and line numbers reported in snapshots, so that your tests can be fully stable even on failure. (You almost certainly don't want to set this.)</param>
|
||||
type ExpectBuilder (mode : Mode) =
|
||||
member private this.Mode = Unchecked.defaultof<Mode>
|
||||
|
||||
new (sourceOverride : string * int) = ExpectBuilder (Mode.AssertMockingSource sourceOverride)
|
||||
|
||||
new (update : bool)
|
||||
@@ -144,6 +142,51 @@ type ExpectBuilder (mode : Mode) =
|
||||
Actual = None
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Expresses that the given expression throws during evaluation.
|
||||
/// </summary>
|
||||
/// <example>
|
||||
/// <code>
|
||||
/// expect {
|
||||
/// snapshotThrows @"System.Exception: oh no"
|
||||
/// return! (fun () -> failwith "oh no")
|
||||
/// }
|
||||
/// </code>
|
||||
/// </example>
|
||||
[<CustomOperation("snapshotThrows", MaintainsVariableSpaceUsingBind = true)>]
|
||||
member _.SnapshotThrows<'a>
|
||||
(
|
||||
state : ExpectState<'a>,
|
||||
snapshot : string,
|
||||
[<CallerMemberName>] ?memberName : string,
|
||||
[<CallerLineNumber>] ?callerLine : int,
|
||||
[<CallerFilePath>] ?filePath : string
|
||||
)
|
||||
: ExpectState<'a>
|
||||
=
|
||||
match state.Snapshot with
|
||||
| Some _ -> failwith "snapshot can only be specified once"
|
||||
| None ->
|
||||
|
||||
let memberName = defaultArg memberName "<unknown method>"
|
||||
let filePath = defaultArg filePath "<unknown file>"
|
||||
let lineNumber = defaultArg callerLine -1
|
||||
|
||||
let callerInfo =
|
||||
{
|
||||
MemberName = memberName
|
||||
FilePath = filePath
|
||||
LineNumber = lineNumber
|
||||
}
|
||||
|
||||
{
|
||||
Formatter = None
|
||||
JsonSerialiserOptions = state.JsonSerialiserOptions
|
||||
JsonDocOptions = state.JsonDocOptions
|
||||
Snapshot = Some (SnapshotValue.ThrowsException snapshot, callerInfo)
|
||||
Actual = None
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Express that the <c>return</c> value of this builder should be formatted using this function, before
|
||||
/// comparing to the snapshot.
|
||||
@@ -158,7 +201,7 @@ type ExpectBuilder (mode : Mode) =
|
||||
| Some _ -> failwith "Please don't supply withFormat more than once"
|
||||
| None ->
|
||||
{ state with
|
||||
Formatter = Some formatter
|
||||
Formatter = Some (fun f -> f () |> formatter)
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -226,6 +269,17 @@ type ExpectBuilder (mode : Mode) =
|
||||
|
||||
/// Expresses the "actual value" component of the assertion "expected snapshot = actual value".
|
||||
member _.Return (value : 'T) : ExpectState<'T> =
|
||||
{
|
||||
Snapshot = None
|
||||
Formatter = None
|
||||
JsonDocOptions = None
|
||||
JsonSerialiserOptions = None
|
||||
Actual = Some (fun () -> value)
|
||||
}
|
||||
|
||||
/// Expresses the "actual value" component of the assertion "expected snapshot = actual value", but delayed behind
|
||||
/// a function (by contrast with `Return`).
|
||||
member _.ReturnFrom (value : unit -> 'T) : ExpectState<'T> =
|
||||
{
|
||||
Snapshot = None
|
||||
Formatter = None
|
||||
@@ -244,40 +298,42 @@ type ExpectBuilder (mode : Mode) =
|
||||
let raiseError (snapshot : string) (actual : string) : unit =
|
||||
match mode with
|
||||
| Mode.AssertMockingSource (mockSource, line) ->
|
||||
let diff = Diff.patience snapshot actual
|
||||
|
||||
sprintf
|
||||
"snapshot mismatch! snapshot at %s:%i (%s) was:\n\n%s\n\nactual was:\n\n%s"
|
||||
"snapshot mismatch! snapshot at %s:%i (%s) diff:\n\n%s"
|
||||
mockSource
|
||||
line
|
||||
state.Caller.MemberName
|
||||
(snapshot |> Text.predent '-')
|
||||
(actual |> Text.predent '+')
|
||||
(Diff.format diff)
|
||||
|> ExpectException
|
||||
|> raise
|
||||
| Mode.Assert ->
|
||||
if GlobalBuilderConfig.bulkUpdate.Value > 0 then
|
||||
if GlobalBuilderConfig.isBulkUpdateMode () then
|
||||
GlobalBuilderConfig.registerTest state
|
||||
else
|
||||
let diff = Diff.patience snapshot actual
|
||||
|
||||
sprintf
|
||||
"snapshot mismatch! snapshot at %s:%i (%s) was:\n\n%s\n\nactual was:\n\n%s"
|
||||
"snapshot mismatch! snapshot at %s:%i (%s) diff:\n\n%s"
|
||||
state.Caller.FilePath
|
||||
state.Caller.LineNumber
|
||||
state.Caller.MemberName
|
||||
(snapshot |> Text.predent '-')
|
||||
(actual |> Text.predent '+')
|
||||
(Diff.format diff)
|
||||
|> ExpectException
|
||||
|> raise
|
||||
| Mode.Update ->
|
||||
let lines = File.ReadAllLines state.Caller.FilePath
|
||||
let oldContents = String.concat "\n" lines
|
||||
let lines = SnapshotUpdate.updateSnapshotAtLine lines state.Caller.LineNumber actual
|
||||
File.WriteAllLines (state.Caller.FilePath, lines)
|
||||
File.writeAllLines lines state.Caller.FilePath
|
||||
failwith ("Snapshot successfully updated. Previous contents:\n" + oldContents)
|
||||
|
||||
match CompletedSnapshotGeneric.passesAssertion state with
|
||||
| None ->
|
||||
match mode, GlobalBuilderConfig.bulkUpdate.Value with
|
||||
match mode, GlobalBuilderConfig.isBulkUpdateMode () with
|
||||
| Mode.Update, _
|
||||
| _, 1 ->
|
||||
| _, true ->
|
||||
failwith
|
||||
"Snapshot assertion passed, but we are in snapshot-updating mode. Use the `expect` builder instead of `expect'` to assert the contents of a single snapshot; disable `GlobalBuilderConfig.bulkUpdate` to move back to assertion-checking mode."
|
||||
| _ -> ()
|
||||
|
@@ -1,12 +1,19 @@
|
||||
namespace WoofWare.Expect
|
||||
|
||||
open System.Threading
|
||||
|
||||
/// Module holding global mutable state controlling the behaviour of WoofWare.Expect
|
||||
/// when running in bulk-update mode.
|
||||
[<RequireQualifiedAccess>]
|
||||
module GlobalBuilderConfig =
|
||||
let internal bulkUpdate = ref 0
|
||||
/// All access to the global mutable state locks on this.
|
||||
let private locker = obj ()
|
||||
|
||||
// Global mutable state ensuring there is at most one `enterBulkUpdateMode`/`updateAllSnapshots` pair running at once.
|
||||
let private bulkUpdate = ref 0
|
||||
|
||||
let private allTests : ResizeArray<CompletedSnapshot> = ResizeArray ()
|
||||
|
||||
let internal isBulkUpdateMode () : bool =
|
||||
lock locker (fun () -> bulkUpdate.Value > 0)
|
||||
|
||||
/// <summary>
|
||||
/// Call this to make the <c>expect</c> builder register all tests for bulk update as it runs.
|
||||
@@ -16,11 +23,15 @@ module GlobalBuilderConfig =
|
||||
/// The implied global mutable state is liable to interfere with other expect builders in other fixtures otherwise.
|
||||
/// </remarks>
|
||||
let enterBulkUpdateMode () =
|
||||
if Interlocked.Increment bulkUpdate <> 1 then
|
||||
failwith
|
||||
"WoofWare.Expect requires bulk updates to happen serially: for example, make the test fixture `[<NonParallelizable>]` if you're using NUnit."
|
||||
lock
|
||||
locker
|
||||
(fun () ->
|
||||
if bulkUpdate.Value <> 0 then
|
||||
failwith
|
||||
"WoofWare.Expect requires bulk updates to happen serially: for example, make the test fixture `[<NonParallelizable>]` if you're using NUnit."
|
||||
|
||||
let private allTests : ResizeArray<CompletedSnapshot> = ResizeArray ()
|
||||
bulkUpdate.Value <- bulkUpdate.Value + 1
|
||||
)
|
||||
|
||||
/// <summary>
|
||||
/// Clear the set of failing tests registered by any previous bulk-update runs.
|
||||
@@ -30,23 +41,31 @@ module GlobalBuilderConfig =
|
||||
/// You probably don't need to do this, because your test runner is probably tearing down
|
||||
/// anyway after the tests have failed; this is mainly here for WoofWare.Expect's own internal testing.
|
||||
/// </remarks>
|
||||
let clearTests () = lock allTests allTests.Clear
|
||||
let clearTests () = lock locker allTests.Clear
|
||||
|
||||
let internal registerTest (s : CompletedSnapshotGeneric<'T>) : unit =
|
||||
let toAdd = s |> CompletedSnapshot.make
|
||||
lock allTests (fun () -> allTests.Add toAdd)
|
||||
lock locker (fun () -> allTests.Add toAdd)
|
||||
|
||||
/// <summary>
|
||||
/// For all tests whose failures have already been registered,
|
||||
/// transform the files on disk so that the failing snapshots now pass.
|
||||
/// </summary>
|
||||
let updateAllSnapshots () =
|
||||
let bulkUpdate' = Interlocked.Decrement bulkUpdate
|
||||
// It's OK for this to be called when `enterBulkUpdateMode` has not been called, i.e. when `bulkUpdate` has
|
||||
// value 0. That just means we aren't in bulk-update mode, so we expect the following simply to do nothing.
|
||||
// (This is an expected workflow: we expect users to run `updateAllSnapshots` unconditionally in a
|
||||
// one-time tear-down of the test suite, and they use the one-time setup to control whether any work is actually
|
||||
// performed here.)
|
||||
lock
|
||||
locker
|
||||
(fun () ->
|
||||
let allTests = Seq.toArray allTests
|
||||
|
||||
try
|
||||
if bulkUpdate' = 0 then
|
||||
let allTests = lock allTests (fun () -> Seq.toArray allTests)
|
||||
SnapshotUpdate.updateAll allTests
|
||||
|
||||
finally
|
||||
clearTests ()
|
||||
try
|
||||
SnapshotUpdate.updateAll allTests
|
||||
finally
|
||||
// double acquiring of reentrant lock is OK, we're not switching threads
|
||||
clearTests ()
|
||||
bulkUpdate.Value <- 0
|
||||
)
|
||||
|
309
WoofWare.Expect/Diff.fs
Normal file
309
WoofWare.Expect/Diff.fs
Normal file
@@ -0,0 +1,309 @@
|
||||
namespace WoofWare.Expect
|
||||
|
||||
open System.Collections.Generic
|
||||
|
||||
/// A unit of measure tagging "positions in a sequence".
|
||||
[<Measure>]
|
||||
type pos
|
||||
|
||||
/// Position in a sequence
|
||||
type Position = int<pos>
|
||||
|
||||
/// A Patience diff is composed of a sequence of transformations to get from one string to another.
|
||||
/// This represents those transformations.
|
||||
type DiffOperation<'line> =
|
||||
/// This line is the same on both sides of the diff.
|
||||
/// On the left, it appears at position posA. On the right, at position posB.
|
||||
| Match of posA : Position * posB : Position * line : 'line
|
||||
/// Delete this line, which is at this position.
|
||||
| Delete of posA : Position * line : 'line
|
||||
/// Insert this line at the given position.
|
||||
| Insert of posB : Position * line : 'line
|
||||
|
||||
/// The diff between two line-oriented streams. Normally the generic parameter will be `string`, indicating
|
||||
/// that the thing being diffed was text.
|
||||
type Diff'<'line> = private | Diff of DiffOperation<'line> list
|
||||
|
||||
/// The diff between two line-oriented pieces of text.
|
||||
type Diff = Diff'<string>
|
||||
|
||||
/// A match between positions in two sequences
|
||||
type internal LineMatch<'line> =
|
||||
{
|
||||
PosA : Position
|
||||
PosB : Position
|
||||
Line : 'line
|
||||
}
|
||||
|
||||
/// Result of finding unique lines in a sequence
|
||||
type internal UniqueLines<'line when 'line : comparison> =
|
||||
{
|
||||
/// Map from line content to its position (only for unique lines)
|
||||
LinePositions : Map<'line, Position>
|
||||
/// All line counts (for verification)
|
||||
LineCounts : Map<'line, int>
|
||||
}
|
||||
|
||||
/// The diff between two line-oriented pieces of text.
|
||||
[<RequireQualifiedAccess>]
|
||||
module Diff =
|
||||
/// Find lines that appear exactly once in a sequence
|
||||
let private findUniqueLines (lines : 'line array) : UniqueLines<'line> =
|
||||
let positions = Dictionary<'line, Position> ()
|
||||
let counts = Dictionary<'line, int> ()
|
||||
|
||||
lines
|
||||
|> Array.iteri (fun i line ->
|
||||
if counts.ContainsKey line then
|
||||
counts.[line] <- counts.[line] + 1
|
||||
else
|
||||
counts.[line] <- 1
|
||||
positions.[line] <- i * 1<pos>
|
||||
)
|
||||
|
||||
let uniquePositions =
|
||||
positions
|
||||
|> Seq.filter (fun kvp -> counts.[kvp.Key] = 1)
|
||||
|> Seq.map (fun kvp -> (kvp.Key, kvp.Value))
|
||||
|> Map.ofSeq
|
||||
|
||||
let allCounts = counts |> Seq.map (fun kvp -> (kvp.Key, kvp.Value)) |> Map.ofSeq
|
||||
|
||||
{
|
||||
LinePositions = uniquePositions
|
||||
LineCounts = allCounts
|
||||
}
|
||||
|
||||
/// Find longest increasing subsequence based on B positions
|
||||
let private longestIncreasingSubsequence (matches : LineMatch<'line> array) : LineMatch<'line> list =
|
||||
let n = matches.Length
|
||||
|
||||
if n = 0 then
|
||||
[]
|
||||
else
|
||||
// Dynamic programming arrays
|
||||
let lengths = Array.create n 1
|
||||
let parents = Array.create n -1
|
||||
|
||||
// Build LIS
|
||||
for i in 1 .. n - 1 do
|
||||
for j in 0 .. i - 1 do
|
||||
let bj = matches.[j].PosB
|
||||
let bi = matches.[i].PosB
|
||||
|
||||
if bj < bi && lengths.[j] + 1 > lengths.[i] then
|
||||
lengths.[i] <- lengths.[j] + 1
|
||||
parents.[i] <- j
|
||||
|
||||
// Find longest sequence
|
||||
let maxLength = Array.max lengths
|
||||
let endIndex = Array.findIndex ((=) maxLength) lengths
|
||||
|
||||
// Reconstruct sequence
|
||||
let rec reconstruct idx acc =
|
||||
if idx = -1 then
|
||||
acc
|
||||
else
|
||||
reconstruct parents.[idx] (matches.[idx] :: acc)
|
||||
|
||||
reconstruct endIndex []
|
||||
|
||||
let private myers' (a : 'line array) (b : 'line array) : DiffOperation<'line> list =
|
||||
let rec diffHelper (i : int) (j : int) (acc : DiffOperation<'line> list) =
|
||||
match i < a.Length, j < b.Length with
|
||||
| false, false -> List.rev acc
|
||||
| true, false ->
|
||||
let deletes =
|
||||
[ i .. a.Length - 1 ] |> List.map (fun idx -> Delete (idx * 1<pos>, a.[idx]))
|
||||
|
||||
(List.rev acc) @ deletes
|
||||
| false, true ->
|
||||
let inserts =
|
||||
[ j .. b.Length - 1 ] |> List.map (fun idx -> Insert (idx * 1<pos>, b.[idx]))
|
||||
|
||||
(List.rev acc) @ inserts
|
||||
| true, true ->
|
||||
if a.[i] = b.[j] then
|
||||
diffHelper (i + 1) (j + 1) (Match (i * 1<pos>, j * 1<pos>, a.[i]) :: acc)
|
||||
else
|
||||
// Look ahead for matches (simple heuristic)
|
||||
let lookAhead = 3
|
||||
|
||||
let aheadMatch =
|
||||
[ 1 .. min lookAhead (min (a.Length - i) (b.Length - j)) ]
|
||||
|> List.tryFind (fun k -> a.[i + k - 1] = b.[j + k - 1])
|
||||
|
||||
match aheadMatch with
|
||||
| Some k when k <= 2 ->
|
||||
// Delete/insert to get to the match
|
||||
let ops =
|
||||
[ 0 .. k - 2 ]
|
||||
|> List.collect (fun offset ->
|
||||
[
|
||||
Delete ((i + offset) * 1<pos>, a.[i + offset])
|
||||
Insert ((j + offset) * 1<pos>, b.[j + offset])
|
||||
]
|
||||
)
|
||||
|
||||
diffHelper (i + k - 1) (j + k - 1) (List.rev ops @ acc)
|
||||
| _ ->
|
||||
// No close match, just delete and insert
|
||||
diffHelper (i + 1) j (Delete (i * 1<pos>, a.[i]) :: acc)
|
||||
|
||||
diffHelper 0 0 []
|
||||
|
||||
/// Simple Myers diff implementation. You probably want to use `patience` instead, for more human-readable diffs.
|
||||
let myers (a : string array) (b : string array) : Diff = myers' a b |> Diff
|
||||
|
||||
/// Patience diff: a human-readable line-based diff.
|
||||
/// Operates on lines of string; the function `patience` will split on lines for you.
|
||||
let rec patienceLines (a : 'line array) (b : 'line array) : Diff'<'line> =
|
||||
// Handle empty sequences
|
||||
match a.Length, b.Length with
|
||||
| 0, 0 -> [] |> Diff
|
||||
| 0, _ ->
|
||||
b
|
||||
|> Array.mapi (fun i line -> Insert (i * 1<pos>, line))
|
||||
|> Array.toList
|
||||
|> Diff
|
||||
| _, 0 ->
|
||||
a
|
||||
|> Array.mapi (fun i line -> Delete (i * 1<pos>, line))
|
||||
|> Array.toList
|
||||
|> Diff
|
||||
| _, _ ->
|
||||
// Find unique lines
|
||||
let uniqueA = findUniqueLines a
|
||||
let uniqueB = findUniqueLines b
|
||||
|
||||
// Find common unique lines
|
||||
let commonUniques =
|
||||
Set.intersect
|
||||
(uniqueA.LinePositions |> Map.toSeq |> Seq.map fst |> Set.ofSeq)
|
||||
(uniqueB.LinePositions |> Map.toSeq |> Seq.map fst |> Set.ofSeq)
|
||||
|
||||
if Set.isEmpty commonUniques then
|
||||
// No unique common lines, fall back to Myers
|
||||
myers' a b |> Diff
|
||||
else
|
||||
// Build matches for unique common lines
|
||||
let matches =
|
||||
commonUniques
|
||||
|> Set.toArray
|
||||
|> Array.map (fun line ->
|
||||
{
|
||||
PosA = uniqueA.LinePositions.[line]
|
||||
PosB = uniqueB.LinePositions.[line]
|
||||
Line = line
|
||||
}
|
||||
)
|
||||
|> Array.sortBy (fun m -> m.PosA)
|
||||
|
||||
// Find LIS
|
||||
let anchorMatches = longestIncreasingSubsequence matches |> List.toArray
|
||||
|
||||
// Build diff imperatively
|
||||
let result = ResizeArray<DiffOperation<_>> ()
|
||||
let mutable prevA = 0<pos>
|
||||
let mutable prevB = 0<pos>
|
||||
|
||||
// Process each anchor
|
||||
for anchor in anchorMatches do
|
||||
let anchorA = anchor.PosA
|
||||
let anchorB = anchor.PosB
|
||||
|
||||
// Add diff for section before this anchor
|
||||
if prevA < anchorA || prevB < anchorB then
|
||||
let sectionA = a.[prevA / 1<pos> .. anchorA / 1<pos> - 1]
|
||||
let sectionB = b.[prevB / 1<pos> .. anchorB / 1<pos> - 1]
|
||||
let (Diff sectionDiff) = patienceLines sectionA sectionB
|
||||
|
||||
// Adjust positions and add to result
|
||||
for op in sectionDiff do
|
||||
match op with
|
||||
| Match (pa, pb, line) -> result.Add (Match ((pa + prevA), (pb + prevB), line))
|
||||
| Delete (pa, line) -> result.Add (Delete ((pa + prevA), line))
|
||||
| Insert (pb, line) -> result.Add (Insert ((pb + prevB), line))
|
||||
|
||||
// Add the anchor match
|
||||
result.Add (Match (anchor.PosA, anchor.PosB, anchor.Line))
|
||||
|
||||
// Update positions
|
||||
prevA <- anchorA + 1<pos>
|
||||
prevB <- anchorB + 1<pos>
|
||||
|
||||
// Handle remaining elements after last anchor
|
||||
if prevA / 1<pos> < a.Length || prevB / 1<pos> < b.Length then
|
||||
let remainingA = a.[prevA / 1<pos> ..]
|
||||
let remainingB = b.[prevB / 1<pos> ..]
|
||||
let (Diff remainingDiff) = patienceLines remainingA remainingB
|
||||
|
||||
for op in remainingDiff do
|
||||
match op with
|
||||
| Match (pa, pb, line) -> result.Add (Match ((pa + prevA), (pb + prevB), line))
|
||||
| Delete (pa, line) -> result.Add (Delete ((pa + prevA), line))
|
||||
| Insert (pb, line) -> result.Add (Insert ((pb + prevB), line))
|
||||
|
||||
result |> Seq.toList |> Diff
|
||||
|
||||
/// Patience diff: a human-readable line-based diff.
|
||||
let patience (a : string) (b : string) =
|
||||
patienceLines (a.Split '\n') (b.Split '\n')
|
||||
|
||||
/// Format the diff as a human-readable string, including line numbers at the left.
|
||||
let formatWithLineNumbers' (formatter : 'line -> string) (Diff ops) : string =
|
||||
ops
|
||||
|> List.map (fun op ->
|
||||
match op with
|
||||
| Match (a, b, line) -> sprintf " %3d %3d %s" a b (formatter line)
|
||||
| Delete (a, line) -> sprintf "- %3d %s" a (formatter line)
|
||||
| Insert (b, line) -> sprintf "+ %3d %s" b (formatter line)
|
||||
)
|
||||
|> String.concat "\n"
|
||||
|
||||
/// Format the diff as a human-readable string, including line numbers at the left.
|
||||
let formatWithLineNumbers (d : Diff) : string = formatWithLineNumbers' id d
|
||||
|
||||
/// Format the diff as a human-readable string.
|
||||
let format' (formatter : 'line -> string) (Diff ops) : string =
|
||||
ops
|
||||
|> List.map (fun op ->
|
||||
match op with
|
||||
| Match (_, _, line) -> " " + (formatter line)
|
||||
| Delete (_, line) -> "- " + (formatter line)
|
||||
| Insert (_, line) -> "+ " + (formatter line)
|
||||
)
|
||||
|> String.concat "\n"
|
||||
|
||||
/// Format the diff as a human-readable string.
|
||||
let format (ops : Diff) : string = format' id ops
|
||||
|
||||
/// Compute diff statistics
|
||||
type internal DiffStats =
|
||||
{
|
||||
Matches : int
|
||||
Deletions : int
|
||||
Insertions : int
|
||||
TotalOperations : int
|
||||
}
|
||||
|
||||
let internal computeStats (ops : DiffOperation<'a> list) : DiffStats =
|
||||
let counts =
|
||||
ops
|
||||
|> List.fold
|
||||
(fun (m, d, i) op ->
|
||||
match op with
|
||||
| Match _ -> (m + 1, d, i)
|
||||
| Delete _ -> (m, d + 1, i)
|
||||
| Insert _ -> (m, d, i + 1)
|
||||
)
|
||||
(0, 0, 0)
|
||||
|
||||
let matches, deletions, insertions = counts
|
||||
|
||||
{
|
||||
Matches = matches
|
||||
Deletions = deletions
|
||||
Insertions = insertions
|
||||
TotalOperations = matches + deletions + insertions
|
||||
}
|
@@ -17,20 +17,21 @@ type CallerInfo =
|
||||
type private SnapshotValue =
|
||||
| Json of expected : string
|
||||
| Formatted of expected : string
|
||||
| ThrowsException of expected : string
|
||||
|
||||
type private CompletedSnapshotValue<'T> =
|
||||
| Json of expected : string * JsonSerializerOptions * JsonDocumentOptions
|
||||
| Formatted of expected : string * format : ('T -> string)
|
||||
| Formatted of expected : string * format : ((unit -> 'T) -> string)
|
||||
|
||||
/// The state accumulated by the `expect` builder. You should never find yourself interacting with this type.
|
||||
type ExpectState<'T> =
|
||||
private
|
||||
{
|
||||
Formatter : ('T -> string) option
|
||||
Formatter : ((unit -> 'T) -> string) option
|
||||
JsonSerialiserOptions : JsonSerializerOptions option
|
||||
JsonDocOptions : JsonDocumentOptions option
|
||||
Snapshot : (SnapshotValue * CallerInfo) option
|
||||
Actual : 'T option
|
||||
Actual : (unit -> 'T) option
|
||||
}
|
||||
|
||||
/// The state accumulated by the `expect` builder. You should never find yourself interacting with this type.
|
||||
@@ -39,7 +40,7 @@ type internal CompletedSnapshotGeneric<'T> =
|
||||
{
|
||||
SnapshotValue : CompletedSnapshotValue<'T>
|
||||
Caller : CallerInfo
|
||||
Actual : 'T
|
||||
Actual : unit -> 'T
|
||||
}
|
||||
|
||||
[<RequireQualifiedAccess>]
|
||||
@@ -68,11 +69,22 @@ module internal CompletedSnapshotGeneric =
|
||||
| SnapshotValue.Formatted expected ->
|
||||
let formatter =
|
||||
match state.Formatter with
|
||||
| None -> fun x -> x.ToString ()
|
||||
| None -> fun x -> x().ToString ()
|
||||
| Some f -> f
|
||||
|
||||
CompletedSnapshotValue.Formatted (expected, formatter)
|
||||
|
||||
| SnapshotValue.ThrowsException expected ->
|
||||
CompletedSnapshotValue.Formatted (
|
||||
expected,
|
||||
fun x ->
|
||||
try
|
||||
x () |> ignore
|
||||
"<no exception raised>"
|
||||
with e ->
|
||||
e.GetType().FullName + ": " + e.Message
|
||||
)
|
||||
|
||||
{
|
||||
SnapshotValue = snapshot
|
||||
Caller = source
|
||||
@@ -84,7 +96,7 @@ module internal CompletedSnapshotGeneric =
|
||||
let internal replacement (s : CompletedSnapshotGeneric<'T>) =
|
||||
match s.SnapshotValue with
|
||||
| CompletedSnapshotValue.Json (_existing, options, _) ->
|
||||
JsonSerializer.Serialize (s.Actual, options)
|
||||
JsonSerializer.Serialize (s.Actual (), options)
|
||||
|> JsonDocument.Parse
|
||||
|> _.RootElement
|
||||
|> _.ToString()
|
||||
@@ -104,7 +116,7 @@ module internal CompletedSnapshotGeneric =
|
||||
None
|
||||
|
||||
let canonicalActual =
|
||||
JsonSerializer.Serialize (state.Actual, jsonSerOptions) |> JsonDocument.Parse
|
||||
JsonSerializer.Serialize (state.Actual (), jsonSerOptions) |> JsonDocument.Parse
|
||||
|
||||
match canonicalSnapshot with
|
||||
| None -> Some ("[JSON failed to parse:] " + snapshot, canonicalActual.RootElement.ToString ())
|
||||
|
91
WoofWare.Expect/Dot.fs
Normal file
91
WoofWare.Expect/Dot.fs
Normal file
@@ -0,0 +1,91 @@
|
||||
namespace WoofWare.Expect
|
||||
|
||||
open System
|
||||
open System.Diagnostics
|
||||
open System.IO
|
||||
|
||||
/// Methods for rendering dot files (specifications of graphs).
|
||||
[<RequireQualifiedAccess>]
|
||||
module Dot =
|
||||
/// A mock for System.Diagnostics.Process.
|
||||
type IProcess<'Process when 'Process :> IDisposable> =
|
||||
/// Equivalent to Process.Create
|
||||
abstract Create : exe : string -> args : string -> 'Process
|
||||
/// Equivalent to Process.Start
|
||||
abstract Start : 'Process -> bool
|
||||
/// Equivalent to Process.WaitForExit
|
||||
abstract WaitForExit : 'Process -> unit
|
||||
/// Equivalent to Process.StandardOutput.ReadToEnd
|
||||
abstract ReadStandardOutput : 'Process -> string
|
||||
/// Equivalent to Process.ExitCode
|
||||
abstract ExitCode : 'Process -> int
|
||||
|
||||
/// The real Process interface, in a form that can be passed to `render'`.
|
||||
let process' =
|
||||
{ new IProcess<Process> with
|
||||
member _.Create exe args =
|
||||
let psi = ProcessStartInfo exe
|
||||
psi.RedirectStandardOutput <- true
|
||||
psi.Arguments <- args
|
||||
let result = new Process ()
|
||||
result.StartInfo <- psi
|
||||
result
|
||||
|
||||
member _.Start p = p.Start ()
|
||||
member _.WaitForExit p = p.WaitForExit ()
|
||||
member _.ReadStandardOutput p = p.StandardOutput.ReadToEnd ()
|
||||
member _.ExitCode p = p.ExitCode
|
||||
}
|
||||
|
||||
/// A mock for System.IO
|
||||
type IFileSystem =
|
||||
/// Equivalent to Path.GetTempFileName
|
||||
abstract GetTempFileName : unit -> string
|
||||
/// Equivalent to File.Delete
|
||||
abstract DeleteFile : string -> unit
|
||||
/// Equivalent to File.WriteAllText (curried)
|
||||
abstract WriteFile : path : string -> contents : string -> unit
|
||||
|
||||
/// The real filesystem, in a form that can be passed to `render'`.
|
||||
let fileSystem =
|
||||
{ new IFileSystem with
|
||||
member _.GetTempFileName () = Path.GetTempFileName ()
|
||||
member _.DeleteFile f = File.Delete f
|
||||
member _.WriteFile path contents = File.WriteAllText (path, contents)
|
||||
}
|
||||
|
||||
/// writeFile takes the filepath first and the contents second.
|
||||
/// Due to the impoverished nature of the .NET Standard APIs, you are in charge of making sure the output of
|
||||
/// fs.GetTempFileName is suitable for interpolation into a command line.
|
||||
let render'<'Process when 'Process :> IDisposable>
|
||||
(pr : IProcess<'Process>)
|
||||
(fs : IFileSystem)
|
||||
(graphEasyExecutable : string)
|
||||
(dotFileContents : string)
|
||||
: string
|
||||
=
|
||||
let tempFile = fs.GetTempFileName ()
|
||||
|
||||
try
|
||||
fs.WriteFile tempFile dotFileContents
|
||||
|
||||
use p = pr.Create graphEasyExecutable ("--as=boxart --from=dot " + tempFile)
|
||||
pr.Start p |> ignore<bool>
|
||||
pr.WaitForExit p
|
||||
|
||||
let stdout = pr.ReadStandardOutput p
|
||||
let exitCode = pr.ExitCode p
|
||||
|
||||
if exitCode <> 0 then
|
||||
failwithf "failed to run; exit code: %i. stdout:\n%s" exitCode stdout
|
||||
|
||||
"\n" + stdout
|
||||
finally
|
||||
try
|
||||
fs.DeleteFile tempFile
|
||||
with _ ->
|
||||
()
|
||||
|
||||
/// Call `graph-easy` to render the dotfile as ASCII art.
|
||||
/// This is fully mockable, but you must use `render'` to do so.
|
||||
let render = render' process' fileSystem "graph-easy"
|
32
WoofWare.Expect/File.fs
Normal file
32
WoofWare.Expect/File.fs
Normal file
@@ -0,0 +1,32 @@
|
||||
namespace WoofWare.Expect
|
||||
|
||||
open System
|
||||
open System.IO
|
||||
|
||||
[<RequireQualifiedAccess>]
|
||||
module internal File =
|
||||
|
||||
/// Standard attempt at an atomic file write.
|
||||
/// It may fail to be atomic if the working directory somehow spans multiple volumes,
|
||||
/// and of course with external network storage all bets are off.
|
||||
let writeAllLines (lines : string[]) (path : string) : unit =
|
||||
let file = FileInfo path
|
||||
|
||||
let tempFile =
|
||||
Path.Combine (file.Directory.FullName, file.Name + "." + Guid.NewGuid().ToString () + ".tmp")
|
||||
|
||||
try
|
||||
File.WriteAllLines (tempFile, lines)
|
||||
// Atomicity guarantees are undocumented, but on Unix this is an atomic `rename` call
|
||||
// https://github.com/dotnet/runtime/blob/9a4be5b56d81aa04c7ea687c02b3f4e64c83761b/src/libraries/System.Private.CoreLib/src/System/IO/FileSystem.Unix.cs#L181
|
||||
// and on Windows this is an atomic ReplaceFile:
|
||||
// https://github.com/dotnet/runtime/blob/9a4be5b56d81aa04c7ea687c02b3f4e64c83761b/src/libraries/System.Private.CoreLib/src/System/IO/FileSystem.Windows.cs#L92
|
||||
// calls https://github.com/dotnet/runtime/blob/9a4be5b56d81aa04c7ea687c02b3f4e64c83761b/src/libraries/Common/src/Interop/Windows/Kernel32/Interop.ReplaceFile.cs#L12
|
||||
// which calls ReplaceFileW, whose atomicity guarantees are again apparently undocumented,
|
||||
// but 4o-turbo, Opus 4, and Gemini 2.5 Flash all think it's atomic.
|
||||
File.Replace (tempFile, path, null)
|
||||
finally
|
||||
try
|
||||
File.Delete tempFile
|
||||
with _ ->
|
||||
()
|
@@ -15,7 +15,7 @@ type private StringLiteralInfo =
|
||||
override this.ToString () =
|
||||
sprintf "%i:%i to %i:%i: %s" this.StartLine this.StartColumn this.EndLine this.EndColumn this.Content
|
||||
|
||||
type private Position =
|
||||
type private SnapshotPosition =
|
||||
{
|
||||
Line : int
|
||||
Column : int
|
||||
@@ -28,8 +28,8 @@ module internal SnapshotUpdate =
|
||||
let tripleQuote = "\"\"\""
|
||||
|
||||
/// Convert a string position to line/column
|
||||
let private positionToLineColumn (text : string) (offset : int) : Position =
|
||||
let rec loop (line : int) (col : int) (totalOffset : int) (i : int) : Position =
|
||||
let private positionToLineColumn (text : string) (offset : int) : SnapshotPosition =
|
||||
let rec loop (line : int) (col : int) (totalOffset : int) (i : int) : SnapshotPosition =
|
||||
if i >= text.Length || totalOffset = offset then
|
||||
{
|
||||
Line = line
|
||||
@@ -147,7 +147,8 @@ module internal SnapshotUpdate =
|
||||
let searchText = String.concat "\n" relevantLines
|
||||
|
||||
// Find snapshot keyword
|
||||
let snapshotMatch = Regex.Match (searchText, @"\b(snapshot|snapshotJson)\b")
|
||||
let snapshotMatch =
|
||||
Regex.Match (searchText, @"\b(snapshot|snapshotJson|snapshotThrows)\b")
|
||||
|
||||
if not snapshotMatch.Success then
|
||||
None
|
||||
@@ -300,5 +301,5 @@ module internal SnapshotUpdate =
|
||||
|
||||
let newContents = updateAllLines contents sources
|
||||
|
||||
System.IO.File.WriteAllLines (callerFile, newContents)
|
||||
File.writeAllLines newContents callerFile
|
||||
)
|
||||
|
@@ -8,6 +8,68 @@ WoofWare.Expect.CallerInfo inherit obj, implements WoofWare.Expect.CallerInfo Sy
|
||||
WoofWare.Expect.CallerInfo.Equals [method]: (WoofWare.Expect.CallerInfo, System.Collections.IEqualityComparer) -> bool
|
||||
WoofWare.Expect.CompletedSnapshot inherit obj, implements WoofWare.Expect.CompletedSnapshot System.IEquatable, System.Collections.IStructuralEquatable, WoofWare.Expect.CompletedSnapshot System.IComparable, System.IComparable, System.Collections.IStructuralComparable
|
||||
WoofWare.Expect.CompletedSnapshot.Equals [method]: (WoofWare.Expect.CompletedSnapshot, System.Collections.IEqualityComparer) -> bool
|
||||
WoofWare.Expect.Diff'`1 inherit obj, implements 'line WoofWare.Expect.Diff' System.IEquatable, System.Collections.IStructuralEquatable, 'line WoofWare.Expect.Diff' System.IComparable, System.IComparable, System.Collections.IStructuralComparable
|
||||
WoofWare.Expect.Diff'`1.Equals [method]: ('line WoofWare.Expect.Diff', System.Collections.IEqualityComparer) -> bool
|
||||
WoofWare.Expect.DiffModule inherit obj
|
||||
WoofWare.Expect.DiffModule.format [static method]: string WoofWare.Expect.Diff' -> string
|
||||
WoofWare.Expect.DiffModule.format' [static method]: ('line -> string) -> 'line WoofWare.Expect.Diff' -> string
|
||||
WoofWare.Expect.DiffModule.formatWithLineNumbers [static method]: string WoofWare.Expect.Diff' -> string
|
||||
WoofWare.Expect.DiffModule.formatWithLineNumbers' [static method]: ('line -> string) -> 'line WoofWare.Expect.Diff' -> string
|
||||
WoofWare.Expect.DiffModule.myers [static method]: string [] -> string [] -> string WoofWare.Expect.Diff'
|
||||
WoofWare.Expect.DiffModule.patience [static method]: string -> string -> string WoofWare.Expect.Diff'
|
||||
WoofWare.Expect.DiffModule.patienceLines [static method]: 'line [] -> 'line [] -> 'line WoofWare.Expect.Diff'
|
||||
WoofWare.Expect.DiffOperation`1 inherit obj, implements 'line WoofWare.Expect.DiffOperation System.IEquatable, System.Collections.IStructuralEquatable, 'line WoofWare.Expect.DiffOperation System.IComparable, System.IComparable, System.Collections.IStructuralComparable - union type with 3 cases
|
||||
WoofWare.Expect.DiffOperation`1+Delete inherit 'line WoofWare.Expect.DiffOperation
|
||||
WoofWare.Expect.DiffOperation`1+Delete.get_line [method]: unit -> 'line
|
||||
WoofWare.Expect.DiffOperation`1+Delete.get_posA [method]: unit -> int
|
||||
WoofWare.Expect.DiffOperation`1+Delete.line [property]: [read-only] 'line
|
||||
WoofWare.Expect.DiffOperation`1+Delete.posA [property]: [read-only] int
|
||||
WoofWare.Expect.DiffOperation`1+Insert inherit 'line WoofWare.Expect.DiffOperation
|
||||
WoofWare.Expect.DiffOperation`1+Insert.get_line [method]: unit -> 'line
|
||||
WoofWare.Expect.DiffOperation`1+Insert.get_posB [method]: unit -> int
|
||||
WoofWare.Expect.DiffOperation`1+Insert.line [property]: [read-only] 'line
|
||||
WoofWare.Expect.DiffOperation`1+Insert.posB [property]: [read-only] int
|
||||
WoofWare.Expect.DiffOperation`1+Match inherit 'line WoofWare.Expect.DiffOperation
|
||||
WoofWare.Expect.DiffOperation`1+Match.get_line [method]: unit -> 'line
|
||||
WoofWare.Expect.DiffOperation`1+Match.get_posA [method]: unit -> int
|
||||
WoofWare.Expect.DiffOperation`1+Match.get_posB [method]: unit -> int
|
||||
WoofWare.Expect.DiffOperation`1+Match.line [property]: [read-only] 'line
|
||||
WoofWare.Expect.DiffOperation`1+Match.posA [property]: [read-only] int
|
||||
WoofWare.Expect.DiffOperation`1+Match.posB [property]: [read-only] int
|
||||
WoofWare.Expect.DiffOperation`1+Tags inherit obj
|
||||
WoofWare.Expect.DiffOperation`1+Tags.Delete [static field]: int = 1
|
||||
WoofWare.Expect.DiffOperation`1+Tags.Insert [static field]: int = 2
|
||||
WoofWare.Expect.DiffOperation`1+Tags.Match [static field]: int = 0
|
||||
WoofWare.Expect.DiffOperation`1.Equals [method]: ('line WoofWare.Expect.DiffOperation, System.Collections.IEqualityComparer) -> bool
|
||||
WoofWare.Expect.DiffOperation`1.get_IsDelete [method]: unit -> bool
|
||||
WoofWare.Expect.DiffOperation`1.get_IsInsert [method]: unit -> bool
|
||||
WoofWare.Expect.DiffOperation`1.get_IsMatch [method]: unit -> bool
|
||||
WoofWare.Expect.DiffOperation`1.get_Tag [method]: unit -> int
|
||||
WoofWare.Expect.DiffOperation`1.IsDelete [property]: [read-only] bool
|
||||
WoofWare.Expect.DiffOperation`1.IsInsert [property]: [read-only] bool
|
||||
WoofWare.Expect.DiffOperation`1.IsMatch [property]: [read-only] bool
|
||||
WoofWare.Expect.DiffOperation`1.NewDelete [static method]: (int, 'line) -> 'line WoofWare.Expect.DiffOperation
|
||||
WoofWare.Expect.DiffOperation`1.NewInsert [static method]: (int, 'line) -> 'line WoofWare.Expect.DiffOperation
|
||||
WoofWare.Expect.DiffOperation`1.NewMatch [static method]: (int, int, 'line) -> 'line WoofWare.Expect.DiffOperation
|
||||
WoofWare.Expect.DiffOperation`1.Tag [property]: [read-only] int
|
||||
WoofWare.Expect.Dot inherit obj
|
||||
WoofWare.Expect.Dot+IFileSystem - interface with 3 member(s)
|
||||
WoofWare.Expect.Dot+IFileSystem.DeleteFile [method]: string -> unit
|
||||
WoofWare.Expect.Dot+IFileSystem.GetTempFileName [method]: unit -> string
|
||||
WoofWare.Expect.Dot+IFileSystem.WriteFile [method]: string -> string -> unit
|
||||
WoofWare.Expect.Dot+IProcess`1 - interface with 5 member(s)
|
||||
WoofWare.Expect.Dot+IProcess`1.Create [method]: string -> string -> #(IDisposable)
|
||||
WoofWare.Expect.Dot+IProcess`1.ExitCode [method]: #(IDisposable) -> int
|
||||
WoofWare.Expect.Dot+IProcess`1.ReadStandardOutput [method]: #(IDisposable) -> string
|
||||
WoofWare.Expect.Dot+IProcess`1.Start [method]: #(IDisposable) -> bool
|
||||
WoofWare.Expect.Dot+IProcess`1.WaitForExit [method]: #(IDisposable) -> unit
|
||||
WoofWare.Expect.Dot.fileSystem [static property]: [read-only] WoofWare.Expect.Dot+IFileSystem
|
||||
WoofWare.Expect.Dot.get_fileSystem [static method]: unit -> WoofWare.Expect.Dot+IFileSystem
|
||||
WoofWare.Expect.Dot.get_process' [static method]: unit -> System.Diagnostics.Process WoofWare.Expect.Dot+IProcess
|
||||
WoofWare.Expect.Dot.get_render [static method]: unit -> (string -> string)
|
||||
WoofWare.Expect.Dot.process' [static property]: [read-only] System.Diagnostics.Process WoofWare.Expect.Dot+IProcess
|
||||
WoofWare.Expect.Dot.render [static property]: [read-only] string -> string
|
||||
WoofWare.Expect.Dot.render' [static method]: #(IDisposable) WoofWare.Expect.Dot+IProcess -> WoofWare.Expect.Dot+IFileSystem -> string -> string -> string
|
||||
WoofWare.Expect.ExpectBuilder inherit obj
|
||||
WoofWare.Expect.ExpectBuilder..ctor [constructor]: (string * int)
|
||||
WoofWare.Expect.ExpectBuilder..ctor [constructor]: bool
|
||||
@@ -16,9 +78,11 @@ WoofWare.Expect.ExpectBuilder.Bind [method]: ('U WoofWare.Expect.ExpectState, un
|
||||
WoofWare.Expect.ExpectBuilder.Delay [method]: (unit -> 'T WoofWare.Expect.ExpectState) -> (unit -> 'T WoofWare.Expect.ExpectState)
|
||||
WoofWare.Expect.ExpectBuilder.Return [method]: 'T -> 'T WoofWare.Expect.ExpectState
|
||||
WoofWare.Expect.ExpectBuilder.Return [method]: unit -> 'T WoofWare.Expect.ExpectState
|
||||
WoofWare.Expect.ExpectBuilder.ReturnFrom [method]: (unit -> 'T) -> 'T WoofWare.Expect.ExpectState
|
||||
WoofWare.Expect.ExpectBuilder.Run [method]: (unit -> 'T WoofWare.Expect.ExpectState) -> unit
|
||||
WoofWare.Expect.ExpectBuilder.Snapshot [method]: ('a WoofWare.Expect.ExpectState, string, string option, int option, string option) -> 'a WoofWare.Expect.ExpectState
|
||||
WoofWare.Expect.ExpectBuilder.SnapshotJson [method]: (unit WoofWare.Expect.ExpectState, string, string option, int option, string option) -> 'a WoofWare.Expect.ExpectState
|
||||
WoofWare.Expect.ExpectBuilder.SnapshotThrows [method]: ('a WoofWare.Expect.ExpectState, string, string option, int option, string option) -> 'a WoofWare.Expect.ExpectState
|
||||
WoofWare.Expect.ExpectBuilder.WithFormat [method]: ('T WoofWare.Expect.ExpectState, 'T -> string) -> 'T WoofWare.Expect.ExpectState
|
||||
WoofWare.Expect.ExpectBuilder.WithJsonDocOptions [method]: ('T WoofWare.Expect.ExpectState, System.Text.Json.JsonDocumentOptions) -> 'T WoofWare.Expect.ExpectState
|
||||
WoofWare.Expect.ExpectBuilder.WithJsonSerializerOptions [method]: ('T WoofWare.Expect.ExpectState, System.Text.Json.JsonSerializerOptions) -> 'T WoofWare.Expect.ExpectState
|
||||
@@ -34,4 +98,5 @@ WoofWare.Expect.GlobalBuilderConfig.clearTests [static method]: unit -> unit
|
||||
WoofWare.Expect.GlobalBuilderConfig.enterBulkUpdateMode [static method]: unit -> unit
|
||||
WoofWare.Expect.GlobalBuilderConfig.updateAllSnapshots [static method]: unit -> unit
|
||||
WoofWare.Expect.Mode inherit obj, implements WoofWare.Expect.Mode System.IEquatable, System.Collections.IStructuralEquatable, WoofWare.Expect.Mode System.IComparable, System.IComparable, System.Collections.IStructuralComparable
|
||||
WoofWare.Expect.Mode.Equals [method]: (WoofWare.Expect.Mode, System.Collections.IEqualityComparer) -> bool
|
||||
WoofWare.Expect.Mode.Equals [method]: (WoofWare.Expect.Mode, System.Collections.IEqualityComparer) -> bool
|
||||
WoofWare.Expect.pos inherit obj
|
@@ -18,6 +18,9 @@
|
||||
<ItemGroup>
|
||||
<Compile Include="AssemblyInfo.fs" />
|
||||
<Compile Include="Text.fs" />
|
||||
<Compile Include="File.fs" />
|
||||
<Compile Include="Diff.fs" />
|
||||
<Compile Include="Dot.fs" />
|
||||
<Compile Include="Domain.fs" />
|
||||
<Compile Include="SnapshotUpdate.fs" />
|
||||
<Compile Include="Config.fs" />
|
||||
|
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "0.4",
|
||||
"version": "0.7",
|
||||
"publicReleaseRefSpec": [
|
||||
"^refs/heads/main$"
|
||||
],
|
||||
|
@@ -65,6 +65,8 @@
|
||||
pkgs.alejandra
|
||||
pkgs.nodePackages.markdown-link-check
|
||||
pkgs.shellcheck
|
||||
pkgs.xmlstarlet
|
||||
pkgs.graph-easy
|
||||
];
|
||||
};
|
||||
});
|
||||
|
@@ -189,6 +189,11 @@
|
||||
"version": "4.2.13",
|
||||
"hash": "sha256-nkC/PiqE6+c1HJ2yTwg3x+qdBh844Z8n3ERWDW8k6Gg="
|
||||
},
|
||||
{
|
||||
"pname": "System.IO.Abstractions.TestingHelpers",
|
||||
"version": "4.2.13",
|
||||
"hash": "sha256-WGGatXlgyROnptdw0zU3ggf54eD/zusO/fvtf+5wuPU="
|
||||
},
|
||||
{
|
||||
"pname": "System.IO.FileSystem.AccessControl",
|
||||
"version": "4.5.0",
|
||||
|
Reference in New Issue
Block a user