14 Commits

Author SHA1 Message Date
Patrick Stevens
ebc24f85aa Add snapshotList syntax for sequences (#25) 2025-07-30 08:11:36 +00:00
Patrick Stevens
faacb4770c Simple strings where possible (#27) 2025-07-30 08:06:15 +00:00
Patrick Stevens
d21786ecd4 Make diff generic (#26) 2025-07-30 09:03:02 +01:00
Patrick Stevens
ad05a9c106 Fix typo resulting in failed renders (#24) 2025-07-25 11:35:14 +00:00
Patrick Stevens
ed352c1b14 Add Dot rendering (#23) 2025-07-25 11:07:40 +00:00
Patrick Stevens
e0153ab182 Patience diff (#17) 2025-07-24 08:14:53 +00:00
Patrick Stevens
ca74c4816b Add syntax for exceptions (#19) 2025-07-06 22:30:02 +01:00
Patrick Stevens
75899d5668 Delete unused variable (#14) 2025-06-22 23:29:53 +01:00
Patrick Stevens
34a2b460b9 Guard more bulk-updates behind lock (#13) 2025-06-22 23:27:11 +01:00
Patrick Stevens
0b64d3dd34 Atomic file writes (#12) 2025-06-22 23:04:13 +01:00
Patrick Stevens
457d7b16de Add a whole lot more tests for the parser (#11) 2025-06-16 22:44:42 +01:00
Patrick Stevens
d115185525 Use MS.Testing.Platform to run tests (#10) 2025-06-16 21:10:16 +01:00
Patrick Stevens
c02acabb8c Allow overriding snapshot formatting (#9) 2025-06-16 19:35:12 +01:00
Patrick Stevens
9c1960722a Multi snapshots (#8) 2025-06-16 16:45:35 +00:00
34 changed files with 4836 additions and 146 deletions

22
.envrc
View File

@@ -1 +1,23 @@
use flake 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

130
README.md
View File

@@ -16,8 +16,7 @@ An [expect-testing](https://blog.janestreet.com/the-joy-of-expect-tests/) librar
# Current status # Current status
The basic mechanism works. 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 fairly well tested, but you will almost certainly be able to find ways to break it; try not to be too fancy with your syntax around the `snapshot` statement.
It's not very well tested, and I expect it to be kind of brittle.
# How to use # How to use
@@ -44,8 +43,85 @@ let ``This test fails: plain text comparison of ToString`` () =
snapshot " 123 " snapshot " 123 "
return 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")
}
[<Test>]
let ``You can do lists more neatly with the snapshotList keyword`` () =
expect {
snapshotList [ "8" ; "9" ; "10" ; "11" ; "12" ]
return [ 8..12 ]
}
``` ```
(JSON output for elements is not yet supported with `snapshotList`.)
You can adjust the formatting:
```fsharp
[<Test>]
let ``Overriding the formatting`` () =
expect {
// doesn't matter which order these two lines are in
withFormat (fun x -> x.GetType().Name)
snapshot @"Int32"
return 123
}
[<Test>]
let ``Overriding the formatting with lists`` () =
expect {
// these two lines *do* have to be in this order, for annoying reasons
snapshotList [ "8" ; "9" ; "0" ; "1" ; "2" ]
withFormat (fun x -> string<int> (x % 10))
return [ 8..12 ]
}
```
You can override the JSON serialisation if you find the snapshot format displeasing:
```fsharp
[<Test>]
let ``Override JSON serialisation`` () =
expect {
snapshotJson "<excerpted>"
withJsonSerializerOptions (
let options = JsonFSharpOptions.ThothLike().ToJsonSerializerOptions ()
options.WriteIndented <- true
options
)
return myComplexAlgebraicDataType
}
```
You can adjust the JSON snapshot parsing if you like, e.g. if you want to add comments to your snapshot text:
```fsharp
[<Test>]
let ``Overriding JSON parse`` () =
expect {
// Without a custom JsonDocumentOptions, WoofWare.Expect would fail to parse this as JSON
// and would unconditionally declare that the snapshot did not match:
snapshotJson @"{
// a key here
""a"":3
}"
// But you can override the JsonDocumentOptions to state that comments are fine:
withJsonDocOptions (JsonDocumentOptions (CommentHandling = JsonCommentHandling.Skip))
return Map.ofList [ "a", 3 ]
}
```
## Updating an individual snapshot
If a snapshot is failing, add a `'` to the `expect` builder and rerun. If a snapshot is failing, add a `'` to the `expect` builder and rerun.
The rerun will throw, but it will update the snapshot; then remove the `'` again to put the test back into "assert snapshot" mode. The rerun will throw, but it will update the snapshot; then remove the `'` again to put the test back into "assert snapshot" mode.
@@ -78,11 +154,61 @@ let ``Example of automatically updating`` () =
} }
``` ```
## Bulk update of snapshots
*Warning*: when doing this, you should probably make sure your test fixture is `[<Parallelizable(ParallelScope.Children)>]` or less parallelizable,
or the equivalent in your test runner of choice.
Otherwise, the global state used by this mechanism may interfere with other fixtures.
You can put WoofWare.Expect into "bulk update" mode as follows:
```fsharp
open NUnit.Framework
open WoofWare.Expect
[<TestFixture>]
[<NonParallelizable>]
module BulkUpdateExample =
[<OneTimeSetUp>]
let ``Prepare to bulk-update tests`` () =
// If you don't want to enter bulk-update mode, just replace this line with a no-op `()`.
// The `updateAllSnapshots` tear-down below will simply do nothing in that case.
GlobalBuilderConfig.enterBulkUpdateMode ()
[<OneTimeTearDown>]
let ``Update all tests`` () =
GlobalBuilderConfig.updateAllSnapshots ()
[<Test>]
let ``Snapshot 2`` () =
// this snapshot fails: the "expected" isn't even JSON!
expect {
snapshotJson ""
return Map.ofList [ "1", "hi" ; "2", "my" ; "3", "name" ; "4", "is" ]
}
[<Test>]
let ``Snapshot 1`` () =
// this snapshot fails: the "expected" is not equal to the "actual"
expect {
snapshotJson @"124"
return 123
}
```
Observe the `OneTimeSetUp` which sets global state to enter "bulk update" mode, and the `OneTimeTearDown` which performs all the updates to rectify failures which were accumulated during this test run.
# Limitations # Limitations
* 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"`). * 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 # Licence
MIT. MIT.

View File

@@ -0,0 +1,48 @@
namespace WoofWare.Expect.Test
open WoofWare.Expect
open NUnit.Framework
[<TestFixture>]
[<Parallelizable(ParallelScope.Children)>]
module BulkUpdateExample =
[<OneTimeSetUp>]
let ``Prepare to bulk-update tests`` () =
// Uncomment the `enterBulkUpdateMode` to cause all failing tests to accumulate their results
// into a global mutable collection.
// At the end of the test run, you should then call `updateAllSnapshots ()`
// to commit these accumulated failures to the source files.
//
// When in bulk update mode, all tests will fail, to remind you to exit bulk update mode afterwards.
//
// We *strongly* recommend making these test fixtures `[<Parallelizable(ParallelScope.Children)>]`
// or less parallelisable.
// GlobalBuilderConfig.enterBulkUpdateMode ()
()
[<OneTimeTearDown>]
let ``Update all tests`` () =
GlobalBuilderConfig.updateAllSnapshots ()
[<Test>]
let ``Snapshot 2`` () =
expect {
snapshotJson
@"{
""1"": ""hi"",
""2"": ""my"",
""3"": ""name"",
""4"": ""is""
}"
return Map.ofList [ "1", "hi" ; "2", "my" ; "3", "name" ; "4", "is" ]
}
[<Test>]
let ``Snapshot 1`` () =
expect {
snapshotJson @"123"
return 123
}

View File

@@ -1,5 +1,8 @@
namespace WoofWare.Expect.Test namespace WoofWare.Expect.Test
open System.Collections.Generic
open System.Text.Json
open System.Text.Json.Serialization
open WoofWare.Expect open WoofWare.Expect
open NUnit.Framework open NUnit.Framework
@@ -16,12 +19,9 @@ module SimpleTest =
let ``Example of a failing test`` () = let ``Example of a failing test`` () =
expect { expect {
snapshot 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 - 123
actual was:
+ 124" + 124"
return return
@@ -41,3 +41,128 @@ actual was:
snapshot @"123" snapshot @"123"
return 123 return 123
} }
[<Test>]
let ``Formatting example`` () =
expect {
withFormat (fun x -> x.GetType().Name)
snapshot @"Int32"
return 123
}
expect {
snapshot @"Int32"
withFormat (fun x -> x.GetType().Name)
return 123
}
[<Test>]
let ``Custom JSON output`` () =
// 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) diff:
- [JSON failed to parse:] {
- // a key here
+ {
""a"": 3
}"
return
Assert.Throws<ExpectException> (fun () ->
expectWithMockedFilePath ("file.fs", 99) {
snapshotJson
@"{
// a key here
""a"": 3
}"
return Map.ofList [ "a", 3 ]
}
)
|> _.Message
}
// but it can be made to like them!
expect {
snapshotJson
@"{
// a key here
""a"":3
}"
withJsonDocOptions (JsonDocumentOptions (CommentHandling = JsonCommentHandling.Skip))
return Map.ofList [ "a", 3 ]
}
type SomeDu =
| Something of IReadOnlyDictionary<string, string>
| SomethingElse of string
type MoreComplexType =
{
Thing : int
SomeDu : SomeDu option
}
[<Test>]
let ``JSON snapshot of complex ADT`` () =
expect {
snapshotJson
@"{
""SomeDu"": {
""Case"": ""Something"",
""Fields"": [
{
""hi"": ""bye""
}
]
},
""Thing"": 3,
}"
return
{
Thing = 3
SomeDu = Some (SomeDu.Something (Map.ofList [ "hi", "bye" ]))
}
}
[<Test>]
let ``Overriding JSON format, from docstring`` () =
expect {
snapshotJson @"{""a"":3}"
withJsonSerializerOptions (JsonSerializerOptions (WriteIndented = false))
return Map.ofList [ "a", 3 ]
}
[<Test>]
let ``Overriding the JSON format`` () =
expect {
snapshotJson
@"{
""Thing"": 3,
""SomeDu"": [
""Some"",
[
""Something"",
{
""hi"": ""bye""
}
]
]
}"
withJsonSerializerOptions (
let options = JsonFSharpOptions.ThothLike().ToJsonSerializerOptions ()
options.WriteIndented <- true
options
)
return
{
Thing = 3
SomeDu = Some (SomeDu.Something (Map.ofList [ "hi", "bye" ]))
}
}

View File

@@ -0,0 +1,44 @@
namespace BigExample
open WoofWare.Expect
module MyModule =
let multipleComments () =
expect {
snapshot (* first comment *) (* second comment *)
(* third comment on new line *)
@"test with many comments"
return 123
}
let nestedComments () =
expect {
snapshot (* outer (* inner *) comment *) """nested comment test"""
return "nested"
}
let commentWithSpecialChars () =
expect {
snapshot (* comment with "quotes" and \ backslash *) "regular string"
return "special"
}
let lotsOfWhitespace () =
expect {
snapshot
"string after whitespace"
return "whitespace"
}
let mixedWhitespaceAndComments () =
expect {
snapshotJson (* comment 1 *)
(* comment 2 *)
(* comment 3 *) @"123"
return 123
}

View File

@@ -0,0 +1,69 @@
namespace BigExample
open WoofWare.Expect
module MyModule =
let emptyString () =
expect {
snapshot ""
return ""
}
let emptyVerbatim () =
expect {
snapshot @""
return ""
}
let emptyTripleQuote () =
expect {
snapshot """"""
return ""
}
let onlyWhitespace () =
expect {
snapshot " \t\n "
return "whitespace"
}
let quotesInQuotes () =
expect {
snapshot @"He said ""Hello"" and she said ""Hi"""
return "quotes"
}
let backslashesGalore () =
expect {
snapshot @"C:\Users\Test\Documents\file.txt"
return "path"
}
let veryLongLine () =
expect {
snapshot
@"This is a very long line that goes on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and contains over 300 characters to test how the parser handles very long single-line strings"
return "long line"
}
let leadingNewlines () =
expect {
snapshot
"""
Starts with newlines"""
return "leading"
}
let trailingNewlines () =
expect {
snapshot
"""Ends with newlines
"""
return "trailing"
}

View File

@@ -0,0 +1,84 @@
namespace BigExample
open WoofWare.Expect
module MyModule =
let veryLongMultiline () =
expect {
snapshot
"""Line 1
Line 2
Line 3
Line 4
Line 5
Line 6
Line 7
Line 8
Line 9
Line 10
Indented line 11
More indented line 12
Line 13
Line 14
Line 15"""
return "long"
}
let multilineWithEmptyLines () =
expect {
snapshot
@"First line
Third line
Sixth line"
return "empty lines"
}
let multilineWithSpecialChars () =
expect {
snapshot
"""Special chars:
Tab: here
Quotes: "double" and 'single'
Backslash: \ and \\
Unicode: 🎯
Regex: .*+?[]"""
return "special"
}
let multilineJson () =
expect {
snapshotJson
@"{
""name"": ""test"",
""values"": [
1,
2,
3
],
""nested"": {
""deep"": true
}
}"
return
{
name = "test"
values = [ 1 ; 2 ; 3 ]
nested =
{|
deep = true
|}
}
}
let windowsLineEndings () =
expect {
snapshot "Line 1\r\nLine 2\r\nLine 3"
return "crlf"
}

View File

@@ -0,0 +1,28 @@
namespace BigExample
open WoofWare.Expect
module MyModule =
let regexChars () =
expect {
snapshot @"test with regex chars: .*+?[]{}()|^$\ and more"
return 123
}
let regexInTripleQuote () =
expect {
snapshot """regex: .*+?[]{}()|^$\ in triple quotes"""
return 456
}
let regexInRegularString () =
expect {
snapshot "escaped regex: \\.\\*\\+\\?\\[\\]\\{\\}\\(\\)\\|\\^\\$\\\\"
return 789
}
let complexRegexPattern () =
expect {
snapshotJson @"^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$"
return "IP regex"
}

View File

@@ -0,0 +1,47 @@
namespace BigExample
open WoofWare.Expect
module MyModule =
let emoji () =
expect {
snapshot @"Hello 👋 World 🌍 with emoji 🎉🎊"
return 123
}
let chineseCharacters () =
expect {
snapshot """Chinese: 你好世界"""
return "hello"
}
let arabicRTL () =
expect {
snapshot @"Arabic RTL: مرحبا بالعالم"
return "rtl test"
}
let combiningCharacters () =
expect {
// Combining diacritics: e + ́ = é
snapshot "test with combining: e\u0301 and a\u0308"
return "combining"
}
let mixedScripts () =
expect {
snapshotJson @"Mixed: English, русский, 日本語, العربية, emoji 🚀"
return [ "multilingual" ]
}
let zeroWidthChars () =
expect {
snapshot @"Zerowidthspacetest" // Contains U+200B
return "zwsp"
}
let mathSymbols () =
expect {
snapshot """Math: ∀x∈, ∃y: + = 1 |x| 1"""
return "math"
}

View 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
}

View 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

View 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")
}

View File

@@ -0,0 +1,313 @@
namespace WoofWare.Expect.Test
open WoofWare.Expect
open NUnit.Framework
[<TestFixture>]
[<Parallelizable(ParallelScope.Children)>]
module TestCommentsAndSpacing =
[<OneTimeSetUp>]
let ``Prepare to bulk-update tests`` () =
// GlobalBuilderConfig.enterBulkUpdateMode ()
()
[<OneTimeTearDown>]
let ``Update all tests`` () =
GlobalBuilderConfig.updateAllSnapshots ()
type Dummy = class end
[<Test>]
let ``Multiple comments between snapshot and string`` () =
let source =
Assembly.getEmbeddedResource typeof<Dummy>.Assembly "CommentsAndSpacing.fs"
|> _.Split('\n')
expect {
snapshot
@"namespace BigExample
open WoofWare.Expect
module MyModule =
let multipleComments () =
expect {
snapshot (* first comment *) (* second comment *)
(* third comment on new line *)
""updated after many comments""
return 123
}
let nestedComments () =
expect {
snapshot (* outer (* inner *) comment *) """"""nested comment test""""""
return ""nested""
}
let commentWithSpecialChars () =
expect {
snapshot (* comment with ""quotes"" and \ backslash *) ""regular string""
return ""special""
}
let lotsOfWhitespace () =
expect {
snapshot
""string after whitespace""
return ""whitespace""
}
let mixedWhitespaceAndComments () =
expect {
snapshotJson (* comment 1 *)
(* comment 2 *)
(* comment 3 *) @""123""
return 123
}
"
return
SnapshotUpdate.updateSnapshotAtLine source 8 "updated after many comments"
|> String.concat "\n"
}
[<Test>]
let ``Nested comments`` () =
let source =
Assembly.getEmbeddedResource typeof<Dummy>.Assembly "CommentsAndSpacing.fs"
|> _.Split('\n')
expect {
snapshot
@"namespace BigExample
open WoofWare.Expect
module MyModule =
let multipleComments () =
expect {
snapshot (* first comment *) (* second comment *)
(* third comment on new line *)
@""test with many comments""
return 123
}
let nestedComments () =
expect {
snapshot (* outer (* inner *) comment *) ""updated after nested comments""
return ""nested""
}
let commentWithSpecialChars () =
expect {
snapshot (* comment with ""quotes"" and \ backslash *) ""regular string""
return ""special""
}
let lotsOfWhitespace () =
expect {
snapshot
""string after whitespace""
return ""whitespace""
}
let mixedWhitespaceAndComments () =
expect {
snapshotJson (* comment 1 *)
(* comment 2 *)
(* comment 3 *) @""123""
return 123
}
"
return
SnapshotUpdate.updateSnapshotAtLine source 17 "updated after nested comments"
|> String.concat "\n"
}
[<Test>]
let ``Comment with special chars`` () =
let source =
Assembly.getEmbeddedResource typeof<Dummy>.Assembly "CommentsAndSpacing.fs"
|> _.Split('\n')
expect {
snapshot
@"namespace BigExample
open WoofWare.Expect
module MyModule =
let multipleComments () =
expect {
snapshot (* first comment *) (* second comment *)
(* third comment on new line *)
@""test with many comments""
return 123
}
let nestedComments () =
expect {
snapshot (* outer (* inner *) comment *) """"""nested comment test""""""
return ""nested""
}
let commentWithSpecialChars () =
expect {
snapshot (* comment with ""quotes"" and \ backslash *) ""updated after weird comment""
return ""special""
}
let lotsOfWhitespace () =
expect {
snapshot
""string after whitespace""
return ""whitespace""
}
let mixedWhitespaceAndComments () =
expect {
snapshotJson (* comment 1 *)
(* comment 2 *)
(* comment 3 *) @""123""
return 123
}
"
return
SnapshotUpdate.updateSnapshotAtLine source 23 "updated after weird comment"
|> String.concat "\n"
}
[<Test>]
let ``Whitespace before`` () =
let source =
Assembly.getEmbeddedResource typeof<Dummy>.Assembly "CommentsAndSpacing.fs"
|> _.Split('\n')
expect {
snapshot
@"namespace BigExample
open WoofWare.Expect
module MyModule =
let multipleComments () =
expect {
snapshot (* first comment *) (* second comment *)
(* third comment on new line *)
@""test with many comments""
return 123
}
let nestedComments () =
expect {
snapshot (* outer (* inner *) comment *) """"""nested comment test""""""
return ""nested""
}
let commentWithSpecialChars () =
expect {
snapshot (* comment with ""quotes"" and \ backslash *) ""regular string""
return ""special""
}
let lotsOfWhitespace () =
expect {
snapshot
""updated after spaces""
return ""whitespace""
}
let mixedWhitespaceAndComments () =
expect {
snapshotJson (* comment 1 *)
(* comment 2 *)
(* comment 3 *) @""123""
return 123
}
"
return
SnapshotUpdate.updateSnapshotAtLine source 29 "updated after spaces"
|> String.concat "\n"
}
[<Test>]
let ``Mixed whitespace and comments`` () =
let source =
Assembly.getEmbeddedResource typeof<Dummy>.Assembly "CommentsAndSpacing.fs"
|> _.Split('\n')
expect {
snapshot
@"namespace BigExample
open WoofWare.Expect
module MyModule =
let multipleComments () =
expect {
snapshot (* first comment *) (* second comment *)
(* third comment on new line *)
@""test with many comments""
return 123
}
let nestedComments () =
expect {
snapshot (* outer (* inner *) comment *) """"""nested comment test""""""
return ""nested""
}
let commentWithSpecialChars () =
expect {
snapshot (* comment with ""quotes"" and \ backslash *) ""regular string""
return ""special""
}
let lotsOfWhitespace () =
expect {
snapshot
""string after whitespace""
return ""whitespace""
}
let mixedWhitespaceAndComments () =
expect {
snapshotJson (* comment 1 *)
(* comment 2 *)
(* comment 3 *) ""updated after comments""
return 123
}
"
return
SnapshotUpdate.updateSnapshotAtLine source 39 "updated after comments"
|> String.concat "\n"
}

View File

@@ -0,0 +1,781 @@
namespace WoofWare.Expect.Test
open NUnit.Framework
open WoofWare.Expect
[<TestFixture>]
module TestEdgeCases =
[<OneTimeSetUp>]
let ``Prepare to bulk-update tests`` () =
// GlobalBuilderConfig.enterBulkUpdateMode ()
()
[<OneTimeTearDown>]
let ``Update all tests`` () =
GlobalBuilderConfig.updateAllSnapshots ()
type Dummy = class end
[<Test>]
let ``Empty string replacements`` () =
let source =
Assembly.getEmbeddedResource typeof<Dummy>.Assembly "EdgeCases.fs"
|> _.Split('\n')
expect {
snapshot
@"namespace BigExample
open WoofWare.Expect
module MyModule =
let emptyString () =
expect {
snapshot ""now has content""
return """"
}
let emptyVerbatim () =
expect {
snapshot @""""
return """"
}
let emptyTripleQuote () =
expect {
snapshot """"""""""""
return """"
}
let onlyWhitespace () =
expect {
snapshot "" \t\n ""
return ""whitespace""
}
let quotesInQuotes () =
expect {
snapshot @""He said """"Hello"""" and she said """"Hi""""""
return ""quotes""
}
let backslashesGalore () =
expect {
snapshot @""C:\Users\Test\Documents\file.txt""
return ""path""
}
let veryLongLine () =
expect {
snapshot
@""This is a very long line that goes on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and contains over 300 characters to test how the parser handles very long single-line strings""
return ""long line""
}
let leadingNewlines () =
expect {
snapshot
""""""
Starts with newlines""""""
return ""leading""
}
let trailingNewlines () =
expect {
snapshot
""""""Ends with newlines
""""""
return ""trailing""
}
"
return
SnapshotUpdate.updateSnapshotAtLine source 8 "now has content"
|> String.concat "\n"
}
[<Test>]
let ``Empty string replacements, verbatim`` () =
let source =
Assembly.getEmbeddedResource typeof<Dummy>.Assembly "EdgeCases.fs"
|> _.Split('\n')
expect {
snapshot
@"namespace BigExample
open WoofWare.Expect
module MyModule =
let emptyString () =
expect {
snapshot """"
return """"
}
let emptyVerbatim () =
expect {
snapshot ""now has content""
return """"
}
let emptyTripleQuote () =
expect {
snapshot """"""""""""
return """"
}
let onlyWhitespace () =
expect {
snapshot "" \t\n ""
return ""whitespace""
}
let quotesInQuotes () =
expect {
snapshot @""He said """"Hello"""" and she said """"Hi""""""
return ""quotes""
}
let backslashesGalore () =
expect {
snapshot @""C:\Users\Test\Documents\file.txt""
return ""path""
}
let veryLongLine () =
expect {
snapshot
@""This is a very long line that goes on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and contains over 300 characters to test how the parser handles very long single-line strings""
return ""long line""
}
let leadingNewlines () =
expect {
snapshot
""""""
Starts with newlines""""""
return ""leading""
}
let trailingNewlines () =
expect {
snapshot
""""""Ends with newlines
""""""
return ""trailing""
}
"
return
SnapshotUpdate.updateSnapshotAtLine source 14 "now has content"
|> String.concat "\n"
}
[<Test>]
let ``Empty string replacements, triple quotes`` () =
let source =
Assembly.getEmbeddedResource typeof<Dummy>.Assembly "EdgeCases.fs"
|> _.Split('\n')
expect {
snapshot
@"namespace BigExample
open WoofWare.Expect
module MyModule =
let emptyString () =
expect {
snapshot """"
return """"
}
let emptyVerbatim () =
expect {
snapshot @""""
return """"
}
let emptyTripleQuote () =
expect {
snapshot ""now has content""
return """"
}
let onlyWhitespace () =
expect {
snapshot "" \t\n ""
return ""whitespace""
}
let quotesInQuotes () =
expect {
snapshot @""He said """"Hello"""" and she said """"Hi""""""
return ""quotes""
}
let backslashesGalore () =
expect {
snapshot @""C:\Users\Test\Documents\file.txt""
return ""path""
}
let veryLongLine () =
expect {
snapshot
@""This is a very long line that goes on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and contains over 300 characters to test how the parser handles very long single-line strings""
return ""long line""
}
let leadingNewlines () =
expect {
snapshot
""""""
Starts with newlines""""""
return ""leading""
}
let trailingNewlines () =
expect {
snapshot
""""""Ends with newlines
""""""
return ""trailing""
}
"
return
SnapshotUpdate.updateSnapshotAtLine source 20 "now has content"
|> String.concat "\n"
}
[<Test>]
let ``Empty string replacements, only whitespace`` () =
let source =
Assembly.getEmbeddedResource typeof<Dummy>.Assembly "EdgeCases.fs"
|> _.Split('\n')
expect {
snapshot
@"namespace BigExample
open WoofWare.Expect
module MyModule =
let emptyString () =
expect {
snapshot """"
return """"
}
let emptyVerbatim () =
expect {
snapshot @""""
return """"
}
let emptyTripleQuote () =
expect {
snapshot """"""""""""
return """"
}
let onlyWhitespace () =
expect {
snapshot ""now has content""
return ""whitespace""
}
let quotesInQuotes () =
expect {
snapshot @""He said """"Hello"""" and she said """"Hi""""""
return ""quotes""
}
let backslashesGalore () =
expect {
snapshot @""C:\Users\Test\Documents\file.txt""
return ""path""
}
let veryLongLine () =
expect {
snapshot
@""This is a very long line that goes on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and contains over 300 characters to test how the parser handles very long single-line strings""
return ""long line""
}
let leadingNewlines () =
expect {
snapshot
""""""
Starts with newlines""""""
return ""leading""
}
let trailingNewlines () =
expect {
snapshot
""""""Ends with newlines
""""""
return ""trailing""
}
"
return
SnapshotUpdate.updateSnapshotAtLine source 26 "now has content"
|> String.concat "\n"
}
[<Test>]
let ``Quotes in quotes handling`` () =
let source =
Assembly.getEmbeddedResource typeof<Dummy>.Assembly "EdgeCases.fs"
|> _.Split('\n')
expect {
snapshot
@"namespace BigExample
open WoofWare.Expect
module MyModule =
let emptyString () =
expect {
snapshot """"
return """"
}
let emptyVerbatim () =
expect {
snapshot @""""
return """"
}
let emptyTripleQuote () =
expect {
snapshot """"""""""""
return """"
}
let onlyWhitespace () =
expect {
snapshot "" \t\n ""
return ""whitespace""
}
let quotesInQuotes () =
expect {
snapshot @""Updated: He said """"What's up?"""" and replied """"Nothing much.""""""
return ""quotes""
}
let backslashesGalore () =
expect {
snapshot @""C:\Users\Test\Documents\file.txt""
return ""path""
}
let veryLongLine () =
expect {
snapshot
@""This is a very long line that goes on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and contains over 300 characters to test how the parser handles very long single-line strings""
return ""long line""
}
let leadingNewlines () =
expect {
snapshot
""""""
Starts with newlines""""""
return ""leading""
}
let trailingNewlines () =
expect {
snapshot
""""""Ends with newlines
""""""
return ""trailing""
}
"
return
SnapshotUpdate.updateSnapshotAtLine
source
32
"Updated: He said \"What's up?\" and replied \"Nothing much.\""
|> String.concat "\n"
}
[<Test>]
let ``Backslashes galore`` () =
let source =
Assembly.getEmbeddedResource typeof<Dummy>.Assembly "EdgeCases.fs"
|> _.Split('\n')
expect {
snapshot
@"namespace BigExample
open WoofWare.Expect
module MyModule =
let emptyString () =
expect {
snapshot """"
return """"
}
let emptyVerbatim () =
expect {
snapshot @""""
return """"
}
let emptyTripleQuote () =
expect {
snapshot """"""""""""
return """"
}
let onlyWhitespace () =
expect {
snapshot "" \t\n ""
return ""whitespace""
}
let quotesInQuotes () =
expect {
snapshot @""He said """"Hello"""" and she said """"Hi""""""
return ""quotes""
}
let backslashesGalore () =
expect {
snapshot @""prefer\these\ones""
return ""path""
}
let veryLongLine () =
expect {
snapshot
@""This is a very long line that goes on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and contains over 300 characters to test how the parser handles very long single-line strings""
return ""long line""
}
let leadingNewlines () =
expect {
snapshot
""""""
Starts with newlines""""""
return ""leading""
}
let trailingNewlines () =
expect {
snapshot
""""""Ends with newlines
""""""
return ""trailing""
}
"
return
SnapshotUpdate.updateSnapshotAtLine source 38 "prefer\\these\\ones"
|> String.concat "\n"
}
[<Test>]
let ``Very long line`` () =
let source =
Assembly.getEmbeddedResource typeof<Dummy>.Assembly "EdgeCases.fs"
|> _.Split('\n')
expect {
snapshot
@"namespace BigExample
open WoofWare.Expect
module MyModule =
let emptyString () =
expect {
snapshot """"
return """"
}
let emptyVerbatim () =
expect {
snapshot @""""
return """"
}
let emptyTripleQuote () =
expect {
snapshot """"""""""""
return """"
}
let onlyWhitespace () =
expect {
snapshot "" \t\n ""
return ""whitespace""
}
let quotesInQuotes () =
expect {
snapshot @""He said """"Hello"""" and she said """"Hi""""""
return ""quotes""
}
let backslashesGalore () =
expect {
snapshot @""C:\Users\Test\Documents\file.txt""
return ""path""
}
let veryLongLine () =
expect {
snapshot
""this line is short though""
return ""long line""
}
let leadingNewlines () =
expect {
snapshot
""""""
Starts with newlines""""""
return ""leading""
}
let trailingNewlines () =
expect {
snapshot
""""""Ends with newlines
""""""
return ""trailing""
}
"
return
SnapshotUpdate.updateSnapshotAtLine source 44 "this line is short though"
|> String.concat "\n"
}
[<Test>]
let ``Leading newlines`` () =
let source =
Assembly.getEmbeddedResource typeof<Dummy>.Assembly "EdgeCases.fs"
|> _.Split('\n')
expect {
snapshot
@"namespace BigExample
open WoofWare.Expect
module MyModule =
let emptyString () =
expect {
snapshot """"
return """"
}
let emptyVerbatim () =
expect {
snapshot @""""
return """"
}
let emptyTripleQuote () =
expect {
snapshot """"""""""""
return """"
}
let onlyWhitespace () =
expect {
snapshot "" \t\n ""
return ""whitespace""
}
let quotesInQuotes () =
expect {
snapshot @""He said """"Hello"""" and she said """"Hi""""""
return ""quotes""
}
let backslashesGalore () =
expect {
snapshot @""C:\Users\Test\Documents\file.txt""
return ""path""
}
let veryLongLine () =
expect {
snapshot
@""This is a very long line that goes on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and contains over 300 characters to test how the parser handles very long single-line strings""
return ""long line""
}
let leadingNewlines () =
expect {
snapshot
@""
Just newlines!
""
return ""leading""
}
let trailingNewlines () =
expect {
snapshot
""""""Ends with newlines
""""""
return ""trailing""
}
"
return
SnapshotUpdate.updateSnapshotAtLine source 52 "\n\nJust newlines!\n\n\n"
|> String.concat "\n"
}
[<Test>]
let ``Trailing newlines`` () =
let source =
Assembly.getEmbeddedResource typeof<Dummy>.Assembly "EdgeCases.fs"
|> _.Split('\n')
expect {
snapshot
@"namespace BigExample
open WoofWare.Expect
module MyModule =
let emptyString () =
expect {
snapshot """"
return """"
}
let emptyVerbatim () =
expect {
snapshot @""""
return """"
}
let emptyTripleQuote () =
expect {
snapshot """"""""""""
return """"
}
let onlyWhitespace () =
expect {
snapshot "" \t\n ""
return ""whitespace""
}
let quotesInQuotes () =
expect {
snapshot @""He said """"Hello"""" and she said """"Hi""""""
return ""quotes""
}
let backslashesGalore () =
expect {
snapshot @""C:\Users\Test\Documents\file.txt""
return ""path""
}
let veryLongLine () =
expect {
snapshot
@""This is a very long line that goes on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and contains over 300 characters to test how the parser handles very long single-line strings""
return ""long line""
}
let leadingNewlines () =
expect {
snapshot
""""""
Starts with newlines""""""
return ""leading""
}
let trailingNewlines () =
expect {
snapshot
@""
Just newlines!
""
return ""trailing""
}
"
return
SnapshotUpdate.updateSnapshotAtLine source 62 "\n\nJust newlines!\n\n\n"
|> String.concat "\n"
}

View File

@@ -0,0 +1,483 @@
namespace WoofWare.Expect.Test
open NUnit.Framework
open WoofWare.Expect
[<TestFixture>]
module TestMultilineComplex =
[<OneTimeSetUp>]
let ``Prepare to bulk-update tests`` () =
// GlobalBuilderConfig.enterBulkUpdateMode ()
()
[<OneTimeTearDown>]
let ``Update all tests`` () =
GlobalBuilderConfig.updateAllSnapshots ()
type Dummy = class end
[<Test>]
let ``Very long multiline string`` () =
let source =
Assembly.getEmbeddedResource typeof<Dummy>.Assembly "MultilineComplex.fs"
|> _.Split('\n')
expect {
snapshot
@"namespace BigExample
open WoofWare.Expect
module MyModule =
let veryLongMultiline () =
expect {
snapshot
@""Replaced with
a different
multiline
value""
return ""long""
}
let multilineWithEmptyLines () =
expect {
snapshot
@""First line
Third line
Sixth line""
return ""empty lines""
}
let multilineWithSpecialChars () =
expect {
snapshot
""""""Special chars:
Tab: here
Quotes: ""double"" and 'single'
Backslash: \ and \\
Unicode: 🎯
Regex: .*+?[]""""""
return ""special""
}
let multilineJson () =
expect {
snapshotJson
@""{
""""name"""": """"test"""",
""""values"""": [
1,
2,
3
],
""""nested"""": {
""""deep"""": true
}
}""
return
{
name = ""test""
values = [ 1 ; 2 ; 3 ]
nested =
{|
deep = true
|}
}
}
let windowsLineEndings () =
expect {
snapshot ""Line 1\r\nLine 2\r\nLine 3""
return ""crlf""
}
"
return
SnapshotUpdate.updateSnapshotAtLine source 8 "Replaced with\na different\nmultiline\nvalue"
|> String.concat "\n"
}
[<Test>]
let ``Multiline with empty lines`` () =
let source =
Assembly.getEmbeddedResource typeof<Dummy>.Assembly "MultilineComplex.fs"
|> _.Split('\n')
expect {
snapshot
@"namespace BigExample
open WoofWare.Expect
module MyModule =
let veryLongMultiline () =
expect {
snapshot
""""""Line 1
Line 2
Line 3
Line 4
Line 5
Line 6
Line 7
Line 8
Line 9
Line 10
Indented line 11
More indented line 12
Line 13
Line 14
Line 15""""""
return ""long""
}
let multilineWithEmptyLines () =
expect {
snapshot
@""Replaced with
a different
multiline
value""
return ""empty lines""
}
let multilineWithSpecialChars () =
expect {
snapshot
""""""Special chars:
Tab: here
Quotes: ""double"" and 'single'
Backslash: \ and \\
Unicode: 🎯
Regex: .*+?[]""""""
return ""special""
}
let multilineJson () =
expect {
snapshotJson
@""{
""""name"""": """"test"""",
""""values"""": [
1,
2,
3
],
""""nested"""": {
""""deep"""": true
}
}""
return
{
name = ""test""
values = [ 1 ; 2 ; 3 ]
nested =
{|
deep = true
|}
}
}
let windowsLineEndings () =
expect {
snapshot ""Line 1\r\nLine 2\r\nLine 3""
return ""crlf""
}
"
return
SnapshotUpdate.updateSnapshotAtLine source 30 "Replaced with\n\na different\nmultiline\nvalue"
|> String.concat "\n"
}
[<Test>]
let ``Multiline with special chars`` () =
let source =
Assembly.getEmbeddedResource typeof<Dummy>.Assembly "MultilineComplex.fs"
|> _.Split('\n')
expect {
snapshot
@"namespace BigExample
open WoofWare.Expect
module MyModule =
let veryLongMultiline () =
expect {
snapshot
""""""Line 1
Line 2
Line 3
Line 4
Line 5
Line 6
Line 7
Line 8
Line 9
Line 10
Indented line 11
More indented line 12
Line 13
Line 14
Line 15""""""
return ""long""
}
let multilineWithEmptyLines () =
expect {
snapshot
@""First line
Third line
Sixth line""
return ""empty lines""
}
let multilineWithSpecialChars () =
expect {
snapshot
@""get rid of it all""
return ""special""
}
let multilineJson () =
expect {
snapshotJson
@""{
""""name"""": """"test"""",
""""values"""": [
1,
2,
3
],
""""nested"""": {
""""deep"""": true
}
}""
return
{
name = ""test""
values = [ 1 ; 2 ; 3 ]
nested =
{|
deep = true
|}
}
}
let windowsLineEndings () =
expect {
snapshot ""Line 1\r\nLine 2\r\nLine 3""
return ""crlf""
}
"
return
SnapshotUpdate.updateSnapshotAtLine source 43 "get rid of it all"
|> String.concat "\n"
}
[<Test>]
let ``Complex nested JSON with Unicode`` () =
let source =
Assembly.getEmbeddedResource typeof<Dummy>.Assembly "MultilineComplex.fs"
|> _.Split('\n')
expect {
snapshot
@"namespace BigExample
open WoofWare.Expect
module MyModule =
let veryLongMultiline () =
expect {
snapshot
""""""Line 1
Line 2
Line 3
Line 4
Line 5
Line 6
Line 7
Line 8
Line 9
Line 10
Indented line 11
More indented line 12
Line 13
Line 14
Line 15""""""
return ""long""
}
let multilineWithEmptyLines () =
expect {
snapshot
@""First line
Third line
Sixth line""
return ""empty lines""
}
let multilineWithSpecialChars () =
expect {
snapshot
""""""Special chars:
Tab: here
Quotes: ""double"" and 'single'
Backslash: \ and \\
Unicode: 🎯
Regex: .*+?[]""""""
return ""special""
}
let multilineJson () =
expect {
snapshotJson
@""wheeeee""
return
{
name = ""test""
values = [ 1 ; 2 ; 3 ]
nested =
{|
deep = true
|}
}
}
let windowsLineEndings () =
expect {
snapshot ""Line 1\r\nLine 2\r\nLine 3""
return ""crlf""
}
"
return SnapshotUpdate.updateSnapshotAtLine source 56 "wheeeee" |> String.concat "\n"
}
[<Test>]
let ``Windows line endings`` () =
let source =
Assembly.getEmbeddedResource typeof<Dummy>.Assembly "MultilineComplex.fs"
|> _.Split('\n')
expect {
snapshot
@"namespace BigExample
open WoofWare.Expect
module MyModule =
let veryLongMultiline () =
expect {
snapshot
""""""Line 1
Line 2
Line 3
Line 4
Line 5
Line 6
Line 7
Line 8
Line 9
Line 10
Indented line 11
More indented line 12
Line 13
Line 14
Line 15""""""
return ""long""
}
let multilineWithEmptyLines () =
expect {
snapshot
@""First line
Third line
Sixth line""
return ""empty lines""
}
let multilineWithSpecialChars () =
expect {
snapshot
""""""Special chars:
Tab: here
Quotes: ""double"" and 'single'
Backslash: \ and \\
Unicode: 🎯
Regex: .*+?[]""""""
return ""special""
}
let multilineJson () =
expect {
snapshotJson
@""{
""""name"""": """"test"""",
""""values"""": [
1,
2,
3
],
""""nested"""": {
""""deep"""": true
}
}""
return
{
name = ""test""
values = [ 1 ; 2 ; 3 ]
nested =
{|
deep = true
|}
}
}
let windowsLineEndings () =
expect {
snapshot ""down with line endings""
return ""crlf""
}
"
return
SnapshotUpdate.updateSnapshotAtLine source 82 "down with line endings"
|> String.concat "\n"
}

View File

@@ -0,0 +1,190 @@
namespace WoofWare.Expect.Test
open WoofWare.Expect
open NUnit.Framework
[<TestFixture>]
[<Parallelizable(ParallelScope.Children)>]
module TestRegexMetacharacters =
[<OneTimeSetUp>]
let ``Prepare to bulk-update tests`` () =
// GlobalBuilderConfig.enterBulkUpdateMode ()
()
[<OneTimeTearDown>]
let ``Update all tests`` () =
GlobalBuilderConfig.updateAllSnapshots ()
type Dummy = class end
[<Test>]
let ``Regex metacharacters in verbatim string`` () =
let source =
Assembly.getEmbeddedResource typeof<Dummy>.Assembly "RegexMetacharacters.fs"
|> _.Split('\n')
expect {
snapshot
@"namespace BigExample
open WoofWare.Expect
module MyModule =
let regexChars () =
expect {
snapshot @""replacement with .*+?[]{}()|^$\ chars""
return 123
}
let regexInTripleQuote () =
expect {
snapshot """"""regex: .*+?[]{}()|^$\ in triple quotes""""""
return 456
}
let regexInRegularString () =
expect {
snapshot ""escaped regex: \\.\\*\\+\\?\\[\\]\\{\\}\\(\\)\\|\\^\\$\\\\""
return 789
}
let complexRegexPattern () =
expect {
snapshotJson @""^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$""
return ""IP regex""
}
"
return
SnapshotUpdate.updateSnapshotAtLine source 8 "replacement with .*+?[]{}()|^$\\ chars"
|> String.concat "\n"
}
[<Test>]
let ``Regex metacharacters in triple quote`` () =
let source =
Assembly.getEmbeddedResource typeof<Dummy>.Assembly "RegexMetacharacters.fs"
|> _.Split('\n')
expect {
snapshot
@"namespace BigExample
open WoofWare.Expect
module MyModule =
let regexChars () =
expect {
snapshot @""test with regex chars: .*+?[]{}()|^$\ and more""
return 123
}
let regexInTripleQuote () =
expect {
snapshot @""new regex: [a-z]+(?:\d{2,4})? pattern""
return 456
}
let regexInRegularString () =
expect {
snapshot ""escaped regex: \\.\\*\\+\\?\\[\\]\\{\\}\\(\\)\\|\\^\\$\\\\""
return 789
}
let complexRegexPattern () =
expect {
snapshotJson @""^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$""
return ""IP regex""
}
"
return
SnapshotUpdate.updateSnapshotAtLine source 14 "new regex: [a-z]+(?:\\d{2,4})? pattern"
|> String.concat "\n"
}
[<Test>]
let ``Regex metacharacters in regular string`` () =
let source =
Assembly.getEmbeddedResource typeof<Dummy>.Assembly "RegexMetacharacters.fs"
|> _.Split('\n')
expect {
snapshot
@"namespace BigExample
open WoofWare.Expect
module MyModule =
let regexChars () =
expect {
snapshot @""test with regex chars: .*+?[]{}()|^$\ and more""
return 123
}
let regexInTripleQuote () =
expect {
snapshot """"""regex: .*+?[]{}()|^$\ in triple quotes""""""
return 456
}
let regexInRegularString () =
expect {
snapshot @""new regex: [a-z]+(?:\d{2,4})? pattern""
return 789
}
let complexRegexPattern () =
expect {
snapshotJson @""^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$""
return ""IP regex""
}
"
return
SnapshotUpdate.updateSnapshotAtLine source 20 "new regex: [a-z]+(?:\\d{2,4})? pattern"
|> String.concat "\n"
}
[<Test>]
let ``IP regex`` () =
let source =
Assembly.getEmbeddedResource typeof<Dummy>.Assembly "RegexMetacharacters.fs"
|> _.Split('\n')
expect {
snapshot
@"namespace BigExample
open WoofWare.Expect
module MyModule =
let regexChars () =
expect {
snapshot @""test with regex chars: .*+?[]{}()|^$\ and more""
return 123
}
let regexInTripleQuote () =
expect {
snapshot """"""regex: .*+?[]{}()|^$\ in triple quotes""""""
return 456
}
let regexInRegularString () =
expect {
snapshot ""escaped regex: \\.\\*\\+\\?\\[\\]\\{\\}\\(\\)\\|\\^\\$\\\\""
return 789
}
let complexRegexPattern () =
expect {
snapshotJson @""new regex: [a-z]+(?:\d{2,4})? pattern""
return ""IP regex""
}
"
return
SnapshotUpdate.updateSnapshotAtLine source 26 "new regex: [a-z]+(?:\\d{2,4})? pattern"
|> String.concat "\n"
}

View File

@@ -4,7 +4,16 @@ open WoofWare.Expect
open NUnit.Framework open NUnit.Framework
[<TestFixture>] [<TestFixture>]
[<Parallelizable(ParallelScope.Children)>]
module TestSnapshotFinding = module TestSnapshotFinding =
[<OneTimeSetUp>]
let ``Prepare to bulk-update tests`` () =
// GlobalBuilderConfig.enterBulkUpdateMode ()
()
[<OneTimeTearDown>]
let ``Update all tests`` () =
GlobalBuilderConfig.updateAllSnapshots ()
type Dummy = class end type Dummy = class end
@@ -23,7 +32,7 @@ open WoofWare.Expect
module MyModule = module MyModule =
let foo () = let foo () =
expect { expect {
snapshot @""replacement"" snapshot ""replacement""
return 123 return 123
} }
" "
@@ -72,7 +81,7 @@ open WoofWare.Expect
module MyModule = module MyModule =
let foo () = let foo () =
expect { expect {
snapshot @""replacement"" snapshot ""replacement""
return 123 return 123
} }
" "

View File

@@ -0,0 +1,456 @@
namespace WoofWare.Expect.Test
open NUnit.Framework
open WoofWare.Expect
[<TestFixture>]
module TestUnicodeCharacters =
[<OneTimeSetUp>]
let ``Prepare to bulk-update tests`` () =
// GlobalBuilderConfig.enterBulkUpdateMode ()
()
[<OneTimeTearDown>]
let ``Update all tests`` () =
GlobalBuilderConfig.updateAllSnapshots ()
type Dummy = class end
[<Test>]
let ``Unicode emoji in string`` () =
let source =
Assembly.getEmbeddedResource typeof<Dummy>.Assembly "UnicodeCharacters.fs"
|> _.Split('\n')
expect {
snapshot
@"namespace BigExample
open WoofWare.Expect
module MyModule =
let emoji () =
expect {
snapshot ""Updated with 🚀🌟✨ more emoji!""
return 123
}
let chineseCharacters () =
expect {
snapshot """"""Chinese: 你好世界""""""
return ""hello""
}
let arabicRTL () =
expect {
snapshot @""Arabic RTL: مرحبا بالعالم""
return ""rtl test""
}
let combiningCharacters () =
expect {
// Combining diacritics: e + ́ = é
snapshot ""test with combining: e\u0301 and a\u0308""
return ""combining""
}
let mixedScripts () =
expect {
snapshotJson @""Mixed: English, русский, 日本語, العربية, emoji 🚀""
return [ ""multilingual"" ]
}
let zeroWidthChars () =
expect {
snapshot @""Zerowidthspacetest"" // Contains U+200B
return ""zwsp""
}
let mathSymbols () =
expect {
snapshot """"""Math: ∀x∈, ∃y: + = 1 |x| 1""""""
return ""math""
}
"
return
SnapshotUpdate.updateSnapshotAtLine source 8 "Updated with 🚀🌟✨ more emoji!"
|> String.concat "\n"
}
[<Test>]
let ``Unicode Chinese characters multi-line`` () =
let source =
Assembly.getEmbeddedResource typeof<Dummy>.Assembly "UnicodeCharacters.fs"
|> _.Split('\n')
expect {
snapshot
@"namespace BigExample
open WoofWare.Expect
module MyModule =
let emoji () =
expect {
snapshot @""Hello 👋 World 🌍 with emoji 🎉🎊""
return 123
}
let chineseCharacters () =
expect {
snapshot @""Chinese poem:
静夜思
床前明月光
疑是地上霜
举头望明月
低头思故乡""
return ""hello""
}
let arabicRTL () =
expect {
snapshot @""Arabic RTL: مرحبا بالعالم""
return ""rtl test""
}
let combiningCharacters () =
expect {
// Combining diacritics: e + ́ = é
snapshot ""test with combining: e\u0301 and a\u0308""
return ""combining""
}
let mixedScripts () =
expect {
snapshotJson @""Mixed: English, русский, 日本語, العربية, emoji 🚀""
return [ ""multilingual"" ]
}
let zeroWidthChars () =
expect {
snapshot @""Zerowidthspacetest"" // Contains U+200B
return ""zwsp""
}
let mathSymbols () =
expect {
snapshot """"""Math: ∀x∈, ∃y: + = 1 |x| 1""""""
return ""math""
}
"
return
SnapshotUpdate.updateSnapshotAtLine source 14 "Chinese poem:\n静夜思\n床前明月光\n疑是地上霜\n举头望明月\n低头思故乡"
|> String.concat "\n"
}
[<Test>]
let ``Arabic RTL`` () =
let source =
Assembly.getEmbeddedResource typeof<Dummy>.Assembly "UnicodeCharacters.fs"
|> _.Split('\n')
expect {
snapshot
@"namespace BigExample
open WoofWare.Expect
module MyModule =
let emoji () =
expect {
snapshot @""Hello 👋 World 🌍 with emoji 🎉🎊""
return 123
}
let chineseCharacters () =
expect {
snapshot """"""Chinese: 你好世界""""""
return ""hello""
}
let arabicRTL () =
expect {
snapshot ""Updated Arabic: مرحبا بالعالم""
return ""rtl test""
}
let combiningCharacters () =
expect {
// Combining diacritics: e + ́ = é
snapshot ""test with combining: e\u0301 and a\u0308""
return ""combining""
}
let mixedScripts () =
expect {
snapshotJson @""Mixed: English, русский, 日本語, العربية, emoji 🚀""
return [ ""multilingual"" ]
}
let zeroWidthChars () =
expect {
snapshot @""Zerowidthspacetest"" // Contains U+200B
return ""zwsp""
}
let mathSymbols () =
expect {
snapshot """"""Math: ∀x∈, ∃y: + = 1 |x| 1""""""
return ""math""
}
"
return
SnapshotUpdate.updateSnapshotAtLine source 20 "Updated Arabic: مرحبا بالعالم"
|> String.concat "\n"
}
[<Test>]
let ``Combining characters`` () =
let source =
Assembly.getEmbeddedResource typeof<Dummy>.Assembly "UnicodeCharacters.fs"
|> _.Split('\n')
expect {
snapshot
@"namespace BigExample
open WoofWare.Expect
module MyModule =
let emoji () =
expect {
snapshot @""Hello 👋 World 🌍 with emoji 🎉🎊""
return 123
}
let chineseCharacters () =
expect {
snapshot """"""Chinese: 你好世界""""""
return ""hello""
}
let arabicRTL () =
expect {
snapshot @""Arabic RTL: مرحبا بالعالم""
return ""rtl test""
}
let combiningCharacters () =
expect {
// Combining diacritics: e + ́ = é
snapshot ""updated test with combining: and ä!""
return ""combining""
}
let mixedScripts () =
expect {
snapshotJson @""Mixed: English, русский, 日本語, العربية, emoji 🚀""
return [ ""multilingual"" ]
}
let zeroWidthChars () =
expect {
snapshot @""Zerowidthspacetest"" // Contains U+200B
return ""zwsp""
}
let mathSymbols () =
expect {
snapshot """"""Math: ∀x∈, ∃y: + = 1 |x| 1""""""
return ""math""
}
"
return
SnapshotUpdate.updateSnapshotAtLine source 27 "updated test with combining: e\u0301 and a\u0308!"
|> String.concat "\n"
}
[<Test>]
let ``Mixed scripts`` () =
let source =
Assembly.getEmbeddedResource typeof<Dummy>.Assembly "UnicodeCharacters.fs"
|> _.Split('\n')
expect {
snapshot
@"namespace BigExample
open WoofWare.Expect
module MyModule =
let emoji () =
expect {
snapshot @""Hello 👋 World 🌍 with emoji 🎉🎊""
return 123
}
let chineseCharacters () =
expect {
snapshot """"""Chinese: 你好世界""""""
return ""hello""
}
let arabicRTL () =
expect {
snapshot @""Arabic RTL: مرحبا بالعالم""
return ""rtl test""
}
let combiningCharacters () =
expect {
// Combining diacritics: e + ́ = é
snapshot ""test with combining: e\u0301 and a\u0308""
return ""combining""
}
let mixedScripts () =
expect {
snapshotJson ""Updated mixed: English, русский, 日本語, العربية, emoji 🚀""
return [ ""multilingual"" ]
}
let zeroWidthChars () =
expect {
snapshot @""Zerowidthspacetest"" // Contains U+200B
return ""zwsp""
}
let mathSymbols () =
expect {
snapshot """"""Math: ∀x∈, ∃y: + = 1 |x| 1""""""
return ""math""
}
"
return
SnapshotUpdate.updateSnapshotAtLine source 33 "Updated mixed: English, русский, 日本語, العربية, emoji 🚀"
|> String.concat "\n"
}
[<Test>]
let ``ZWBS character`` () =
let source =
Assembly.getEmbeddedResource typeof<Dummy>.Assembly "UnicodeCharacters.fs"
|> _.Split('\n')
expect {
snapshot
@"namespace BigExample
open WoofWare.Expect
module MyModule =
let emoji () =
expect {
snapshot @""Hello 👋 World 🌍 with emoji 🎉🎊""
return 123
}
let chineseCharacters () =
expect {
snapshot """"""Chinese: 你好世界""""""
return ""hello""
}
let arabicRTL () =
expect {
snapshot @""Arabic RTL: مرحبا بالعالم""
return ""rtl test""
}
let combiningCharacters () =
expect {
// Combining diacritics: e + ́ = é
snapshot ""test with combining: e\u0301 and a\u0308""
return ""combining""
}
let mixedScripts () =
expect {
snapshotJson @""Mixed: English, русский, 日本語, العربية, emoji 🚀""
return [ ""multilingual"" ]
}
let zeroWidthChars () =
expect {
snapshot ""Updated: Zerowidthspacetest"" // Contains U+200B
return ""zwsp""
}
let mathSymbols () =
expect {
snapshot """"""Math: ∀x∈, ∃y: + = 1 |x| 1""""""
return ""math""
}
"
return
SnapshotUpdate.updateSnapshotAtLine source 39 "Updated: Zerowidthspacetest"
|> String.concat "\n"
}
[<Test>]
let ``Maths`` () =
let source =
Assembly.getEmbeddedResource typeof<Dummy>.Assembly "UnicodeCharacters.fs"
|> _.Split('\n')
expect {
snapshot
@"namespace BigExample
open WoofWare.Expect
module MyModule =
let emoji () =
expect {
snapshot @""Hello 👋 World 🌍 with emoji 🎉🎊""
return 123
}
let chineseCharacters () =
expect {
snapshot """"""Chinese: 你好世界""""""
return ""hello""
}
let arabicRTL () =
expect {
snapshot @""Arabic RTL: مرحبا بالعالم""
return ""rtl test""
}
let combiningCharacters () =
expect {
// Combining diacritics: e + ́ = é
snapshot ""test with combining: e\u0301 and a\u0308""
return ""combining""
}
let mixedScripts () =
expect {
snapshotJson @""Mixed: English, русский, 日本語, العربية, emoji 🚀""
return [ ""multilingual"" ]
}
let zeroWidthChars () =
expect {
snapshot @""Zerowidthspacetest"" // Contains U+200B
return ""zwsp""
}
let mathSymbols () =
expect {
snapshot ""Pretty vacuous, huh: ∀x∈, ∃y: + = 1 |x| 1""
return ""math""
}
"
return
SnapshotUpdate.updateSnapshotAtLine source 45 "Pretty vacuous, huh: ∀x∈, ∃y: + = 1 |x| 1"
|> String.concat "\n"
}

View File

@@ -0,0 +1,32 @@
namespace WoofWare.Expect.Test
open NUnit.Framework
open WoofWare.Expect
[<TestFixture>]
module TestSnapshotList =
[<OneTimeSetUp>]
let ``Prepare to bulk-update tests`` () =
// If you don't want to enter bulk-update mode, just replace this line with a no-op `()`.
// The `updateAllSnapshots` tear-down below will simply do nothing in that case.
// GlobalBuilderConfig.enterBulkUpdateMode ()
()
[<OneTimeTearDown>]
let ``Update all tests`` () =
GlobalBuilderConfig.updateAllSnapshots ()
[<Test>]
let ``simple list test`` () =
expect {
snapshotList [ "1" ; "2" ; "3" ]
return [ 1..3 ]
}
[<Test>]
let ``list test with formatting`` () =
expect {
snapshotList [ "8" ; "9" ; "0" ; "1" ; "2" ]
withFormat (fun x -> string<int> (x % 10))
return [ 8..12 ]
}

View File

@@ -1,20 +1,39 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net9.0</TargetFramework>
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
<IsPackable>false</IsPackable> <IsPackable>false</IsPackable>
<EnableNUnitRunner>true</EnableNUnitRunner>
<OutputType>Exe</OutputType>
<TestingPlatformDotnetTestSupport>true</TestingPlatformDotnetTestSupport>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<Compile Include="Assembly.fs" /> <Compile Include="Assembly.fs" />
<Compile Include="BulkUpdateExample.fs" />
<Compile Include="SimpleTest.fs" /> <Compile Include="SimpleTest.fs" />
<Compile Include="TestSnapshotFinding.fs" /> <Compile Include="TestDiff.fs" />
<Compile Include="TestDot.fs" />
<Compile Include="TestExceptionThrowing.fs" />
<Compile Include="TestSnapshotList.fs" />
<Compile Include="TestSurface.fs" /> <Compile Include="TestSurface.fs" />
<Compile Include="TestSnapshotFinding\TestSnapshotFinding.fs" />
<Compile Include="TestSnapshotFinding\TestUnicodeCharacters.fs" />
<Compile Include="TestSnapshotFinding\TestMultilineComplex.fs" />
<Compile Include="TestSnapshotFinding\TestEdgeCases.fs" />
<Compile Include="TestSnapshotFinding\TestCommentsAndSpacing.fs" />
<Compile Include="TestSnapshotFinding\TestRegexMetacharacters.fs" />
<EmbeddedResource Include="SyntaxCases\AtStringOneLine.fs" /> <EmbeddedResource Include="SyntaxCases\AtStringOneLine.fs" />
<EmbeddedResource Include="SyntaxCases\SingleQuoteManyLine.fs" /> <EmbeddedResource Include="SyntaxCases\SingleQuoteManyLine.fs" />
<EmbeddedResource Include="SyntaxCases\TripleQuoteInterveningComment.fs" /> <EmbeddedResource Include="SyntaxCases\TripleQuoteInterveningComment.fs" />
<EmbeddedResource Include="SyntaxCases\TripleQuoteOneLine.fs" /> <EmbeddedResource Include="SyntaxCases\TripleQuoteOneLine.fs" />
<EmbeddedResource Include="SyntaxCases\RegexMetacharacters.fs" />
<EmbeddedResource Include="SyntaxCases\UnicodeCharacters.fs" />
<EmbeddedResource Include="SyntaxCases\MultilineComplex.fs" />
<EmbeddedResource Include="SyntaxCases\EdgeCases.fs" />
<EmbeddedResource Include="SyntaxCases\CommentsAndSpacing.fs" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -23,6 +42,9 @@
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1"/> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1"/>
<PackageReference Include="NUnit" Version="4.3.2"/> <PackageReference Include="NUnit" Version="4.3.2"/>
<PackageReference Include="NUnit3TestAdapter" Version="5.0.0"/> <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>
<ItemGroup> <ItemGroup>

View File

@@ -0,0 +1,213 @@
namespace WoofWare.Expect
// This file mostly courtesy of Claude 4 Opus.
open Fantomas.FCS.Diagnostics
open Fantomas.FCS.Syntax
open Fantomas.FCS.Text
type internal SnapshotLocation =
{
KeywordRange : Range
Keyword : string
ReplacementRange : Range
}
[<RequireQualifiedAccess>]
module internal AstWalker =
let private snapshotSignifier =
[ "snapshot" ; "snapshotJson" ; "snapshotList" ; "snapshotThrows" ]
|> Set.ofList
/// Check if this is a call to snapshotList (or any other snapshot method we care about)
/// Returns the identifier that is the snapshot invocation, and its range.
let private isSnapshotCall (funcExpr : SynExpr) : (string * Range) option =
match funcExpr with
| SynExpr.Ident ident when snapshotSignifier.Contains ident.idText -> Some (ident.idText, ident.idRange)
| SynExpr.LongIdent (_, longIdent, _, _) ->
match longIdent.IdentsWithTrivia with
| [] -> None
| ids ->
match List.last ids with
| SynIdent.SynIdent (ident, _) ->
if snapshotSignifier.Contains ident.idText then
Some (ident.idText, ident.idRange)
else
None
| _ -> None
/// Extract the argument from a method application
let private getMethodArgument (expr : SynExpr) =
match expr with
| SynExpr.App (_, _, _, argExpr, _) -> Some argExpr
| _ -> None
/// Walk expressions looking for our target
let rec findSnapshotListCalls (targetLine : int) (methodName : string) (expr : SynExpr) : SnapshotLocation list =
match expr with
// Direct method application
| SynExpr.App (_, _, funcExpr, argExpr, range) ->
match isSnapshotCall funcExpr with
| Some (keyword, keywordRange) ->
if range.StartLine <= targetLine && targetLine <= range.EndLine then
match argExpr with
| SynExpr.ArrayOrList (isList, _, argRange) when isList ->
// It's a list literal
[
{
ReplacementRange = argRange
KeywordRange = keywordRange
Keyword = keyword
}
] // Text will be extracted separately
| SynExpr.ArrayOrListComputed (isArray, _inner, argRange) when not isArray ->
// It's a list comprehension
[
{
ReplacementRange = argRange
KeywordRange = keywordRange
Keyword = keyword
}
]
| _ ->
// It could be a variable reference or other expression
[
{
ReplacementRange = argExpr.Range
KeywordRange = keywordRange
Keyword = keyword
}
]
else
[]
| None ->
// Other app variations
findSnapshotListCalls targetLine methodName funcExpr
@ findSnapshotListCalls targetLine methodName argExpr
// Nested in paren
| SynExpr.Paren (innerExpr, _, _, _) -> findSnapshotListCalls targetLine methodName innerExpr
// Sequential expressions (e.g., in a do block)
| SynExpr.Sequential (_, _, expr1, expr2, _, _) ->
findSnapshotListCalls targetLine methodName expr1
@ findSnapshotListCalls targetLine methodName expr2
// Let bindings
| SynExpr.LetOrUse (_, _, bindings, bodyExpr, _, _) ->
let bindingResults =
bindings
|> List.collect (fun binding ->
match binding with
| SynBinding (expr = expr) -> findSnapshotListCalls targetLine methodName expr
)
bindingResults @ findSnapshotListCalls targetLine methodName bodyExpr
// Match expressions
| SynExpr.Match (_, _, clauses, _, _) ->
clauses
|> List.collect (fun (SynMatchClause (resultExpr = expr)) ->
findSnapshotListCalls targetLine methodName expr
)
// If/then/else
| SynExpr.IfThenElse (_, thenExpr, elseExprOpt, _, _, _, _) ->
let thenResults = findSnapshotListCalls targetLine methodName thenExpr
let elseResults =
match elseExprOpt with
| Some elseExpr -> findSnapshotListCalls targetLine methodName elseExpr
| None -> []
thenResults @ elseResults
// Lambda
| SynExpr.Lambda (body = bodyExpr) -> findSnapshotListCalls targetLine methodName bodyExpr
// Computation expression
| SynExpr.ComputationExpr (_, innerExpr, _) -> findSnapshotListCalls targetLine methodName innerExpr
// Default case - no results
| _ -> []
/// Walk a module or namespace looking for expressions
let rec findInModuleDecls (targetLine : int) (methodName : string) (decls : SynModuleDecl list) =
decls
|> List.collect (fun decl ->
match decl with
| SynModuleDecl.Let (_, bindings, _) ->
bindings
|> List.collect (fun binding ->
match binding with
| SynBinding (expr = expr) -> findSnapshotListCalls targetLine methodName expr
)
| SynModuleDecl.Expr (expr, _) -> findSnapshotListCalls targetLine methodName expr
| SynModuleDecl.NestedModule (decls = nestedDecls) -> findInModuleDecls targetLine methodName nestedDecls
| SynModuleDecl.Types (typeDefs, _) ->
typeDefs
|> List.collect (fun typeDef ->
match typeDef with
| SynTypeDefn (typeRepr = SynTypeDefnRepr.ObjectModel (members = members)) ->
members
|> List.collect (fun member' ->
match member' with
| SynMemberDefn.Member (memberBinding, _) ->
match memberBinding with
| SynBinding (expr = expr) -> findSnapshotListCalls targetLine methodName expr
| _ -> []
)
| _ -> []
)
| SynModuleDecl.HashDirective _
| SynModuleDecl.Attributes _
| SynModuleDecl.ModuleAbbrev _
| SynModuleDecl.Exception _
| SynModuleDecl.Open _ -> []
| SynModuleDecl.NamespaceFragment (SynModuleOrNamespace (decls = decls)) ->
findInModuleDecls targetLine methodName decls
)
/// Main function to find snapshot list locations
let findSnapshotList
(infoFilePath : string)
(lines : string[])
(lineNumber : int)
(methodName : string) // e.g., "snapshotList"
: SnapshotLocation
=
let sourceText = SourceText.ofString (String.concat "\n" lines)
// Parse the file
let parsedInput, diagnostics = Fantomas.FCS.Parse.parseFile false sourceText []
// Check for parse errors
if
diagnostics
|> List.exists (fun d -> d.Severity = FSharpDiagnosticSeverity.Error)
then
failwithf $"Parse errors in file %s{infoFilePath}: %A{diagnostics}"
// Walk the AST
let results =
match parsedInput with
| ParsedInput.ImplFile (ParsedImplFileInput (contents = modules)) ->
modules
|> List.collect (fun moduleOrNs ->
match moduleOrNs with
| SynModuleOrNamespace (decls = decls) -> findInModuleDecls lineNumber methodName decls
)
| ParsedInput.SigFile _ -> failwith "unexpected: signature files can't contain expressions"
// Find the closest match
results
|> Seq.filter (fun loc ->
loc.KeywordRange.StartLine <= lineNumber
&& lineNumber <= loc.KeywordRange.EndLine
)
|> Seq.exactlyOne

View File

@@ -1,41 +1,13 @@
namespace WoofWare.Expect namespace WoofWare.Expect
open System.Collections.Generic
open System.IO open System.IO
open System.Runtime.CompilerServices open System.Runtime.CompilerServices
open System.Text.Json open System.Text.Json
open System.Text.Json.Serialization
type private CallerInfo =
{
MemberName : string
FilePath : string
LineNumber : int
}
type private SnapshotValue =
| BareString of string
| Json of string
/// An exception indicating that a value failed to match its snapshot. /// An exception indicating that a value failed to match its snapshot.
exception ExpectException of Message : string exception ExpectException of Message : string
/// A dummy type which is here to provide better error messages when you supply
/// the `snapshot` keyword multiple times.
type YouHaveSuppliedMultipleSnapshots = private | NonConstructible
/// The state accumulated by the `expect` builder. You should never find yourself interacting with this type.
type ExpectState<'T> =
private
{
Snapshot : (SnapshotValue * CallerInfo) option
Actual : 'T option
}
[<RequireQualifiedAccess>]
module private Text =
let predent (c : char) (s : string) =
s.Split '\n' |> Seq.map (sprintf "%c %s" c) |> String.concat "\n"
/// <summary>Specify how the Expect computation expression treats failures.</summary> /// <summary>Specify how the Expect computation expression treats failures.</summary>
/// <remarks>You probably don't want to use this directly; use the computation expression definitions /// <remarks>You probably don't want to use this directly; use the computation expression definitions
/// like <c>expect</c> in the <c>Builder</c> module instead.</remarks> /// like <c>expect</c> in the <c>Builder</c> module instead.</remarks>
@@ -52,8 +24,6 @@ type Mode =
/// <param name="applyChanges">When running the tests, instead of throwing an exception on failure, update the snapshot.</param> /// <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> /// <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) = type ExpectBuilder (mode : Mode) =
member private this.Mode = Unchecked.defaultof<Mode>
new (sourceOverride : string * int) = ExpectBuilder (Mode.AssertMockingSource sourceOverride) new (sourceOverride : string * int) = ExpectBuilder (Mode.AssertMockingSource sourceOverride)
new (update : bool) new (update : bool)
@@ -63,10 +33,35 @@ type ExpectBuilder (mode : Mode) =
else else
ExpectBuilder Mode.Assert ExpectBuilder Mode.Assert
/// Combine two `ExpectState`s. The first one is the "expected" snapshot; the second is the "actual". /// Combine two `ExpectStateListy`s. The first one is the "expected" snapshot; the second is the "actual".
member _.Bind member _.Bind<'U> (state : ExpectStateListy<'U>, f : unit -> ExpectStateListy<'U>) : ExpectStateListy<'U> =
(state : ExpectState<YouHaveSuppliedMultipleSnapshots>, f : unit -> ExpectState<'U>) let actual = f ()
: ExpectState<'U>
match state.Actual with
| Some _ -> failwith "somehow came in with an Actual"
| None ->
match actual.Snapshot with
| Some _ -> failwith "somehow Actual came through with a Snapshot"
| None ->
let formatter =
match state.Formatter, actual.Formatter with
| None, f -> f
| Some f, None -> Some f
| Some _, Some _ -> failwith "multiple formatters supplied for a single expect!"
{
Formatter = formatter
Snapshot = state.Snapshot
Actual = actual.Actual
}
/// Combine an `ExpectStateListy` with an `ExpectState`. The first one is the "expected" snapshot; the second is
/// the "actual".
member _.Bind<'U, 'elt when 'U :> IEnumerable<'elt>>
(state : ExpectStateListy<'elt>, f : unit -> ExpectState<'U>)
: ExpectStateListy<'elt>
= =
let actual = f () let actual = f ()
@@ -78,23 +73,71 @@ type ExpectBuilder (mode : Mode) =
| Some _ -> failwith "somehow Actual came through with a Snapshot" | Some _ -> failwith "somehow Actual came through with a Snapshot"
| None -> | None ->
let formatter : ((unit -> 'elt) -> string) option =
match state.Formatter, actual.Formatter with
| None, None -> None
| None, Some _ ->
failwith
"unexpectedly had a formatter supplied before the snapshotList keyword; I thought this was impossible"
| Some f, None -> Some f
| Some _, Some _ -> failwith "multiple formatters supplied for a single expect!"
{
Formatter = formatter
Snapshot = state.Snapshot
Actual = actual.Actual |> Option.map (fun f () -> Seq.cast<'elt> (f ()))
}
/// Combine two `ExpectState`s. The first one is the "expected" snapshot; the second is the "actual".
member _.Bind<'U> (state : ExpectState<'U>, f : unit -> ExpectState<'U>) : ExpectState<'U> =
let actual = f ()
match state.Actual with
| Some _ -> failwith "somehow came in with an Actual"
| None ->
match actual.Snapshot with
| Some _ -> failwith "somehow Actual came through with a Snapshot"
| None ->
let formatter =
match state.Formatter, actual.Formatter with
| None, f -> f
| Some f, None -> Some f
| Some _, Some _ -> failwith "multiple formatters supplied for a single expect!"
let jsonSerOptions =
match state.JsonSerialiserOptions, actual.JsonSerialiserOptions with
| None, f -> f
| Some f, None -> Some f
| Some _, Some _ -> failwith "multiple JSON serialiser options supplied for a single expect!"
let jsonDocOptions =
match state.JsonDocOptions, actual.JsonDocOptions with
| None, f -> f
| Some f, None -> Some f
| Some _, Some _ -> failwith "multiple JSON document options supplied for a single expect!"
// Pass through the state structure when there's no actual value // Pass through the state structure when there's no actual value
{ {
Formatter = formatter
Snapshot = state.Snapshot Snapshot = state.Snapshot
Actual = actual.Actual Actual = actual.Actual
JsonSerialiserOptions = jsonSerOptions
JsonDocOptions = jsonDocOptions
} }
/// <summary>Express that the actual value's <c>ToString</c> should identically equal this string.</summary> /// <summary>Express that the actual value's <c>ToString</c> should identically equal this string.</summary>
[<CustomOperation("snapshot", MaintainsVariableSpaceUsingBind = true)>] [<CustomOperation("snapshot", MaintainsVariableSpaceUsingBind = true)>]
member _.Snapshot member _.Snapshot<'a>
( (
state : ExpectState<unit>, state : ExpectStateListy<'a>,
snapshot : string, snapshot : string,
[<CallerMemberName>] ?memberName : string, [<CallerMemberName>] ?memberName : string,
[<CallerLineNumber>] ?callerLine : int, [<CallerLineNumber>] ?callerLine : int,
[<CallerFilePath>] ?filePath : string [<CallerFilePath>] ?filePath : string
) )
: ExpectState<YouHaveSuppliedMultipleSnapshots> : ExpectState<'a>
= =
match state.Snapshot with match state.Snapshot with
| Some _ -> failwith "snapshot can only be specified once" | Some _ -> failwith "snapshot can only be specified once"
@@ -111,7 +154,41 @@ type ExpectBuilder (mode : Mode) =
} }
{ {
Snapshot = Some (SnapshotValue.BareString snapshot, callerInfo) Formatter = state.Formatter
JsonSerialiserOptions = None
JsonDocOptions = None
Snapshot = Some (SnapshotValue.Formatted snapshot, callerInfo)
Actual = None
}
/// <summary>Express that the actual value's <c>ToString</c> should identically equal this string.</summary>
[<CustomOperation("snapshot", MaintainsVariableSpaceUsingBind = true)>]
member _.Snapshot<'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
}
{ state with
Snapshot = Some (SnapshotValue.Formatted snapshot, callerInfo)
Actual = None Actual = None
} }
@@ -120,18 +197,18 @@ type ExpectBuilder (mode : Mode) =
/// which matches the JSON document that is this string. /// which matches the JSON document that is this string.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// For example, <c>snapshot "123"</c> indicates the JSON integer 123. /// For example, <c>snapshotJson "123"</c> indicates the JSON integer 123.
/// </remarks> /// </remarks>
[<CustomOperation("snapshotJson", MaintainsVariableSpaceUsingBind = true)>] [<CustomOperation("snapshotJson", MaintainsVariableSpaceUsingBind = true)>]
member _.SnapshotJson member this.SnapshotJson<'a>
( (
state : ExpectState<unit>, state : ExpectStateListy<unit>,
snapshot : string, snapshot : string,
[<CallerMemberName>] ?memberName : string, [<CallerMemberName>] ?memberName : string,
[<CallerLineNumber>] ?callerLine : int, [<CallerLineNumber>] ?callerLine : int,
[<CallerFilePath>] ?filePath : string [<CallerFilePath>] ?filePath : string
) )
: ExpectState<YouHaveSuppliedMultipleSnapshots> : ExpectState<'a>
= =
match state.Snapshot with match state.Snapshot with
| Some _ -> failwith "snapshot can only be specified once" | Some _ -> failwith "snapshot can only be specified once"
@@ -148,94 +225,433 @@ type ExpectBuilder (mode : Mode) =
} }
{ {
Formatter = None
JsonSerialiserOptions = None
JsonDocOptions = None
Snapshot = Some (SnapshotValue.Json snapshot, callerInfo) Snapshot = Some (SnapshotValue.Json snapshot, callerInfo)
Actual = None Actual = None
} }
/// MaintainsVariableSpaceUsingBind causes this to be used; it's a dummy representing "no snapshot and no assertion". /// <summary>
member _.Return (() : unit) : ExpectState<'T> = /// Express that the actual value, when converted to JSON, should result in a JSON document
/// which matches the JSON document that is this string.
/// </summary>
/// <remarks>
/// For example, <c>snapshotJson "123"</c> indicates the JSON integer 123.
/// </remarks>
[<CustomOperation("snapshotJson", MaintainsVariableSpaceUsingBind = true)>]
member _.SnapshotJson<'a>
(
state : ExpectState<unit>,
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.Json snapshot, callerInfo)
Actual = None
}
/// <summary>
/// Express that the actual value, which is a sequence, should have elements which individually (in order) match
/// this snapshot list.
/// </summary>
/// <remarks>
/// For example, <c>snapshotList ["123" ; "456"]</c> indicates an exactly-two-element list <c>[123 ; 456]</c>.
/// </remarks>
[<CustomOperation("snapshotList", MaintainsVariableSpaceUsingBind = true)>]
member _.SnapshotList<'a>
(
state : ExpectStateListy<unit>,
snapshot : string list,
[<CallerMemberName>] ?memberName : string,
[<CallerLineNumber>] ?callerLine : int,
[<CallerFilePath>] ?filePath : string
)
: ExpectStateListy<'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
Snapshot = Some (snapshot, callerInfo)
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>
/// 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 : ExpectStateListy<'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 = None
JsonDocOptions = None
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.
/// </summary>
/// <remarks>
/// For example, <c>withFormat (fun x -> x.ToString ()) "123"</c> is equivalent to <c>snapshot "123"</c>.
/// </remarks>
[<CustomOperation("withFormat", MaintainsVariableSpaceUsingBind = true)>]
member _.WithFormat<'T> (state : ExpectState<'T>, formatter : 'T -> string) =
match state.Formatter with
| Some _ -> failwith "Please don't supply withFormat more than once"
| None ->
{ state with
Formatter = Some (fun f -> f () |> formatter)
}
/// <summary>
/// Express that the <c>return</c> value of this builder should be formatted using this function, before
/// comparing to the snapshot.
/// In the case of <c>snapshotList</c>, this applies to the elements of the sequence, not to the sequence itself.
/// </summary>
/// <remarks>
/// For example, <c>withFormat (fun x -> x.ToString ()) "123"</c> is equivalent to <c>snapshot "123"</c>.
/// </remarks>
[<CustomOperation("withFormat", MaintainsVariableSpaceUsingBind = true)>]
member _.WithFormat<'T> (state : ExpectStateListy<'T>, formatter : 'T -> string) =
match state.Formatter with
| Some _ -> failwith "Please don't supply withFormat more than once"
| None ->
{ state with
Formatter = Some (fun f -> f () |> formatter)
}
/// <summary>
/// Express that these JsonSerializerOptions should be used to construct the JSON object to which the snapshot
/// is to be compared (or, in write-out-the-snapshot mode, to construct the JSON object to be written out).
/// </summary>
/// <example>
/// If you want your snapshots to be written out compactly, rather than the default indenting:
/// <code>
/// expect {
/// snapshotJson @"{""a"":3}"
/// withJsonSerializerOptions (JsonSerializerOptions (WriteIndented = false))
/// return Map.ofList ["a", 3]
/// }
/// </code>
/// </example>
[<CustomOperation("withJsonSerializerOptions", MaintainsVariableSpaceUsingBind = true)>]
member _.WithJsonSerializerOptions<'T> (state : ExpectState<'T>, jsonOptions : JsonSerializerOptions) =
match state.JsonSerialiserOptions with
| Some _ -> failwith "Please don't supply withJsonSerializerOptions more than once"
| None ->
{ state with
JsonSerialiserOptions = Some jsonOptions
}
/// <summary>
/// Express that these JsonSerializerOptions should be used to construct the JSON object to which the snapshot
/// is to be compared (or, in write-out-the-snapshot mode, to construct the JSON object to be written out).
/// </summary>
/// <example>
/// If you want your snapshots to be written out compactly, rather than the default indenting:
/// <code>
/// expect {
/// snapshotJson @"{""a"":3}"
/// withJsonSerializerOptions (JsonSerializerOptions (WriteIndented = false))
/// return Map.ofList ["a", 3]
/// }
/// </code>
/// </example>
[<CustomOperation("withJsonSerializerOptions", MaintainsVariableSpaceUsingBind = true)>]
member _.WithJsonSerializerOptions<'T> (state : ExpectStateListy<'T>, jsonOptions : JsonSerializerOptions) =
match state.Snapshot with
| Some _ ->
failwith
"I don't know how you've managed to do this; please raise an issue against github.com/Smaug123/WoofWare.Expect . Somehow withJsonSerializerOptions has already got a snapshot."
| None ->
match state.Actual with
| Some _ ->
failwith
"I don't know how you've managed to do this; please raise an issue against github.com/Smaug123/WoofWare.Expect . Somehow withJsonSerializerOptions has already got an expected-value."
| None ->
{ {
Formatter = state.Formatter
JsonSerialiserOptions = Some jsonOptions
JsonDocOptions = None
Snapshot = None
Actual = None
}
/// <summary>
/// Express that these JsonDocumentOptions should be used when parsing the snapshot string into a JSON object.
/// </summary>
/// <remarks>
/// For example, you might use this if you want your snapshot to contain comments;
/// the default JSON document parser will instead throw on comments, causing the snapshot instantly to fail to match.
/// </remarks>
/// <example>
/// <code>
/// expect {
/// snapshotJson
/// @"{
/// // a key here
/// ""a"":3
/// }"
///
/// withJsonDocOptions (JsonDocumentOptions (CommentHandling = JsonCommentHandling.Skip))
/// return Map.ofList [ "a", 3 ]
/// }
/// </code>
/// </example>
[<CustomOperation("withJsonDocOptions", MaintainsVariableSpaceUsingBind = true)>]
member _.WithJsonDocOptions<'T> (state : ExpectState<'T>, jsonOptions : JsonDocumentOptions) =
match state.JsonDocOptions with
| Some _ -> failwith "Please don't supply withJsonDocOptions more than once"
| None ->
{ state with
JsonDocOptions = Some jsonOptions
}
/// MaintainsVariableSpaceUsingBind causes this to be used; it's a dummy representing "no snapshot and no assertion".
member _.Return (() : unit) : ExpectStateListy<'T> =
{
Formatter = None
Snapshot = None Snapshot = None
Actual = None Actual = None
} }
/// Expresses the "actual value" component of the assertion "expected snapshot = actual value". /// Expresses the "actual value" component of the assertion "expected snapshot = actual value".
member _.Return (value : 'T) : ExpectState<'T> = member _.Return<'T> (value : 'T) : ExpectState<'T> =
{ {
Snapshot = None Snapshot = None
Formatter = None
Actual = Some (fun () -> value)
JsonSerialiserOptions = None
JsonDocOptions = None
}
/// 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
JsonDocOptions = None
JsonSerialiserOptions = None
Actual = Some value Actual = Some value
} }
/// Computation expression `Delay`. /// Computation expression `Delay`.
member _.Delay (f : unit -> ExpectState<'T>) : unit -> ExpectState<'T> = f member _.Delay (f : unit -> ExpectState<'T>) : unit -> ExpectState<'T> = f
/// Computation expression `Delay`.
member _.Delay (f : unit -> ExpectStateListy<'T>) : unit -> ExpectStateListy<'T> = f
/// Computation expression `Run`, which runs a `Delay`ed snapshot assertion, throwing if the assertion fails.
member _.Run (f : unit -> ExpectStateListy<'T>) : unit =
let state = f () |> CompletedListSnapshotGeneric.make
let lines = lazy File.ReadAllLines state.Caller.FilePath
let listSource =
lazy
AstWalker.findSnapshotList
state.Caller.FilePath
(lines.Force ())
state.Caller.LineNumber
state.Caller.MemberName
let raiseError expected actual =
let diff =
Diff.patienceLines (Array.ofList expected) (Array.ofList actual) |> Diff.format
match mode with
| Mode.Assert ->
if GlobalBuilderConfig.isBulkUpdateMode () then
GlobalBuilderConfig.registerTest (CompletedSnapshot.makeFromAst (listSource.Force ()) state)
else
$"snapshot mismatch! snapshot at %s{state.Caller.FilePath}:%i{state.Caller.LineNumber} (%s{state.Caller.MemberName}) diff:\n%s{diff}"
|> ExpectException
|> raise
| Mode.AssertMockingSource (mockSource, line) ->
$"snapshot mismatch! snapshot at %s{mockSource}:%i{line} (%s{state.Caller.MemberName}) diff:\n%s{diff}"
|> ExpectException
|> raise
| Mode.Update ->
let lines = lines.Force ()
let listSource = listSource.Force ()
let result = SnapshotUpdate.updateAtLocation listSource lines actual
File.writeAllLines result state.Caller.FilePath
failwith ("Snapshot successfully updated. Previous contents:\n" + String.concat "\n" lines)
match CompletedListSnapshotGeneric.passesAssertion state with
| None ->
match mode, GlobalBuilderConfig.isBulkUpdateMode () with
| Mode.Update, _
| _, 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."
| _ -> ()
| Some (expected, actual) -> raiseError expected actual
/// Computation expression `Run`, which runs a `Delay`ed snapshot assertion, throwing if the assertion fails. /// Computation expression `Run`, which runs a `Delay`ed snapshot assertion, throwing if the assertion fails.
member _.Run (f : unit -> ExpectState<'T>) : unit = member _.Run (f : unit -> ExpectState<'T>) : unit =
let state = f () let state = f () |> CompletedSnapshotGeneric.make
let options = JsonFSharpOptions.Default().ToJsonSerializerOptions () 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) diff:\n\n%s"
mockSource
line
state.Caller.MemberName
(Diff.format diff)
|> ExpectException
|> raise
| Mode.Assert ->
if GlobalBuilderConfig.isBulkUpdateMode () then
GlobalBuilderConfig.registerTest (CompletedSnapshot.makeGuess state)
else
let diff = Diff.patience snapshot actual
match state.Snapshot, state.Actual with
| Some (snapshot, source), Some actual ->
let raiseError (snapshot : string) (actual : string) : unit =
match mode with
| Mode.AssertMockingSource (mockSource, line) ->
sprintf 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 state.Caller.FilePath
line state.Caller.LineNumber
source.MemberName state.Caller.MemberName
(snapshot |> Text.predent '-') (Diff.format diff)
(actual |> Text.predent '+')
|> ExpectException |> ExpectException
|> raise |> raise
| Mode.Assert -> | Mode.Update ->
sprintf let lines = File.ReadAllLines state.Caller.FilePath
"snapshot mismatch! snapshot at %s:%i (%s) was:\n\n%s\n\nactual was:\n\n%s" let oldContents = String.concat "\n" lines
source.FilePath let lines = SnapshotUpdate.updateSnapshotAtLine lines state.Caller.LineNumber actual
source.LineNumber File.writeAllLines lines state.Caller.FilePath
source.MemberName failwith ("Snapshot successfully updated. Previous contents:\n" + oldContents)
(snapshot |> Text.predent '-')
(actual |> Text.predent '+')
|> ExpectException
|> raise
| Mode.Update ->
let lines = File.ReadAllLines source.FilePath
let oldContents = String.concat "\n" lines
let lines = SnapshotUpdate.updateSnapshotAtLine lines source.LineNumber actual
File.WriteAllLines (source.FilePath, lines)
failwith ("Snapshot successfully updated. Previous contents:\n" + oldContents)
match snapshot with match CompletedSnapshotGeneric.passesAssertion state with
| SnapshotValue.Json snapshot -> | None ->
let canonicalSnapshot = JsonDocument.Parse snapshot match mode, GlobalBuilderConfig.isBulkUpdateMode () with
| Mode.Update, _
let canonicalActual = | _, true ->
JsonSerializer.Serialize (actual, options) |> JsonDocument.Parse 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."
if not (JsonElement.DeepEquals (canonicalActual.RootElement, canonicalSnapshot.RootElement)) then | _ -> ()
raiseError (canonicalSnapshot.RootElement.ToString ()) (canonicalActual.RootElement.ToString ()) | Some (expected, actual) -> raiseError expected actual
else
match mode with
| Mode.Update ->
failwith
"Snapshot assertion passed, but we are in snapshot-updating mode. Use the `expect` builder instead of `expect'` to assert the contents of a snapshot."
| _ -> ()
| SnapshotValue.BareString snapshot ->
let actual = actual.ToString ()
if actual <> snapshot then
raiseError snapshot actual
else
match mode with
| Mode.Update ->
failwith
"Snapshot assertion passed, but we are in snapshot-updating mode. Use the `expect` builder instead of `expect'` to assert the contents of a snapshot."
| _ -> ()
| None, _ -> failwith "Must specify snapshot"
| _, None -> failwith "Must specify actual value with 'return'"
/// Module containing the `expect` builder. /// Module containing the `expect` builder.
[<AutoOpen>] [<AutoOpen>]

70
WoofWare.Expect/Config.fs Normal file
View File

@@ -0,0 +1,70 @@
namespace WoofWare.Expect
/// Module holding global mutable state controlling the behaviour of WoofWare.Expect
/// when running in bulk-update mode.
[<RequireQualifiedAccess>]
module GlobalBuilderConfig =
/// 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.
/// </summary>
/// <remarks>
/// We *strongly* recommend making test fixtures <c>Parallelizable(ParallelScope.Children)</c> or less parallelizable (for NUnit) if you're running in bulk update mode.
/// The implied global mutable state is liable to interfere with other expect builders in other fixtures otherwise.
/// </remarks>
let enterBulkUpdateMode () =
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."
bulkUpdate.Value <- bulkUpdate.Value + 1
)
/// <summary>
/// Clear the set of failing tests registered by any previous bulk-update runs.
/// </summary>
///
/// <remarks>
/// 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 locker allTests.Clear
let internal registerTest (toAdd : CompletedSnapshot) : unit =
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 () =
// 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
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
View 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
}

204
WoofWare.Expect/Domain.fs Normal file
View File

@@ -0,0 +1,204 @@
namespace WoofWare.Expect
open System.Collections
open System.Text.Json
open System.Text.Json.Serialization
/// <summary>
/// Information about where in source code a specific snapshot is located.
/// </summary>
type CallerInfo =
internal
{
MemberName : string
FilePath : string
LineNumber : int
}
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 : ((unit -> 'T) -> string)
/// The state accumulated by the `expect` builder. You should never find yourself interacting with this type.
type ExpectState<'T> =
private
{
Formatter : ((unit -> 'T) -> string) option
JsonSerialiserOptions : JsonSerializerOptions option
JsonDocOptions : JsonDocumentOptions option
Snapshot : (SnapshotValue * CallerInfo) option
Actual : (unit -> 'T) option
}
/// The state accumulated by the `expect` builder. You should never find yourself interacting with this type.
type ExpectStateListy<'T> =
private
{
Formatter : ((unit -> 'T) -> string) option
Snapshot : (string list * CallerInfo) option
Actual : (unit -> 'T seq) option
}
/// The state accumulated by the `expect` builder. You should never find yourself interacting with this type.
type internal CompletedSnapshotGeneric<'T> =
private
{
SnapshotValue : CompletedSnapshotValue<'T>
Caller : CallerInfo
Actual : unit -> 'T
}
[<RequireQualifiedAccess>]
module internal CompletedSnapshotGeneric =
let private defaultJsonSerialiserOptions : JsonSerializerOptions =
let options = JsonFSharpOptions.Default().ToJsonSerializerOptions ()
options.AllowTrailingCommas <- true
options.WriteIndented <- true
options
let private defaultJsonDocOptions : JsonDocumentOptions =
let options = JsonDocumentOptions (AllowTrailingCommas = true)
options
let make (state : ExpectState<'T>) : CompletedSnapshotGeneric<'T> =
match state.Snapshot, state.Actual with
| Some (snapshot, source), Some actual ->
let snapshot =
match snapshot with
| SnapshotValue.Json expected ->
let serOpts =
state.JsonSerialiserOptions |> Option.defaultValue defaultJsonSerialiserOptions
let docOpts = state.JsonDocOptions |> Option.defaultValue defaultJsonDocOptions
CompletedSnapshotValue.Json (expected, serOpts, docOpts)
| SnapshotValue.Formatted expected ->
let formatter =
match state.Formatter with
| 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
Actual = actual
}
| None, _ -> failwith "Must specify snapshot"
| _, None -> failwith "Must specify actual value with 'return'"
let internal replacement (s : CompletedSnapshotGeneric<'T>) =
match s.SnapshotValue with
| CompletedSnapshotValue.Json (_existing, options, _) ->
JsonSerializer.Serialize (s.Actual (), options)
|> JsonDocument.Parse
|> _.RootElement
|> _.ToString()
| CompletedSnapshotValue.Formatted (_existing, f) -> f s.Actual
/// Returns None if the assertion passes, or Some (expected, actual) if the assertion fails.
let internal passesAssertion (state : CompletedSnapshotGeneric<'T>) : (string * string) option =
match state.SnapshotValue with
| CompletedSnapshotValue.Formatted (snapshot, f) ->
let actual = f state.Actual
if actual = snapshot then None else Some (snapshot, actual)
| CompletedSnapshotValue.Json (snapshot, jsonSerOptions, jsonDocOptions) ->
let canonicalSnapshot =
try
JsonDocument.Parse (snapshot, jsonDocOptions) |> Some
with _ ->
None
let canonicalActual =
JsonSerializer.Serialize (state.Actual (), jsonSerOptions) |> JsonDocument.Parse
match canonicalSnapshot with
| None -> Some ("[JSON failed to parse:] " + snapshot, canonicalActual.RootElement.ToString ())
| Some canonicalSnapshot ->
if not (JsonElement.DeepEquals (canonicalActual.RootElement, canonicalSnapshot.RootElement)) then
Some (canonicalSnapshot.RootElement.ToString (), canonicalActual.RootElement.ToString ())
else
None
type internal CompletedListSnapshotGeneric<'elt> =
private
{
Expected : string list
Format : 'elt -> string
Caller : CallerInfo
Actual : unit -> 'elt seq
}
[<RequireQualifiedAccess>]
module internal CompletedListSnapshotGeneric =
let replacement (s : CompletedListSnapshotGeneric<'T>) =
s.Actual () |> unbox<IEnumerable> |> Seq.cast |> Seq.map s.Format |> Seq.toList
/// Returns None if the assertion passes, or Some (expected, actual) if the assertion fails.
let internal passesAssertion (state : CompletedListSnapshotGeneric<'T>) : (string list * string list) option =
let actual =
state.Actual ()
|> unbox<IEnumerable>
|> Seq.cast
|> Seq.map state.Format
|> Seq.toList
if state.Expected <> actual then
Some (state.Expected, actual)
else
None
let make (state : ExpectStateListy<'elt>) : CompletedListSnapshotGeneric<'elt> =
match state.Actual with
| None -> failwith "expected an assertion, but got none"
| Some actual ->
match state.Snapshot with
| None -> failwith "expected a snapshotList, but got none"
| Some (snapshot, caller) ->
let formatter =
match state.Formatter with
| Some f -> fun x -> f (fun () -> x)
| None -> fun x -> x.ToString ()
{
Expected = snapshot
Format = formatter
Caller = caller
Actual = actual
}
/// Represents a snapshot test that has failed and is awaiting update or report to the user.
type internal CompletedSnapshot =
| GuessString of CallerInfo * replacement : string
| Known of CallerInfo * replacement : string list * SnapshotLocation
member this.CallerInfo =
match this with
| CompletedSnapshot.GuessString (c, _) -> c
| CompletedSnapshot.Known (c, _, _) -> c
[<RequireQualifiedAccess>]
module internal CompletedSnapshot =
let makeGuess (s : CompletedSnapshotGeneric<'T>) =
CompletedSnapshot.GuessString (s.Caller, CompletedSnapshotGeneric.replacement s)
let makeFromAst (source : SnapshotLocation) (s : CompletedListSnapshotGeneric<'elt>) =
CompletedSnapshot.Known (s.Caller, CompletedListSnapshotGeneric.replacement s, source)

91
WoofWare.Expect/Dot.fs Normal file
View 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
View 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 _ ->
()

View File

@@ -15,7 +15,7 @@ type private StringLiteralInfo =
override this.ToString () = override this.ToString () =
sprintf "%i:%i to %i:%i: %s" this.StartLine this.StartColumn this.EndLine this.EndColumn this.Content 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 Line : int
Column : int Column : int
@@ -28,8 +28,8 @@ module internal SnapshotUpdate =
let tripleQuote = "\"\"\"" let tripleQuote = "\"\"\""
/// Convert a string position to line/column /// Convert a string position to line/column
let private positionToLineColumn (text : string) (offset : int) : Position = let private positionToLineColumn (text : string) (offset : int) : SnapshotPosition =
let rec loop (line : int) (col : int) (totalOffset : int) (i : int) : Position = let rec loop (line : int) (col : int) (totalOffset : int) (i : int) : SnapshotPosition =
if i >= text.Length || totalOffset = offset then if i >= text.Length || totalOffset = offset then
{ {
Line = line Line = line
@@ -141,13 +141,14 @@ module internal SnapshotUpdate =
else else
// We need to include enough lines to capture multi-line strings // We need to include enough lines to capture multi-line strings
// Take a reasonable number of lines after the snapshot line // Take a reasonable number of lines after the snapshot line
let maxLines = min 50 (lines.Length - startIdx) let maxLines = lines.Length - startIdx
let relevantLines = lines |> Array.skip startIdx |> Array.take maxLines let relevantLines = lines |> Array.skip startIdx |> Array.take maxLines
let searchText = String.concat "\n" relevantLines let searchText = String.concat "\n" relevantLines
// Find snapshot keyword // 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 if not snapshotMatch.Success then
None None
@@ -202,11 +203,22 @@ module internal SnapshotUpdate =
) )
) )
let internal stringLiteral (content : string) =
if
(content.IndexOf '\n' < 0)
&& (content.IndexOf '\\' < 0)
&& (content.IndexOf '"' < 0)
then
// simple case where there's no escaping
"\"" + content + "\""
else
"@\"" + content.Replace ("\"", "\"\"") + "\""
/// Update the snapshot string with a new value; this doesn't edit the file on disk, but /// Update the snapshot string with a new value; this doesn't edit the file on disk, but
/// instead returns the new contents. /// instead returns the new contents.
/// We always write single-quoted @-strings for simplicity. /// We always write single-quoted @-strings for simplicity.
let private updateSnapshot (lines : string[]) (info : StringLiteralInfo) (newContent : string) : string[] = let private updateSnapshot (lines : string[]) (info : StringLiteralInfo) (newContent : string) : string[] =
let newString = "@\"" + newContent.Replace ("\"", "\"\"") + "\"" let newString = stringLiteral newContent
if info.StartLine = info.EndLine then if info.StartLine = info.EndLine then
// Single line update // Single line update
@@ -229,7 +241,7 @@ module internal SnapshotUpdate =
let newLines = let newLines =
if newContent.IndexOf '\n' >= 0 then if newContent.IndexOf '\n' >= 0 then
let split = newContent.Replace("\"", "\"\"").Split ('\n') let split = newContent.Replace("\"", "\"\"").Split '\n'
match split with match split with
| [||] -> failwith "expected contents from split string" | [||] -> failwith "expected contents from split string"
@@ -254,15 +266,19 @@ module internal SnapshotUpdate =
/// <remarks>Example usage: /// <remarks>Example usage:
/// <c>updateSnapshotAtLine [|lines-of-file|] 42 "new test output"</c> /// <c>updateSnapshotAtLine [|lines-of-file|] 42 "new test output"</c>
/// /// <br />
/// This will find a snapshot call on line 42 like: /// This will find a snapshot call on line 42 like:
/// snapshot "old value" -> snapshot @"new test output" /// <ul>
/// snapshot @"old value" -> snapshot @"new test output" /// <li><c>snapshot "old value"</c> -> <c>snapshot @"new test output"</c></li>
/// snapshot """old value""" -> snapshot @"new test output" /// <li><c>snapshot @"old value"</c> -> <c>snapshot @"new test output"</c></li>
/// snapshot """multi /// <li><c>snapshot """old value"""</c> -> <c>snapshot @"new test output"</c></li>
/// line""" -> snapshot """multi /// <li><c>snapshot "has \"\"\" in it"</c> -> <c>snapshot @"has """""" in it"</c></li>
/// line""" /// <li>
/// snapshot "has \"\"\" in it" -> snapshot @"has """""" in it" /// <code>snapshot """multi
/// line"""</code> -> <code>snapshot """multi
/// line"""</code>
/// </li>
/// </ul>
/// </remarks> /// </remarks>
let updateSnapshotAtLine (fileLines : string[]) (snapshotLine : int) (newValue : string) : string[] = let updateSnapshotAtLine (fileLines : string[]) (snapshotLine : int) (newValue : string) : string[] =
match findSnapshotString fileLines snapshotLine with match findSnapshotString fileLines snapshotLine with
@@ -270,3 +286,61 @@ module internal SnapshotUpdate =
Console.Error.WriteLine ("String literal to update: " + string<StringLiteralInfo> info) Console.Error.WriteLine ("String literal to update: " + string<StringLiteralInfo> info)
updateSnapshot fileLines info newValue updateSnapshot fileLines info newValue
| None -> failwithf "Could not find string literal after snapshot at line %d" snapshotLine | None -> failwithf "Could not find string literal after snapshot at line %d" snapshotLine
/// <summary>
/// Bulk-apply all the snapshot replacements.
/// </summary>
/// <param name="fileLines">The original file contents, as an array of lines.</param>
/// <param name="sources">The (unsorted) line numbers of the snapshots which need to be replaced, and the replacement value for each.</param>
/// <returns>The entire desired new contents of the file, as an array of lines.</returns>
let private updateAllLines (fileLines : string[]) (sources : (int * string) seq) : string[] =
sources
|> Seq.sortByDescending fst
|> Seq.fold (fun lines (lineNum, replacement) -> updateSnapshotAtLine lines lineNum replacement) fileLines
let internal updateAtLocation (source : SnapshotLocation) (lines : string[]) (actual : string seq) =
let indent = String.replicate source.KeywordRange.StartColumn " "
[|
// Range's lines are one-indexed!
lines.[0 .. source.KeywordRange.EndLine - 2]
[|
lines.[source.KeywordRange.EndLine - 1].Substring (0, source.KeywordRange.EndColumn)
+ " ["
|]
actual |> Seq.map (fun s -> indent + " " + stringLiteral s) |> Array.ofSeq
[|
indent
+ "]"
+ lines.[source.ReplacementRange.EndLine - 1].Substring source.ReplacementRange.EndColumn
|]
lines.[source.ReplacementRange.EndLine ..]
|]
|> Array.concat
/// <summary>
/// Update every failed snapshot in the input, editing the files on disk.
/// </summary>
let updateAll (sources : CompletedSnapshot seq) : unit =
sources
|> Seq.groupBy (fun csc -> csc.CallerInfo.FilePath)
|> Seq.iter (fun (callerFile, callers) ->
let contents = System.IO.File.ReadAllLines callerFile
let newContents =
callers
|> Seq.map (fun csc -> csc.CallerInfo.LineNumber, csc)
|> Seq.sortByDescending fst
|> Seq.fold
(fun lines (lineNum, replacement) ->
match replacement with
| CompletedSnapshot.GuessString (_, replacement) ->
updateSnapshotAtLine lines lineNum replacement
| CompletedSnapshot.Known (_, replacement, snapshotLocation) ->
updateAtLocation snapshotLocation lines replacement
)
contents
File.writeAllLines newContents callerFile
)

View File

@@ -4,26 +4,108 @@ WoofWare.Expect.Builder.expect' [static property]: [read-only] WoofWare.Expect.E
WoofWare.Expect.Builder.expectWithMockedFilePath [static method]: (string, int) -> WoofWare.Expect.ExpectBuilder WoofWare.Expect.Builder.expectWithMockedFilePath [static method]: (string, int) -> WoofWare.Expect.ExpectBuilder
WoofWare.Expect.Builder.get_expect [static method]: unit -> WoofWare.Expect.ExpectBuilder WoofWare.Expect.Builder.get_expect [static method]: unit -> WoofWare.Expect.ExpectBuilder
WoofWare.Expect.Builder.get_expect' [static method]: unit -> WoofWare.Expect.ExpectBuilder WoofWare.Expect.Builder.get_expect' [static method]: unit -> WoofWare.Expect.ExpectBuilder
WoofWare.Expect.CallerInfo inherit obj, implements WoofWare.Expect.CallerInfo System.IEquatable, System.Collections.IStructuralEquatable, WoofWare.Expect.CallerInfo System.IComparable, System.IComparable, System.Collections.IStructuralComparable
WoofWare.Expect.CallerInfo.Equals [method]: (WoofWare.Expect.CallerInfo, 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 inherit obj
WoofWare.Expect.ExpectBuilder..ctor [constructor]: (string * int) WoofWare.Expect.ExpectBuilder..ctor [constructor]: (string * int)
WoofWare.Expect.ExpectBuilder..ctor [constructor]: bool WoofWare.Expect.ExpectBuilder..ctor [constructor]: bool
WoofWare.Expect.ExpectBuilder..ctor [constructor]: WoofWare.Expect.Mode WoofWare.Expect.ExpectBuilder..ctor [constructor]: WoofWare.Expect.Mode
WoofWare.Expect.ExpectBuilder.Bind [method]: (WoofWare.Expect.YouHaveSuppliedMultipleSnapshots WoofWare.Expect.ExpectState, unit -> 'U WoofWare.Expect.ExpectState) -> 'U WoofWare.Expect.ExpectState WoofWare.Expect.ExpectBuilder.Bind [method]: ('elt WoofWare.Expect.ExpectStateListy, unit -> #('elt seq) WoofWare.Expect.ExpectState) -> 'elt WoofWare.Expect.ExpectStateListy
WoofWare.Expect.ExpectBuilder.Bind [method]: ('U WoofWare.Expect.ExpectState, unit -> 'U WoofWare.Expect.ExpectState) -> 'U WoofWare.Expect.ExpectState
WoofWare.Expect.ExpectBuilder.Bind [method]: ('U WoofWare.Expect.ExpectStateListy, unit -> 'U WoofWare.Expect.ExpectStateListy) -> 'U WoofWare.Expect.ExpectStateListy
WoofWare.Expect.ExpectBuilder.Delay [method]: (unit -> 'T WoofWare.Expect.ExpectState) -> (unit -> 'T WoofWare.Expect.ExpectState) WoofWare.Expect.ExpectBuilder.Delay [method]: (unit -> 'T WoofWare.Expect.ExpectState) -> (unit -> 'T WoofWare.Expect.ExpectState)
WoofWare.Expect.ExpectBuilder.Delay [method]: (unit -> 'T WoofWare.Expect.ExpectStateListy) -> (unit -> 'T WoofWare.Expect.ExpectStateListy)
WoofWare.Expect.ExpectBuilder.Return [method]: 'T -> '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.Return [method]: unit -> 'T WoofWare.Expect.ExpectStateListy
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.Run [method]: (unit -> 'T WoofWare.Expect.ExpectState) -> unit
WoofWare.Expect.ExpectBuilder.Snapshot [method]: (unit WoofWare.Expect.ExpectState, string, string option, int option, string option) -> WoofWare.Expect.YouHaveSuppliedMultipleSnapshots WoofWare.Expect.ExpectState WoofWare.Expect.ExpectBuilder.Run [method]: (unit -> 'T WoofWare.Expect.ExpectStateListy) -> unit
WoofWare.Expect.ExpectBuilder.SnapshotJson [method]: (unit WoofWare.Expect.ExpectState, string, string option, int option, string option) -> WoofWare.Expect.YouHaveSuppliedMultipleSnapshots WoofWare.Expect.ExpectState WoofWare.Expect.ExpectBuilder.Snapshot [method]: ('a WoofWare.Expect.ExpectState, string, string option, int option, string option) -> 'a WoofWare.Expect.ExpectState
WoofWare.Expect.ExpectBuilder.Snapshot [method]: ('a WoofWare.Expect.ExpectStateListy, 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.SnapshotJson [method]: (unit WoofWare.Expect.ExpectStateListy, string, string option, int option, string option) -> 'a WoofWare.Expect.ExpectState
WoofWare.Expect.ExpectBuilder.SnapshotList [method]: (unit WoofWare.Expect.ExpectStateListy, string list, string option, int option, string option) -> 'a WoofWare.Expect.ExpectStateListy
WoofWare.Expect.ExpectBuilder.SnapshotThrows [method]: ('a WoofWare.Expect.ExpectState, string, string option, int option, string option) -> 'a WoofWare.Expect.ExpectState
WoofWare.Expect.ExpectBuilder.SnapshotThrows [method]: ('a WoofWare.Expect.ExpectStateListy, 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.WithFormat [method]: ('T WoofWare.Expect.ExpectStateListy, 'T -> string) -> 'T WoofWare.Expect.ExpectStateListy
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
WoofWare.Expect.ExpectBuilder.WithJsonSerializerOptions [method]: ('T WoofWare.Expect.ExpectStateListy, System.Text.Json.JsonSerializerOptions) -> 'T WoofWare.Expect.ExpectState
WoofWare.Expect.ExpectException inherit System.Exception, implements System.Collections.IStructuralEquatable WoofWare.Expect.ExpectException inherit System.Exception, implements System.Collections.IStructuralEquatable
WoofWare.Expect.ExpectException..ctor [constructor]: string WoofWare.Expect.ExpectException..ctor [constructor]: string
WoofWare.Expect.ExpectException..ctor [constructor]: unit WoofWare.Expect.ExpectException..ctor [constructor]: unit
WoofWare.Expect.ExpectException.Equals [method]: (System.Exception, System.Collections.IEqualityComparer) -> bool WoofWare.Expect.ExpectException.Equals [method]: (System.Exception, System.Collections.IEqualityComparer) -> bool
WoofWare.Expect.ExpectException.Equals [method]: System.Exception -> bool WoofWare.Expect.ExpectException.Equals [method]: System.Exception -> bool
WoofWare.Expect.ExpectException.Message [property]: [read-only] string WoofWare.Expect.ExpectException.Message [property]: [read-only] string
WoofWare.Expect.ExpectState`1 inherit obj, implements 'T WoofWare.Expect.ExpectState System.IEquatable, System.Collections.IStructuralEquatable, 'T WoofWare.Expect.ExpectState System.IComparable, System.IComparable, System.Collections.IStructuralComparable WoofWare.Expect.ExpectState`1 inherit obj
WoofWare.Expect.ExpectState`1.Equals [method]: ('T WoofWare.Expect.ExpectState, System.Collections.IEqualityComparer) -> bool WoofWare.Expect.ExpectStateListy`1 inherit obj
WoofWare.Expect.GlobalBuilderConfig inherit obj
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 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.YouHaveSuppliedMultipleSnapshots inherit obj, implements WoofWare.Expect.YouHaveSuppliedMultipleSnapshots System.IEquatable, System.Collections.IStructuralEquatable, WoofWare.Expect.YouHaveSuppliedMultipleSnapshots System.IComparable, System.IComparable, System.Collections.IStructuralComparable WoofWare.Expect.pos inherit obj
WoofWare.Expect.YouHaveSuppliedMultipleSnapshots.Equals [method]: (WoofWare.Expect.YouHaveSuppliedMultipleSnapshots, System.Collections.IEqualityComparer) -> bool

6
WoofWare.Expect/Text.fs Normal file
View File

@@ -0,0 +1,6 @@
namespace WoofWare.Expect
[<RequireQualifiedAccess>]
module internal Text =
let predent (c : char) (s : string) =
s.Split '\n' |> Seq.map (sprintf "%c %s" c) |> String.concat "\n"

View File

@@ -17,7 +17,14 @@
<ItemGroup> <ItemGroup>
<Compile Include="AssemblyInfo.fs" /> <Compile Include="AssemblyInfo.fs" />
<Compile Include="Text.fs" />
<Compile Include="File.fs" />
<Compile Include="Diff.fs" />
<Compile Include="Dot.fs" />
<Compile Include="AstWalker.fs" />
<Compile Include="Domain.fs" />
<Compile Include="SnapshotUpdate.fs" /> <Compile Include="SnapshotUpdate.fs" />
<Compile Include="Config.fs" />
<Compile Include="Builder.fs" /> <Compile Include="Builder.fs" />
<None Include="..\README.md"> <None Include="..\README.md">
<Pack>True</Pack> <Pack>True</Pack>
@@ -33,8 +40,9 @@
<ItemGroup> <ItemGroup>
<!-- FSharp.SystemTextJson requires at least this version --> <!-- FSharp.SystemTextJson requires at least this version -->
<PackageReference Update="FSharp.Core" Version="4.7.0" /> <PackageReference Update="FSharp.Core" Version="8.0.100" />
<PackageReference Include="FSharp.SystemTextJson" Version="1.4.36" /> <PackageReference Include="FSharp.SystemTextJson" Version="1.4.36" />
<PackageReference Include="Fantomas.FCS" Version="7.0.3" />
<!-- Needed for DeepEquals --> <!-- Needed for DeepEquals -->
<PackageReference Include="System.Text.Json" Version="9.0.0" /> <PackageReference Include="System.Text.Json" Version="9.0.0" />
</ItemGroup> </ItemGroup>

View File

@@ -1,5 +1,5 @@
{ {
"version": "0.2", "version": "0.8",
"publicReleaseRefSpec": [ "publicReleaseRefSpec": [
"^refs/heads/main$" "^refs/heads/main$"
], ],

View File

@@ -65,6 +65,8 @@
pkgs.alejandra pkgs.alejandra
pkgs.nodePackages.markdown-link-check pkgs.nodePackages.markdown-link-check
pkgs.shellcheck pkgs.shellcheck
pkgs.xmlstarlet
pkgs.graph-easy
]; ];
}; };
}); });

View File

@@ -9,6 +9,11 @@
"version": "7.0.2", "version": "7.0.2",
"hash": "sha256-BAaENIm/ksTiXrUImRgKoIXTGIlgsX7ch6ayoFjhJXA=" "hash": "sha256-BAaENIm/ksTiXrUImRgKoIXTGIlgsX7ch6ayoFjhJXA="
}, },
{
"pname": "Fantomas.FCS",
"version": "7.0.3",
"hash": "sha256-BmCUq+ZQ3b25nrMBTc5tcxdO2soryEjNx9Fn/FJpi1c="
},
{ {
"pname": "fsharp-analyzers", "pname": "fsharp-analyzers",
"version": "0.31.0", "version": "0.31.0",
@@ -16,8 +21,8 @@
}, },
{ {
"pname": "FSharp.Core", "pname": "FSharp.Core",
"version": "4.7.0", "version": "8.0.100",
"hash": "sha256-7aa4bga9XWLkq7J5KXv8Bilf1KGum77lSUqp+ooYIUg=" "hash": "sha256-FCjhq+W603ibz9XAA9iH5K6gJhX02/pMHyge6dHb4xs="
}, },
{ {
"pname": "FSharp.Core", "pname": "FSharp.Core",
@@ -59,11 +64,26 @@
"version": "1.1.0", "version": "1.1.0",
"hash": "sha256-FeM40ktcObQJk4nMYShB61H/E8B7tIKfl9ObJ0IOcCM=" "hash": "sha256-FeM40ktcObQJk4nMYShB61H/E8B7tIKfl9ObJ0IOcCM="
}, },
{
"pname": "Microsoft.NETCore.Platforms",
"version": "1.1.1",
"hash": "sha256-8hLiUKvy/YirCWlFwzdejD2Db3DaXhHxT7GSZx/znJg="
},
{ {
"pname": "Microsoft.NETCore.Platforms", "pname": "Microsoft.NETCore.Platforms",
"version": "2.0.0", "version": "2.0.0",
"hash": "sha256-IEvBk6wUXSdyCnkj6tHahOJv290tVVT8tyemYcR0Yro=" "hash": "sha256-IEvBk6wUXSdyCnkj6tHahOJv290tVVT8tyemYcR0Yro="
}, },
{
"pname": "Microsoft.NETCore.Targets",
"version": "1.1.0",
"hash": "sha256-0AqQ2gMS8iNlYkrD+BxtIg7cXMnr9xZHtKAuN4bjfaQ="
},
{
"pname": "Microsoft.NETCore.Targets",
"version": "1.1.3",
"hash": "sha256-WLsf1NuUfRWyr7C7Rl9jiua9jximnVvzy6nk2D2bVRc="
},
{ {
"pname": "Microsoft.Testing.Extensions.Telemetry", "pname": "Microsoft.Testing.Extensions.Telemetry",
"version": "1.5.3", "version": "1.5.3",
@@ -159,11 +179,31 @@
"version": "5.0.0", "version": "5.0.0",
"hash": "sha256-7jZM4qAbIzne3AcdFfMbvbgogqpxvVe6q2S7Ls8xQy0=" "hash": "sha256-7jZM4qAbIzne3AcdFfMbvbgogqpxvVe6q2S7Ls8xQy0="
}, },
{
"pname": "runtime.any.System.Runtime",
"version": "4.3.0",
"hash": "sha256-qwhNXBaJ1DtDkuRacgHwnZmOZ1u9q7N8j0cWOLYOELM="
},
{
"pname": "runtime.native.System",
"version": "4.3.0",
"hash": "sha256-ZBZaodnjvLXATWpXXakFgcy6P+gjhshFXmglrL5xD5Y="
},
{
"pname": "runtime.unix.System.Private.Uri",
"version": "4.3.0",
"hash": "sha256-c5tXWhE/fYbJVl9rXs0uHh3pTsg44YD1dJvyOA0WoMs="
},
{ {
"pname": "System.Buffers", "pname": "System.Buffers",
"version": "4.5.1", "version": "4.5.1",
"hash": "sha256-wws90sfi9M7kuCPWkv1CEYMJtCqx9QB/kj0ymlsNaxI=" "hash": "sha256-wws90sfi9M7kuCPWkv1CEYMJtCqx9QB/kj0ymlsNaxI="
}, },
{
"pname": "System.Buffers",
"version": "4.6.0",
"hash": "sha256-c2QlgFB16IlfBms5YLsTCFQ/QeKoS6ph1a9mdRkq/Jc="
},
{ {
"pname": "System.Collections.Immutable", "pname": "System.Collections.Immutable",
"version": "8.0.0", "version": "8.0.0",
@@ -174,6 +214,11 @@
"version": "5.0.0", "version": "5.0.0",
"hash": "sha256-6mW3N6FvcdNH/pB58pl+pFSCGWgyaP4hfVtC/SMWDV4=" "hash": "sha256-6mW3N6FvcdNH/pB58pl+pFSCGWgyaP4hfVtC/SMWDV4="
}, },
{
"pname": "System.Diagnostics.DiagnosticSource",
"version": "8.0.1",
"hash": "sha256-zmwHjcJgKcbkkwepH038QhcnsWMJcHys+PEbFGC0Jgo="
},
{ {
"pname": "System.Formats.Asn1", "pname": "System.Formats.Asn1",
"version": "6.0.0", "version": "6.0.0",
@@ -189,6 +234,11 @@
"version": "4.2.13", "version": "4.2.13",
"hash": "sha256-nkC/PiqE6+c1HJ2yTwg3x+qdBh844Z8n3ERWDW8k6Gg=" "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", "pname": "System.IO.FileSystem.AccessControl",
"version": "4.5.0", "version": "4.5.0",
@@ -204,16 +254,31 @@
"version": "4.5.5", "version": "4.5.5",
"hash": "sha256-EPQ9o1Kin7KzGI5O3U3PUQAZTItSbk9h/i4rViN3WiI=" "hash": "sha256-EPQ9o1Kin7KzGI5O3U3PUQAZTItSbk9h/i4rViN3WiI="
}, },
{
"pname": "System.Memory",
"version": "4.6.0",
"hash": "sha256-OhAEKzUM6eEaH99DcGaMz2pFLG/q/N4KVWqqiBYUOFo="
},
{ {
"pname": "System.Numerics.Vectors", "pname": "System.Numerics.Vectors",
"version": "4.4.0", "version": "4.6.0",
"hash": "sha256-auXQK2flL/JpnB/rEcAcUm4vYMCYMEMiWOCAlIaqu2U=" "hash": "sha256-fKS3uWQ2HmR69vNhDHqPLYNOt3qpjiWQOXZDHvRE1HU="
},
{
"pname": "System.Private.Uri",
"version": "4.3.0",
"hash": "sha256-fVfgcoP4AVN1E5wHZbKBIOPYZ/xBeSIdsNF+bdukIRM="
}, },
{ {
"pname": "System.Reflection.Metadata", "pname": "System.Reflection.Metadata",
"version": "8.0.0", "version": "8.0.0",
"hash": "sha256-dQGC30JauIDWNWXMrSNOJncVa1umR1sijazYwUDdSIE=" "hash": "sha256-dQGC30JauIDWNWXMrSNOJncVa1umR1sijazYwUDdSIE="
}, },
{
"pname": "System.Runtime",
"version": "4.3.1",
"hash": "sha256-R9T68AzS1PJJ7v6ARz9vo88pKL1dWqLOANg4pkQjkA0="
},
{ {
"pname": "System.Runtime.CompilerServices.Unsafe", "pname": "System.Runtime.CompilerServices.Unsafe",
"version": "4.5.3", "version": "4.5.3",
@@ -224,6 +289,11 @@
"version": "6.0.0", "version": "6.0.0",
"hash": "sha256-bEG1PnDp7uKYz/OgLOWs3RWwQSVYm+AnPwVmAmcgp2I=" "hash": "sha256-bEG1PnDp7uKYz/OgLOWs3RWwQSVYm+AnPwVmAmcgp2I="
}, },
{
"pname": "System.Runtime.CompilerServices.Unsafe",
"version": "6.1.0",
"hash": "sha256-NyqqpRcHumzSxpsgRDguD5SGwdUNHBbo0OOdzLTIzCU="
},
{ {
"pname": "System.Security.AccessControl", "pname": "System.Security.AccessControl",
"version": "4.5.0", "version": "4.5.0",