10 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
26 changed files with 1726 additions and 111 deletions

22
.envrc
View File

@@ -1 +1,23 @@
use flake
DOTNET_PATH=$(readlink "$(which dotnet)")
SETTINGS_FILE=$(find . -maxdepth 1 -type f -name '*.sln.DotSettings.user')
MSBUILD=$(realpath "$(find "$(dirname "$DOTNET_PATH")/../share/dotnet/sdk" -maxdepth 2 -type f -name MSBuild.dll)")
if [ -f "$SETTINGS_FILE" ] ; then
xmlstarlet ed --inplace \
-N wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation" \
-N x="http://schemas.microsoft.com/winfx/2006/xaml" \
-N s="clr-namespace:System;assembly=mscorlib" \
-N ss="urn:shemas-jetbrains-com:settings-storage-xaml" \
--update "//s:String[@x:Key='/Default/Environment/Hierarchy/Build/BuildTool/DotNetCliExePath/@EntryValue']" \
--value "$(realpath "$(dirname "$DOTNET_PATH")/../share/dotnet/dotnet")" \
"$SETTINGS_FILE"
xmlstarlet ed --inplace \
-N wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation" \
-N x="http://schemas.microsoft.com/winfx/2006/xaml" \
-N s="clr-namespace:System;assembly=mscorlib" \
-N ss="urn:shemas-jetbrains-com:settings-storage-xaml" \
--update "//s:String[@x:Key='/Default/Environment/Hierarchy/Build/BuildTool/CustomBuildToolPath/@EntryValue']" \
--value "$MSBUILD" \
"$SETTINGS_FILE"
fi

View File

@@ -16,8 +16,7 @@ An [expect-testing](https://blog.janestreet.com/the-joy-of-expect-tests/) librar
# Current status
The basic mechanism works.
Snapshot updating is vibe-coded with Opus 4 and is purely text-based; I didn't want to use the F# compiler services because that's a pretty heavyweight dependency which should be confined to a separate test runner entity.
It's not very well tested, and I expect it to be kind of brittle.
It's fairly well tested, but you will almost certainly be able to find ways to break it; try not to be too fancy with your syntax around the `snapshot` statement.
# How to use
@@ -44,8 +43,24 @@ let ``This test fails: plain text comparison of ToString`` () =
snapshot " 123 "
return 123
}
[<Test>]
let ``With return! and snapshotThrows, you can see exceptions too`` () =
expect {
snapshotThrows @"System.Exception: oh no"
return! (fun () -> failwith<int> "oh no")
}
[<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
@@ -57,6 +72,15 @@ let ``Overriding the formatting`` () =
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:
@@ -180,6 +204,11 @@ Observe the `OneTimeSetUp` which sets global state to enter "bulk update" mode,
* The snapshot updating mechanism *requires* you to use verbatim string literals. While the test assertions will work correctly if you do `snapshot ("foo" + "bar" + f 3)`, for example, the updating code is liable to do something undefined in that case. Also do not use format strings (`$"blah"`).
# Output formats
* The `Diff` module provides a Patience diff and a Myers diff implementation, which you can use to make certain tests much more readable.
* The `Dot` module provides `render`, which renders a dot file as ASCII art. You will need `graph-easy` to use this feature.
# Licence
MIT.

View File

@@ -19,12 +19,9 @@ module SimpleTest =
let ``Example of a failing test`` () =
expect {
snapshot
@"snapshot mismatch! snapshot at filepath.fs:99 (Example of a failing test) was:
@"snapshot mismatch! snapshot at filepath.fs:99 (Example of a failing test) diff:
- 123
actual was:
+ 124"
return
@@ -64,26 +61,21 @@ actual was:
// Out of the box, comments in snapshots cause the JSON parser to throw, so the snapshot fails to match...
expect {
snapshot
@"snapshot mismatch! snapshot at file.fs:99 (Custom JSON output) was:
@"snapshot mismatch! snapshot at file.fs:99 (Custom JSON output) diff:
- [JSON failed to parse:] {
- // a key here
- ""a"":3
- }
actual was:
- // a key here
+ {
+ ""a"": 3
+ }"
""a"": 3
}"
return
Assert.Throws<ExpectException> (fun () ->
expectWithMockedFilePath ("file.fs", 99) {
snapshotJson
@"{
// a key here
""a"":3
// a key here
""a"": 3
}"
return Map.ofList [ "a", 3 ]

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

@@ -34,7 +34,7 @@ module MyModule =
expect {
snapshot (* first comment *) (* second comment *)
(* third comment on new line *)
@""updated after many comments""
""updated after many comments""
return 123
}
@@ -100,7 +100,7 @@ module MyModule =
let nestedComments () =
expect {
snapshot (* outer (* inner *) comment *) @""updated after nested comments""
snapshot (* outer (* inner *) comment *) ""updated after nested comments""
return ""nested""
}
@@ -165,7 +165,7 @@ module MyModule =
let commentWithSpecialChars () =
expect {
snapshot (* comment with ""quotes"" and \ backslash *) @""updated after weird comment""
snapshot (* comment with ""quotes"" and \ backslash *) ""updated after weird comment""
return ""special""
}
@@ -233,7 +233,7 @@ module MyModule =
snapshot
@""updated after spaces""
""updated after spaces""
return ""whitespace""
}
@@ -301,7 +301,7 @@ module MyModule =
expect {
snapshotJson (* comment 1 *)
(* comment 2 *)
(* comment 3 *) @""updated after comments""
(* comment 3 *) ""updated after comments""
return 123
}

View File

@@ -31,7 +31,7 @@ open WoofWare.Expect
module MyModule =
let emptyString () =
expect {
snapshot @""now has content""
snapshot ""now has content""
return """"
}
@@ -121,7 +121,7 @@ module MyModule =
let emptyVerbatim () =
expect {
snapshot @""now has content""
snapshot ""now has content""
return """"
}
@@ -211,7 +211,7 @@ module MyModule =
let emptyTripleQuote () =
expect {
snapshot @""now has content""
snapshot ""now has content""
return """"
}
@@ -301,7 +301,7 @@ module MyModule =
let onlyWhitespace () =
expect {
snapshot @""now has content""
snapshot ""now has content""
return ""whitespace""
}
@@ -575,7 +575,7 @@ module MyModule =
let veryLongLine () =
expect {
snapshot
@""this line is short though""
""this line is short though""
return ""long line""
}

View File

@@ -472,7 +472,7 @@ Sixth line""
let windowsLineEndings () =
expect {
snapshot @""down with line endings""
snapshot ""down with line endings""
return ""crlf""
}
"

View File

@@ -32,7 +32,7 @@ open WoofWare.Expect
module MyModule =
let foo () =
expect {
snapshot @""replacement""
snapshot ""replacement""
return 123
}
"
@@ -81,7 +81,7 @@ open WoofWare.Expect
module MyModule =
let foo () =
expect {
snapshot @""replacement""
snapshot ""replacement""
return 123
}
"

View File

@@ -31,7 +31,7 @@ open WoofWare.Expect
module MyModule =
let emoji () =
expect {
snapshot @""Updated with 🚀🌟✨ more emoji!""
snapshot ""Updated with 🚀🌟✨ more emoji!""
return 123
}
@@ -172,7 +172,7 @@ module MyModule =
let arabicRTL () =
expect {
snapshot @""Updated Arabic: مرحبا بالعالم""
snapshot ""Updated Arabic: مرحبا بالعالم""
return ""rtl test""
}
@@ -241,7 +241,7 @@ module MyModule =
let combiningCharacters () =
expect {
// Combining diacritics: e + ́ = é
snapshot @""updated test with combining: and ä!""
snapshot ""updated test with combining: and ä!""
return ""combining""
}
@@ -309,7 +309,7 @@ module MyModule =
let mixedScripts () =
expect {
snapshotJson @""Updated mixed: English, русский, 日本語, العربية, emoji 🚀""
snapshotJson ""Updated mixed: English, русский, 日本語, العربية, emoji 🚀""
return [ ""multilingual"" ]
}
@@ -377,7 +377,7 @@ module MyModule =
let zeroWidthChars () =
expect {
snapshot @""Updated: Zerowidthspacetest"" // Contains U+200B
snapshot ""Updated: Zerowidthspacetest"" // Contains U+200B
return ""zwsp""
}
@@ -445,7 +445,7 @@ module MyModule =
let mathSymbols () =
expect {
snapshot @""Pretty vacuous, huh: ∀x∈, ∃y: + = 1 |x| 1""
snapshot ""Pretty vacuous, huh: ∀x∈, ∃y: + = 1 |x| 1""
return ""math""
}
"

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

@@ -13,6 +13,10 @@
<Compile Include="Assembly.fs" />
<Compile Include="BulkUpdateExample.fs" />
<Compile Include="SimpleTest.fs" />
<Compile Include="TestDiff.fs" />
<Compile Include="TestDot.fs" />
<Compile Include="TestExceptionThrowing.fs" />
<Compile Include="TestSnapshotList.fs" />
<Compile Include="TestSurface.fs" />
<Compile Include="TestSnapshotFinding\TestSnapshotFinding.fs" />
<Compile Include="TestSnapshotFinding\TestUnicodeCharacters.fs" />
@@ -38,6 +42,9 @@
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1"/>
<PackageReference Include="NUnit" Version="4.3.2"/>
<PackageReference Include="NUnit3TestAdapter" Version="5.0.0"/>
<!-- TODO: when ApiSurface accepts https://github.com/G-Research/ApiSurface/pull/116, upgrade these -->
<PackageReference Include="System.IO.Abstractions" Version="4.2.13" />
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="4.2.13" />
</ItemGroup>
<ItemGroup>

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,5 +1,6 @@
namespace WoofWare.Expect
open System.Collections.Generic
open System.IO
open System.Runtime.CompilerServices
open System.Text.Json
@@ -23,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="sourceOverride">Override the file path and line numbers reported in snapshots, so that your tests can be fully stable even on failure. (You almost certainly don't want to set this.)</param>
type ExpectBuilder (mode : Mode) =
member private this.Mode = Unchecked.defaultof<Mode>
new (sourceOverride : string * int) = ExpectBuilder (Mode.AssertMockingSource sourceOverride)
new (update : bool)
@@ -34,6 +33,61 @@ type ExpectBuilder (mode : Mode) =
else
ExpectBuilder Mode.Assert
/// Combine two `ExpectStateListy`s. The first one is the "expected" snapshot; the second is the "actual".
member _.Bind<'U> (state : ExpectStateListy<'U>, f : unit -> ExpectStateListy<'U>) : ExpectStateListy<'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!"
{
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 ()
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 : ((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 ()
@@ -73,6 +127,40 @@ type ExpectBuilder (mode : Mode) =
JsonDocOptions = jsonDocOptions
}
/// <summary>Express that the actual value's <c>ToString</c> should identically equal this string.</summary>
[<CustomOperation("snapshot", MaintainsVariableSpaceUsingBind = true)>]
member _.Snapshot<'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 = 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>
@@ -104,6 +192,46 @@ type ExpectBuilder (mode : Mode) =
Actual = None
}
/// <summary>
/// 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 this.SnapshotJson<'a>
(
state : ExpectStateListy<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 = None
JsonDocOptions = None
Snapshot = Some (SnapshotValue.Json snapshot, callerInfo)
Actual = None
}
/// <summary>
/// Express that the actual value, when converted to JSON, should result in a JSON document
/// which matches the JSON document that is this string.
@@ -144,10 +272,138 @@ type ExpectBuilder (mode : Mode) =
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.
/// this value.
/// </summary>
/// <remarks>
/// For example, <c>withFormat (fun x -> x.ToString ()) "123"</c> is equivalent to <c>snapshot "123"</c>.
@@ -158,7 +414,24 @@ type ExpectBuilder (mode : Mode) =
| Some _ -> failwith "Please don't supply withFormat more than once"
| None ->
{ state with
Formatter = Some formatter
Formatter = Some (fun f -> f () |> formatter)
}
/// <summary>
/// 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>
@@ -184,6 +457,42 @@ type ExpectBuilder (mode : Mode) =
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>
@@ -215,17 +524,26 @@ type ExpectBuilder (mode : Mode) =
}
/// MaintainsVariableSpaceUsingBind causes this to be used; it's a dummy representing "no snapshot and no assertion".
member _.Return (() : unit) : ExpectState<'T> =
member _.Return (() : unit) : ExpectStateListy<'T> =
{
Formatter = None
JsonSerialiserOptions = None
JsonDocOptions = None
Snapshot = None
Actual = None
}
/// 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
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
@@ -237,6 +555,56 @@ type ExpectBuilder (mode : Mode) =
/// Computation expression `Delay`.
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.
member _.Run (f : unit -> ExpectState<'T>) : unit =
let state = f () |> CompletedSnapshotGeneric.make
@@ -244,40 +612,42 @@ type ExpectBuilder (mode : Mode) =
let raiseError (snapshot : string) (actual : string) : unit =
match mode with
| Mode.AssertMockingSource (mockSource, line) ->
let diff = Diff.patience snapshot actual
sprintf
"snapshot mismatch! snapshot at %s:%i (%s) was:\n\n%s\n\nactual was:\n\n%s"
"snapshot mismatch! snapshot at %s:%i (%s) diff:\n\n%s"
mockSource
line
state.Caller.MemberName
(snapshot |> Text.predent '-')
(actual |> Text.predent '+')
(Diff.format diff)
|> ExpectException
|> raise
| Mode.Assert ->
if GlobalBuilderConfig.bulkUpdate.Value > 0 then
GlobalBuilderConfig.registerTest state
if GlobalBuilderConfig.isBulkUpdateMode () then
GlobalBuilderConfig.registerTest (CompletedSnapshot.makeGuess state)
else
let diff = Diff.patience snapshot actual
sprintf
"snapshot mismatch! snapshot at %s:%i (%s) was:\n\n%s\n\nactual was:\n\n%s"
"snapshot mismatch! snapshot at %s:%i (%s) diff:\n\n%s"
state.Caller.FilePath
state.Caller.LineNumber
state.Caller.MemberName
(snapshot |> Text.predent '-')
(actual |> Text.predent '+')
(Diff.format diff)
|> ExpectException
|> raise
| Mode.Update ->
let lines = File.ReadAllLines state.Caller.FilePath
let oldContents = String.concat "\n" lines
let lines = SnapshotUpdate.updateSnapshotAtLine lines state.Caller.LineNumber actual
File.WriteAllLines (state.Caller.FilePath, lines)
File.writeAllLines lines state.Caller.FilePath
failwith ("Snapshot successfully updated. Previous contents:\n" + oldContents)
match CompletedSnapshotGeneric.passesAssertion state with
| None ->
match mode, GlobalBuilderConfig.bulkUpdate.Value with
match mode, GlobalBuilderConfig.isBulkUpdateMode () with
| Mode.Update, _
| _, 1 ->
| _, true ->
failwith
"Snapshot assertion passed, but we are in snapshot-updating mode. Use the `expect` builder instead of `expect'` to assert the contents of a single snapshot; disable `GlobalBuilderConfig.bulkUpdate` to move back to assertion-checking mode."
| _ -> ()

View File

@@ -1,12 +1,19 @@
namespace WoofWare.Expect
open System.Threading
/// Module holding global mutable state controlling the behaviour of WoofWare.Expect
/// when running in bulk-update mode.
[<RequireQualifiedAccess>]
module GlobalBuilderConfig =
let internal bulkUpdate = ref 0
/// All access to the global mutable state locks on this.
let private locker = obj ()
// Global mutable state ensuring there is at most one `enterBulkUpdateMode`/`updateAllSnapshots` pair running at once.
let private bulkUpdate = ref 0
let private allTests : ResizeArray<CompletedSnapshot> = ResizeArray ()
let internal isBulkUpdateMode () : bool =
lock locker (fun () -> bulkUpdate.Value > 0)
/// <summary>
/// Call this to make the <c>expect</c> builder register all tests for bulk update as it runs.
@@ -16,11 +23,15 @@ module GlobalBuilderConfig =
/// The implied global mutable state is liable to interfere with other expect builders in other fixtures otherwise.
/// </remarks>
let enterBulkUpdateMode () =
if Interlocked.Increment bulkUpdate <> 1 then
failwith
"WoofWare.Expect requires bulk updates to happen serially: for example, make the test fixture `[<NonParallelizable>]` if you're using NUnit."
lock
locker
(fun () ->
if bulkUpdate.Value <> 0 then
failwith
"WoofWare.Expect requires bulk updates to happen serially: for example, make the test fixture `[<NonParallelizable>]` if you're using NUnit."
let private allTests : ResizeArray<CompletedSnapshot> = ResizeArray ()
bulkUpdate.Value <- bulkUpdate.Value + 1
)
/// <summary>
/// Clear the set of failing tests registered by any previous bulk-update runs.
@@ -30,23 +41,30 @@ module GlobalBuilderConfig =
/// You probably don't need to do this, because your test runner is probably tearing down
/// anyway after the tests have failed; this is mainly here for WoofWare.Expect's own internal testing.
/// </remarks>
let clearTests () = lock allTests allTests.Clear
let clearTests () = lock locker allTests.Clear
let internal registerTest (s : CompletedSnapshotGeneric<'T>) : unit =
let toAdd = s |> CompletedSnapshot.make
lock allTests (fun () -> allTests.Add toAdd)
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 () =
let bulkUpdate' = Interlocked.Decrement bulkUpdate
// It's OK for this to be called when `enterBulkUpdateMode` has not been called, i.e. when `bulkUpdate` has
// value 0. That just means we aren't in bulk-update mode, so we expect the following simply to do nothing.
// (This is an expected workflow: we expect users to run `updateAllSnapshots` unconditionally in a
// one-time tear-down of the test suite, and they use the one-time setup to control whether any work is actually
// performed here.)
lock
locker
(fun () ->
let allTests = Seq.toArray allTests
try
if bulkUpdate' = 0 then
let allTests = lock allTests (fun () -> Seq.toArray allTests)
SnapshotUpdate.updateAll allTests
finally
clearTests ()
try
SnapshotUpdate.updateAll allTests
finally
// double acquiring of reentrant lock is OK, we're not switching threads
clearTests ()
bulkUpdate.Value <- 0
)

309
WoofWare.Expect/Diff.fs Normal file
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
}

View File

@@ -1,5 +1,6 @@
namespace WoofWare.Expect
open System.Collections
open System.Text.Json
open System.Text.Json.Serialization
@@ -17,20 +18,30 @@ type CallerInfo =
type private SnapshotValue =
| Json of expected : string
| Formatted of expected : string
| ThrowsException of expected : string
type private CompletedSnapshotValue<'T> =
| Json of expected : string * JsonSerializerOptions * JsonDocumentOptions
| Formatted of expected : string * format : ('T -> string)
| Formatted of expected : string * format : ((unit -> 'T) -> string)
/// The state accumulated by the `expect` builder. You should never find yourself interacting with this type.
type ExpectState<'T> =
private
{
Formatter : ('T -> string) option
Formatter : ((unit -> 'T) -> string) option
JsonSerialiserOptions : JsonSerializerOptions option
JsonDocOptions : JsonDocumentOptions option
Snapshot : (SnapshotValue * CallerInfo) option
Actual : 'T option
Actual : (unit -> 'T) option
}
/// The state accumulated by the `expect` builder. You should never find yourself interacting with this type.
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.
@@ -39,7 +50,7 @@ type internal CompletedSnapshotGeneric<'T> =
{
SnapshotValue : CompletedSnapshotValue<'T>
Caller : CallerInfo
Actual : 'T
Actual : unit -> 'T
}
[<RequireQualifiedAccess>]
@@ -68,11 +79,22 @@ module internal CompletedSnapshotGeneric =
| SnapshotValue.Formatted expected ->
let formatter =
match state.Formatter with
| None -> fun x -> x.ToString ()
| None -> fun x -> x().ToString ()
| Some f -> f
CompletedSnapshotValue.Formatted (expected, formatter)
| SnapshotValue.ThrowsException expected ->
CompletedSnapshotValue.Formatted (
expected,
fun x ->
try
x () |> ignore
"<no exception raised>"
with e ->
e.GetType().FullName + ": " + e.Message
)
{
SnapshotValue = snapshot
Caller = source
@@ -84,7 +106,7 @@ module internal CompletedSnapshotGeneric =
let internal replacement (s : CompletedSnapshotGeneric<'T>) =
match s.SnapshotValue with
| CompletedSnapshotValue.Json (_existing, options, _) ->
JsonSerializer.Serialize (s.Actual, options)
JsonSerializer.Serialize (s.Actual (), options)
|> JsonDocument.Parse
|> _.RootElement
|> _.ToString()
@@ -104,7 +126,7 @@ module internal CompletedSnapshotGeneric =
None
let canonicalActual =
JsonSerializer.Serialize (state.Actual, jsonSerOptions) |> JsonDocument.Parse
JsonSerializer.Serialize (state.Actual (), jsonSerOptions) |> JsonDocument.Parse
match canonicalSnapshot with
| None -> Some ("[JSON failed to parse:] " + snapshot, canonicalActual.RootElement.ToString ())
@@ -114,18 +136,69 @@ module internal CompletedSnapshotGeneric =
else
None
/// Represents a snapshot test that has failed and is awaiting update or report to the user.
type CompletedSnapshot =
internal
type internal CompletedListSnapshotGeneric<'elt> =
private
{
CallerInfo : CallerInfo
Replacement : string
Expected : string list
Format : 'elt -> string
Caller : CallerInfo
Actual : unit -> 'elt seq
}
[<RequireQualifiedAccess>]
module internal CompletedSnapshot =
let make (s : CompletedSnapshotGeneric<'T>) =
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 ()
{
CallerInfo = s.Caller
Replacement = CompletedSnapshotGeneric.replacement s
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 () =
sprintf "%i:%i to %i:%i: %s" this.StartLine this.StartColumn this.EndLine this.EndColumn this.Content
type private Position =
type private SnapshotPosition =
{
Line : int
Column : int
@@ -28,8 +28,8 @@ module internal SnapshotUpdate =
let tripleQuote = "\"\"\""
/// Convert a string position to line/column
let private positionToLineColumn (text : string) (offset : int) : Position =
let rec loop (line : int) (col : int) (totalOffset : int) (i : int) : Position =
let private positionToLineColumn (text : string) (offset : int) : SnapshotPosition =
let rec loop (line : int) (col : int) (totalOffset : int) (i : int) : SnapshotPosition =
if i >= text.Length || totalOffset = offset then
{
Line = line
@@ -147,7 +147,8 @@ module internal SnapshotUpdate =
let searchText = String.concat "\n" relevantLines
// Find snapshot keyword
let snapshotMatch = Regex.Match (searchText, @"\b(snapshot|snapshotJson)\b")
let snapshotMatch =
Regex.Match (searchText, @"\b(snapshot|snapshotJson|snapshotThrows)\b")
if not snapshotMatch.Success then
None
@@ -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
/// instead returns the new contents.
/// We always write single-quoted @-strings for simplicity.
let private updateSnapshot (lines : string[]) (info : StringLiteralInfo) (newContent : string) : string[] =
let newString = "@\"" + newContent.Replace ("\"", "\"\"") + "\""
let newString = stringLiteral newContent
if info.StartLine = info.EndLine then
// Single line update
@@ -229,7 +241,7 @@ module internal SnapshotUpdate =
let newLines =
if newContent.IndexOf '\n' >= 0 then
let split = newContent.Replace("\"", "\"\"").Split ('\n')
let split = newContent.Replace("\"", "\"\"").Split '\n'
match split with
| [||] -> failwith "expected contents from split string"
@@ -286,6 +298,27 @@ module internal SnapshotUpdate =
|> 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>
@@ -295,10 +328,19 @@ module internal SnapshotUpdate =
|> Seq.iter (fun (callerFile, callers) ->
let contents = System.IO.File.ReadAllLines callerFile
let sources =
callers |> Seq.map (fun csc -> csc.CallerInfo.LineNumber, csc.Replacement)
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
let newContents = updateAllLines contents sources
System.IO.File.WriteAllLines (callerFile, newContents)
File.writeAllLines newContents callerFile
)

View File

@@ -6,22 +6,94 @@ WoofWare.Expect.Builder.get_expect [static method]: unit -> WoofWare.Expect.Expe
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.CompletedSnapshot inherit obj, implements WoofWare.Expect.CompletedSnapshot System.IEquatable, System.Collections.IStructuralEquatable, WoofWare.Expect.CompletedSnapshot System.IComparable, System.IComparable, System.Collections.IStructuralComparable
WoofWare.Expect.CompletedSnapshot.Equals [method]: (WoofWare.Expect.CompletedSnapshot, System.Collections.IEqualityComparer) -> bool
WoofWare.Expect.Diff'`1 inherit obj, implements 'line WoofWare.Expect.Diff' System.IEquatable, System.Collections.IStructuralEquatable, 'line WoofWare.Expect.Diff' System.IComparable, System.IComparable, System.Collections.IStructuralComparable
WoofWare.Expect.Diff'`1.Equals [method]: ('line WoofWare.Expect.Diff', System.Collections.IEqualityComparer) -> bool
WoofWare.Expect.DiffModule inherit obj
WoofWare.Expect.DiffModule.format [static method]: string WoofWare.Expect.Diff' -> string
WoofWare.Expect.DiffModule.format' [static method]: ('line -> string) -> 'line WoofWare.Expect.Diff' -> string
WoofWare.Expect.DiffModule.formatWithLineNumbers [static method]: string WoofWare.Expect.Diff' -> string
WoofWare.Expect.DiffModule.formatWithLineNumbers' [static method]: ('line -> string) -> 'line WoofWare.Expect.Diff' -> string
WoofWare.Expect.DiffModule.myers [static method]: string [] -> string [] -> string WoofWare.Expect.Diff'
WoofWare.Expect.DiffModule.patience [static method]: string -> string -> string WoofWare.Expect.Diff'
WoofWare.Expect.DiffModule.patienceLines [static method]: 'line [] -> 'line [] -> 'line WoofWare.Expect.Diff'
WoofWare.Expect.DiffOperation`1 inherit obj, implements 'line WoofWare.Expect.DiffOperation System.IEquatable, System.Collections.IStructuralEquatable, 'line WoofWare.Expect.DiffOperation System.IComparable, System.IComparable, System.Collections.IStructuralComparable - union type with 3 cases
WoofWare.Expect.DiffOperation`1+Delete inherit 'line WoofWare.Expect.DiffOperation
WoofWare.Expect.DiffOperation`1+Delete.get_line [method]: unit -> 'line
WoofWare.Expect.DiffOperation`1+Delete.get_posA [method]: unit -> int
WoofWare.Expect.DiffOperation`1+Delete.line [property]: [read-only] 'line
WoofWare.Expect.DiffOperation`1+Delete.posA [property]: [read-only] int
WoofWare.Expect.DiffOperation`1+Insert inherit 'line WoofWare.Expect.DiffOperation
WoofWare.Expect.DiffOperation`1+Insert.get_line [method]: unit -> 'line
WoofWare.Expect.DiffOperation`1+Insert.get_posB [method]: unit -> int
WoofWare.Expect.DiffOperation`1+Insert.line [property]: [read-only] 'line
WoofWare.Expect.DiffOperation`1+Insert.posB [property]: [read-only] int
WoofWare.Expect.DiffOperation`1+Match inherit 'line WoofWare.Expect.DiffOperation
WoofWare.Expect.DiffOperation`1+Match.get_line [method]: unit -> 'line
WoofWare.Expect.DiffOperation`1+Match.get_posA [method]: unit -> int
WoofWare.Expect.DiffOperation`1+Match.get_posB [method]: unit -> int
WoofWare.Expect.DiffOperation`1+Match.line [property]: [read-only] 'line
WoofWare.Expect.DiffOperation`1+Match.posA [property]: [read-only] int
WoofWare.Expect.DiffOperation`1+Match.posB [property]: [read-only] int
WoofWare.Expect.DiffOperation`1+Tags inherit obj
WoofWare.Expect.DiffOperation`1+Tags.Delete [static field]: int = 1
WoofWare.Expect.DiffOperation`1+Tags.Insert [static field]: int = 2
WoofWare.Expect.DiffOperation`1+Tags.Match [static field]: int = 0
WoofWare.Expect.DiffOperation`1.Equals [method]: ('line WoofWare.Expect.DiffOperation, System.Collections.IEqualityComparer) -> bool
WoofWare.Expect.DiffOperation`1.get_IsDelete [method]: unit -> bool
WoofWare.Expect.DiffOperation`1.get_IsInsert [method]: unit -> bool
WoofWare.Expect.DiffOperation`1.get_IsMatch [method]: unit -> bool
WoofWare.Expect.DiffOperation`1.get_Tag [method]: unit -> int
WoofWare.Expect.DiffOperation`1.IsDelete [property]: [read-only] bool
WoofWare.Expect.DiffOperation`1.IsInsert [property]: [read-only] bool
WoofWare.Expect.DiffOperation`1.IsMatch [property]: [read-only] bool
WoofWare.Expect.DiffOperation`1.NewDelete [static method]: (int, 'line) -> 'line WoofWare.Expect.DiffOperation
WoofWare.Expect.DiffOperation`1.NewInsert [static method]: (int, 'line) -> 'line WoofWare.Expect.DiffOperation
WoofWare.Expect.DiffOperation`1.NewMatch [static method]: (int, int, 'line) -> 'line WoofWare.Expect.DiffOperation
WoofWare.Expect.DiffOperation`1.Tag [property]: [read-only] int
WoofWare.Expect.Dot inherit obj
WoofWare.Expect.Dot+IFileSystem - interface with 3 member(s)
WoofWare.Expect.Dot+IFileSystem.DeleteFile [method]: string -> unit
WoofWare.Expect.Dot+IFileSystem.GetTempFileName [method]: unit -> string
WoofWare.Expect.Dot+IFileSystem.WriteFile [method]: string -> string -> unit
WoofWare.Expect.Dot+IProcess`1 - interface with 5 member(s)
WoofWare.Expect.Dot+IProcess`1.Create [method]: string -> string -> #(IDisposable)
WoofWare.Expect.Dot+IProcess`1.ExitCode [method]: #(IDisposable) -> int
WoofWare.Expect.Dot+IProcess`1.ReadStandardOutput [method]: #(IDisposable) -> string
WoofWare.Expect.Dot+IProcess`1.Start [method]: #(IDisposable) -> bool
WoofWare.Expect.Dot+IProcess`1.WaitForExit [method]: #(IDisposable) -> unit
WoofWare.Expect.Dot.fileSystem [static property]: [read-only] WoofWare.Expect.Dot+IFileSystem
WoofWare.Expect.Dot.get_fileSystem [static method]: unit -> WoofWare.Expect.Dot+IFileSystem
WoofWare.Expect.Dot.get_process' [static method]: unit -> System.Diagnostics.Process WoofWare.Expect.Dot+IProcess
WoofWare.Expect.Dot.get_render [static method]: unit -> (string -> string)
WoofWare.Expect.Dot.process' [static property]: [read-only] System.Diagnostics.Process WoofWare.Expect.Dot+IProcess
WoofWare.Expect.Dot.render [static property]: [read-only] string -> string
WoofWare.Expect.Dot.render' [static method]: #(IDisposable) WoofWare.Expect.Dot+IProcess -> WoofWare.Expect.Dot+IFileSystem -> string -> string -> string
WoofWare.Expect.ExpectBuilder inherit obj
WoofWare.Expect.ExpectBuilder..ctor [constructor]: (string * int)
WoofWare.Expect.ExpectBuilder..ctor [constructor]: bool
WoofWare.Expect.ExpectBuilder..ctor [constructor]: WoofWare.Expect.Mode
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.ExpectStateListy) -> (unit -> 'T WoofWare.Expect.ExpectStateListy)
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.ExpectStateListy) -> unit
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..ctor [constructor]: string
WoofWare.Expect.ExpectException..ctor [constructor]: unit
@@ -29,9 +101,11 @@ WoofWare.Expect.ExpectException.Equals [method]: (System.Exception, System.Colle
WoofWare.Expect.ExpectException.Equals [method]: System.Exception -> bool
WoofWare.Expect.ExpectException.Message [property]: [read-only] string
WoofWare.Expect.ExpectState`1 inherit obj
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.Equals [method]: (WoofWare.Expect.Mode, System.Collections.IEqualityComparer) -> bool
WoofWare.Expect.Mode.Equals [method]: (WoofWare.Expect.Mode, System.Collections.IEqualityComparer) -> bool
WoofWare.Expect.pos inherit obj

View File

@@ -18,6 +18,10 @@
<ItemGroup>
<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="Config.fs" />
@@ -36,8 +40,9 @@
<ItemGroup>
<!-- 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="Fantomas.FCS" Version="7.0.3" />
<!-- Needed for DeepEquals -->
<PackageReference Include="System.Text.Json" Version="9.0.0" />
</ItemGroup>

View File

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

View File

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

View File

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