Move to WoofWare.PrattParser

This commit is contained in:
Smaug123
2024-06-04 19:46:00 +01:00
parent e7cbdf91d3
commit a20ba20d2e
9 changed files with 207 additions and 13 deletions

228
TestRunner/Filter.fs Normal file
View File

@@ -0,0 +1,228 @@
namespace TestRunner
open System
open PrattParser
// Documentation:
// https://learn.microsoft.com/en-us/dotnet/core/testing/selective-unit-tests?pivots=mstest
[<RequireQualifiedAccess>]
type FilterIntermediate =
| FullyQualifiedName
| Name
| TestCategory
| Not of FilterIntermediate
| Or of FilterIntermediate * FilterIntermediate
| And of FilterIntermediate * FilterIntermediate
| Equal of FilterIntermediate * FilterIntermediate
| Contains of FilterIntermediate * FilterIntermediate
| String of string
[<RequireQualifiedAccess>]
type TokenType =
| FullyQualifiedName
| Name
| TestCategory
| OpenParen
| CloseParen
| And
| Or
| Not
| Equal
| NotEqual
| Contains
| NotContains
| String
type Token =
{
Type : TokenType
Trivia : int * int
}
[<RequireQualifiedAccess>]
module Token =
let inline standalone (ty : TokenType) (charPos : int) : Token =
{
Type = ty
Trivia = charPos, 1
}
let inline single (ty : TokenType) (start : int) (len : int) : Token =
{
Type = ty
Trivia = start, len
}
let (|SingleChar|_|) (i : int, c : char) : Token option =
match c with
| '(' -> Some (standalone TokenType.OpenParen i)
| ')' -> Some (standalone TokenType.CloseParen i)
| '~' -> Some (standalone TokenType.Contains i)
| '=' -> Some (standalone TokenType.Equal i)
| '&' -> Some (standalone TokenType.And i)
| '|' -> Some (standalone TokenType.Or i)
| '!' -> Some (standalone TokenType.Not i)
| _ -> None
[<RequireQualifiedAccess>]
module Lexer =
let lex (s : string) : Token seq =
seq {
let mutable i = 0
let mutable stringAcc : int option = None
while i < s.Length do
match (i, s.[i]), stringAcc with
// This one has to come before the check for prefix Not
| (startI, '!'), None when i + 1 < s.Length ->
i <- i + 1
match s.[i] with
| '~' ->
yield Token.single TokenType.NotContains startI 2
i <- i + 1
| '=' ->
yield Token.single TokenType.NotEqual startI 2
i <- i + 1
| _ ->
yield Token.single TokenType.Not startI 1
i <- i + 1
| Token.SingleChar token, None ->
i <- i + 1
yield token
| Token.SingleChar _, Some stringStart ->
yield Token.single TokenType.String stringStart (i - stringStart)
stringAcc <- None // and we'll do the match again
| (_, 'F'), None when
i + 1 < s.Length
&& s.[i + 1 ..].StartsWith ("ullyQualifiedName", StringComparison.Ordinal)
->
yield Token.single TokenType.FullyQualifiedName i "FullyQualifiedName".Length
i <- i + "FullyQualifiedName".Length
| (_, 'N'), None when i + 1 < s.Length && s.[i + 1 ..].StartsWith ("ame", StringComparison.Ordinal) ->
yield Token.single TokenType.Name i "Name".Length
i <- i + "Name".Length
| (_, 'T'), None when
i + 1 < s.Length
&& s.[i + 1 ..].StartsWith ("estCategory", StringComparison.Ordinal)
->
yield Token.single TokenType.TestCategory i "TestCategory".Length
i <- i + "TestCategory".Length
| (_, ' '), None -> i <- i + 1
| (_, _), None ->
stringAcc <- Some i
i <- i + 1
| (_, _), Some _ -> i <- i + 1
match stringAcc with
| None -> ()
| Some start -> yield Token.single TokenType.String start (s.Length - start)
}
[<RequireQualifiedAccess>]
module FilterIntermediate =
let private atom (inputString : string) (token : Token) : FilterIntermediate option =
let start, len = token.Trivia
match token.Type with
| TokenType.String -> Some (FilterIntermediate.String (inputString.Substring (start, len)))
| TokenType.FullyQualifiedName -> Some FilterIntermediate.FullyQualifiedName
| TokenType.Name -> Some FilterIntermediate.Name
| TokenType.TestCategory -> Some FilterIntermediate.TestCategory
| TokenType.OpenParen -> None
| TokenType.CloseParen -> None
| TokenType.And -> None
| TokenType.Or -> None
| TokenType.Not -> None
| TokenType.NotEqual -> None
| TokenType.Equal -> None
| TokenType.NotContains -> None
| TokenType.Contains -> None
let parser =
Parser.make<_, Token, FilterIntermediate> _.Type atom
|> Parser.withInfix TokenType.And (10, 11) (fun a b -> FilterIntermediate.And (a, b))
|> Parser.withInfix TokenType.Equal (15, 16) (fun a b -> FilterIntermediate.Equal (a, b))
|> Parser.withInfix
TokenType.NotEqual
(15, 16)
(fun a b -> FilterIntermediate.Not (FilterIntermediate.Equal (a, b)))
|> Parser.withInfix TokenType.Contains (15, 16) (fun a b -> FilterIntermediate.Contains (a, b))
|> Parser.withInfix
TokenType.NotContains
(15, 16)
(fun a b -> FilterIntermediate.Not (FilterIntermediate.Contains (a, b)))
|> Parser.withInfix TokenType.Or (5, 6) (fun a b -> FilterIntermediate.Or (a, b))
|> Parser.withUnaryPrefix TokenType.Not ((), 13) FilterIntermediate.Not
|> Parser.withBracketLike
TokenType.OpenParen
{
ConsumeBeforeInitialToken = false
ConsumeAfterFinalToken = false
BoundaryTokens = [ TokenType.CloseParen ]
Construct = Seq.exactlyOne
}
let parse (s : string) : FilterIntermediate =
let parsed, remaining = Parser.execute parser s (Lexer.lex s |> Seq.toList)
if not remaining.IsEmpty then
failwith $"Leftover tokens: %O{remaining}"
match parsed with
| FilterIntermediate.String _ -> FilterIntermediate.Contains (FilterIntermediate.FullyQualifiedName, parsed)
| _ -> parsed
type Match =
| Exact of string
| Contains of string
[<RequireQualifiedAccess>]
type Filter =
| FullyQualifiedName of Match
| Name of Match
| TestCategory of Match
| Not of Filter
| Or of Filter * Filter
| And of Filter * Filter
[<RequireQualifiedAccess>]
module Filter =
let private unescape (s : string) : string =
// TODO: XML escaping
s
let rec make (fi : FilterIntermediate) : Filter =
match fi with
| FilterIntermediate.Not x -> Filter.Not (make x)
| FilterIntermediate.FullyQualifiedName -> failwith "malformed filter: found FullyQualifiedName with no operand"
| FilterIntermediate.Name -> failwith "malformed filter: found Name with no operand"
| FilterIntermediate.TestCategory -> failwith "malformed filter: found TestCategory with no operand"
| FilterIntermediate.Or (a, b) -> Filter.Or (make a, make b)
| FilterIntermediate.And (a, b) -> Filter.And (make a, make b)
| FilterIntermediate.Equal (key, value) ->
let value =
match value with
| FilterIntermediate.String s -> unescape s
| _ -> failwith $"malformed filter: found non-string operand on RHS of equality, '%O{value}'"
match key with
| FilterIntermediate.TestCategory -> Filter.TestCategory (Match.Exact value)
| FilterIntermediate.FullyQualifiedName -> Filter.FullyQualifiedName (Match.Exact value)
| FilterIntermediate.Name -> Filter.Name (Match.Exact value)
| _ -> failwith $"Malformed filter: left-hand side of Equals clause must be e.g. TestCategory, was %O{key}"
| FilterIntermediate.Contains (key, value) ->
let value =
match value with
| FilterIntermediate.String s -> unescape s
| _ -> failwith $"malformed filter: found non-string operand on RHS of containment, '%O{value}'"
match key with
| FilterIntermediate.TestCategory -> Filter.TestCategory (Match.Contains value)
| FilterIntermediate.FullyQualifiedName -> Filter.FullyQualifiedName (Match.Contains value)
| FilterIntermediate.Name -> Filter.Name (Match.Contains value)
| _ ->
failwith $"Malformed filter: left-hand side of Contains clause must be e.g. TestCategory, was %O{key}"
| FilterIntermediate.String s ->
failwith $"Malformed filter: got verbatim string %s{s} when expected an operation"

318
TestRunner/Program.fs Normal file
View File

@@ -0,0 +1,318 @@
namespace TestRunner
open System
open System.Collections.Generic
open System.IO
open System.Reflection
open System.Threading
open NUnit.Framework
type Modifier =
| Explicit of reason : string option
| Ignored of reason : string option
type TestKind =
| Single
| Source of string
| Data of obj list list
type SingleTestMethod =
{
// TODO: cope with [<Values>] on the parameters
Method : MethodInfo
Kind : TestKind
Modifiers : Modifier list
Categories : string list
}
member this.Name = this.Method.Name
[<RequireQualifiedAccess>]
module SingleTestMethod =
let parse (parentCategories : string list) (method : MethodInfo) : SingleTestMethod option =
let isTest, hasSource, hasData, modifiers, categories =
((false, None, None, [], []), method.CustomAttributes)
||> Seq.fold (fun (isTest, hasSource, hasData, mods, cats) attr ->
match attr.AttributeType.FullName with
| "NUnit.Framework.TestAttribute" ->
if attr.ConstructorArguments.Count > 0 then
failwith "Unexpectedly got arguments to the Test attribute"
(true, hasSource, hasData, mods, cats)
| "NUnit.Framework.TestCaseAttribute" ->
let args = attr.ConstructorArguments |> Seq.map _.Value |> Seq.toList
match hasData with
| None -> (isTest, hasSource, Some [ List.ofSeq args ], mods, cats)
| Some existing -> (isTest, hasSource, Some ((List.ofSeq args) :: existing), mods, cats)
| "NUnit.Framework.TestCaseSourceAttribute" ->
let arg = attr.ConstructorArguments |> Seq.exactlyOne |> _.Value |> unbox<string>
match hasSource with
| None -> (isTest, Some arg, hasData, mods, cats)
| Some existing ->
failwith
$"Unexpectedly got multiple different sources for test %s{method.Name} (%s{existing}, %s{arg})"
| "NUnit.Framework.ExplicitAttribute" ->
let reason =
attr.ConstructorArguments
|> Seq.tryHead
|> Option.map (_.Value >> unbox<string>)
(isTest, hasSource, hasData, (Modifier.Explicit reason) :: mods, cats)
| "NUnit.Framework.IgnoreAttribute" ->
let reason =
attr.ConstructorArguments
|> Seq.tryHead
|> Option.map (_.Value >> unbox<string>)
(isTest, hasSource, hasData, (Modifier.Ignored reason) :: mods, cats)
| "NUnit.Framework.CategoryAttribute" ->
let category =
attr.ConstructorArguments |> Seq.exactlyOne |> _.Value |> unbox<string>
(isTest, hasSource, hasData, mods, category :: cats)
| s when s.StartsWith ("NUnit.Framework", StringComparison.Ordinal) ->
failwith $"Unrecognised attribute on function %s{method.Name}: %s{attr.AttributeType.FullName}"
| _ -> (isTest, hasSource, hasData, mods, cats)
)
match isTest, hasSource, hasData, modifiers, categories with
| _, Some _, Some _, _, _ ->
failwith $"Test %s{method.Name} unexpectedly has both TestData and TestCaseSource; not currently supported"
| false, None, None, [], _ -> None
| _, Some source, None, mods, categories ->
{
Kind = TestKind.Source source
Method = method
Modifiers = mods
Categories = categories @ parentCategories
}
|> Some
| _, None, Some data, mods, categories ->
{
Kind = TestKind.Data data
Method = method
Modifiers = mods
Categories = categories @ parentCategories
}
|> Some
| true, None, None, mods, categories ->
{
Kind = TestKind.Single
Method = method
Modifiers = mods
Categories = categories @ parentCategories
}
|> Some
| false, None, None, _ :: _, _ ->
failwith
$"Unexpectedly got test modifiers but no test settings on '%s{method.Name}', which you probably didn't intend."
type TestFixture =
{
Name : string
OneTimeSetUp : MethodInfo option
OneTimeTearDown : MethodInfo option
Tests : SingleTestMethod list
}
static member Empty (name : string) =
{
Name = name
OneTimeSetUp = None
OneTimeTearDown = None
Tests = []
}
type TestFailure =
| TestReturnedNonUnit of obj
| TestThrew of exn
[<RequireQualifiedAccess>]
module TestFixture =
let private runOne (test : MethodInfo) (args : obj[]) : Result<unit, TestFailure> =
try
match test.Invoke (null, args) with
| :? unit -> Ok ()
| ret -> Error (TestReturnedNonUnit ret)
with exc ->
Error (TestThrew exc)
let private runFixture (test : SingleTestMethod) : Result<unit, TestFailure> list =
let shouldRunTest =
(true, test.Modifiers)
||> List.fold (fun _ modifier ->
match modifier with
| Modifier.Explicit reason ->
// TODO: if the filter explicitly says to run this, then do so
let reason =
match reason with
| None -> ""
| Some r -> $" (%s{r})"
printfn $"Will ignore test %s{test.Name} because it is marked explicit%s{reason}"
false
| Modifier.Ignored reason ->
let reason =
match reason with
| None -> ""
| Some r -> $" (%s{r})"
eprintfn $"Will ignore test %s{test.Name} because it is marked ignored%s{reason}"
false
)
if not shouldRunTest then
[]
else
match test.Kind with
| TestKind.Data data -> data |> List.map (fun args -> runOne test.Method (Array.ofList args))
| TestKind.Single -> [ runOne test.Method [||] ]
| TestKind.Source s ->
let args = test.Method.DeclaringType.GetProperty s
args.GetValue null :?> IEnumerable<obj>
|> Seq.map (fun arg ->
match arg with
| :? TestCaseData as tcd -> runOne test.Method tcd.Arguments
| :? Tuple<obj, obj> as (a, b) -> runOne test.Method [| a ; b |]
| :? Tuple<obj, obj, obj> as (a, b, c) -> runOne test.Method [| a ; b ; c |]
| :? Tuple<obj, obj, obj, obj> as (a, b, c, d) -> runOne test.Method [| a ; b ; c ; d |]
| arg -> runOne test.Method [| arg |]
)
|> List.ofSeq
let rec shouldRun (filter : Filter) : TestFixture -> SingleTestMethod -> bool =
match filter with
| Filter.Not filter ->
let inner = shouldRun filter
fun a b -> not (inner a b)
| Filter.And (a, b) ->
let inner1 = shouldRun a
let inner2 = shouldRun b
fun a b -> inner1 a b && inner2 a b
| Filter.Or (a, b) ->
let inner1 = shouldRun a
let inner2 = shouldRun b
fun a b -> inner1 a b || inner2 a b
| Filter.Name (Match.Exact m) -> fun _fixture method -> method.Method.Name = m
| Filter.Name (Match.Contains m) -> fun _fixture method -> method.Method.Name.Contains m
| Filter.FullyQualifiedName (Match.Exact m) -> fun fixture method -> (fixture.Name + method.Method.Name) = m
| Filter.FullyQualifiedName (Match.Contains m) ->
fun fixture method -> (fixture.Name + method.Method.Name).Contains m
| Filter.TestCategory (Match.Contains m) ->
fun _fixture method -> method.Categories |> List.exists (fun cat -> cat.Contains m)
| Filter.TestCategory (Match.Exact m) -> fun _fixture method -> method.Categories |> List.contains m
let run (filter : TestFixture -> SingleTestMethod -> bool) (tests : TestFixture) : int =
eprintfn $"Running test fixture: %s{tests.Name} (%i{tests.Tests.Length} tests to run)"
match tests.OneTimeSetUp with
| Some su ->
if not (isNull (su.Invoke (null, [||]))) then
failwith "Setup procedure returned non-null"
| _ -> ()
let totalTestSuccess = ref 0
let testFailures = ref 0
try
for test in tests.Tests do
if filter tests test then
eprintfn $"Running test: %s{test.Name}"
let testSuccess = ref 0
let results = runFixture test
for result in results do
match result with
| Error exc ->
eprintfn $"Test failed: {exc}"
Interlocked.Increment testFailures |> ignore<int>
| Ok () -> Interlocked.Increment testSuccess |> ignore<int>
Interlocked.Add (totalTestSuccess, testSuccess.Value) |> ignore<int>
eprintfn $"Finished test %s{test.Name} (%i{testSuccess.Value} success)"
else
eprintfn $"Skipping test due to filter: %s{test.Name}"
finally
match tests.OneTimeTearDown with
| Some td ->
if not (isNull (td.Invoke (null, [||]))) then
failwith "TearDown procedure returned non-null"
| _ -> ()
eprintfn $"Test fixture %s{tests.Name} completed (%i{totalTestSuccess.Value} success)."
testFailures.Value
let parse (parentType : Type) : TestFixture =
let categories =
parentType.CustomAttributes
|> Seq.filter (fun attr -> attr.AttributeType.FullName = "NUnit.Framework.CategoryAttribute")
|> Seq.map (fun attr -> attr.ConstructorArguments |> Seq.exactlyOne |> _.Value |> unbox<string>)
|> Seq.toList
(TestFixture.Empty parentType.Name, parentType.GetRuntimeMethods ())
||> Seq.fold (fun state mi ->
if
mi.CustomAttributes
|> Seq.exists (fun attr -> attr.AttributeType.FullName = "NUnit.Framework.OneTimeSetUpAttribute")
then
match state.OneTimeSetUp with
| None ->
{ state with
OneTimeSetUp = Some mi
}
| Some _existing -> failwith "Multiple OneTimeSetUp methods found"
elif
mi.CustomAttributes
|> Seq.exists (fun attr -> attr.AttributeType.FullName = "NUnit.Framework.OneTimeTearDownAttribute")
then
match state.OneTimeTearDown with
| None ->
{ state with
OneTimeTearDown = Some mi
}
| Some _existing -> failwith "Multiple OneTimeTearDown methods found"
else
match SingleTestMethod.parse categories mi with
| Some test ->
{ state with
Tests = test :: state.Tests
}
| None -> state
)
module Program =
[<EntryPoint>]
let main argv =
let testDll, filter =
match argv |> List.ofSeq with
| [ dll ] -> FileInfo dll, None
| [ dll ; "--filter" ; filter ] -> FileInfo dll, Some (FilterIntermediate.parse filter |> Filter.make)
| _ -> failwith "provide exactly one arg, a test DLL"
let filter =
match filter with
| Some filter -> TestFixture.shouldRun filter
| None -> fun _ _ -> true
let assy = Assembly.LoadFrom testDll.FullName
assy.ExportedTypes
// TODO: NUnit nowadays doesn't care if you're a TestFixture or not
|> Seq.filter (fun ty ->
ty.CustomAttributes
|> Seq.exists (fun attr -> attr.AttributeType.FullName = "NUnit.Framework.TestFixtureAttribute")
)
|> Seq.iter (fun ty ->
let testFixture = TestFixture.parse ty
match TestFixture.run filter testFixture with
| 0 -> ()
| i -> eprintfn $"%i{i} tests failed"
)
0

View File

@@ -0,0 +1,115 @@
namespace TestRunner.Test
open TestRunner
open NUnit.Framework
open FsUnitTyped
[<TestFixture>]
module TestFilter =
let docExamples =
[
"(Name~MyClass) | (Name~MyClass2)",
FilterIntermediate.Or (
FilterIntermediate.Contains (FilterIntermediate.Name, FilterIntermediate.String "MyClass"),
FilterIntermediate.Contains (FilterIntermediate.Name, FilterIntermediate.String "MyClass2")
)
"xyz", FilterIntermediate.Contains (FilterIntermediate.FullyQualifiedName, FilterIntermediate.String "xyz")
"FullyQualifiedName~xyz",
FilterIntermediate.Contains (FilterIntermediate.FullyQualifiedName, FilterIntermediate.String "xyz")
"FullyQualifiedName!~IntegrationTests",
FilterIntermediate.Not (
FilterIntermediate.Contains (
FilterIntermediate.FullyQualifiedName,
FilterIntermediate.String "IntegrationTests"
)
)
"FullyQualifiedName=MyNamespace.MyTestsClass<ParameterType1%2CParameterType2>.MyTestMethod",
FilterIntermediate.Equal (
FilterIntermediate.FullyQualifiedName,
FilterIntermediate.String "MyNamespace.MyTestsClass<ParameterType1%2CParameterType2>.MyTestMethod"
)
"Name~Method", FilterIntermediate.Contains (FilterIntermediate.Name, FilterIntermediate.String "Method")
"FullyQualifiedName!=MSTestNamespace.UnitTest1.TestMethod1",
FilterIntermediate.Not (
FilterIntermediate.Equal (
FilterIntermediate.FullyQualifiedName,
FilterIntermediate.String "MSTestNamespace.UnitTest1.TestMethod1"
)
)
"TestCategory=CategoryA",
FilterIntermediate.Equal (FilterIntermediate.TestCategory, FilterIntermediate.String "CategoryA")
"FullyQualifiedName~UnitTest1|TestCategory=CategoryA",
FilterIntermediate.Or (
FilterIntermediate.Contains (
FilterIntermediate.FullyQualifiedName,
FilterIntermediate.String "UnitTest1"
),
FilterIntermediate.Equal (FilterIntermediate.TestCategory, FilterIntermediate.String "CategoryA")
)
"FullyQualifiedName~UnitTest1&TestCategory=CategoryA",
FilterIntermediate.And (
FilterIntermediate.Contains (
FilterIntermediate.FullyQualifiedName,
FilterIntermediate.String "UnitTest1"
),
FilterIntermediate.Equal (FilterIntermediate.TestCategory, FilterIntermediate.String "CategoryA")
)
"(FullyQualifiedName~UnitTest1&TestCategory=CategoryA)|TestCategory=1",
FilterIntermediate.Or (
FilterIntermediate.And (
FilterIntermediate.Contains (
FilterIntermediate.FullyQualifiedName,
FilterIntermediate.String "UnitTest1"
),
FilterIntermediate.Equal (FilterIntermediate.TestCategory, FilterIntermediate.String "CategoryA")
),
FilterIntermediate.Equal (FilterIntermediate.TestCategory, FilterIntermediate.String "1")
)
]
|> List.map TestCaseData
[<TestCaseSource(nameof docExamples)>]
let ``Doc examples`` (example : string, expected : FilterIntermediate) =
FilterIntermediate.parse example |> shouldEqual expected
let docExamplesRefined =
[
"(Name~MyClass) | (Name~MyClass2)",
Filter.Or (Filter.Name (Match.Contains "MyClass"), Filter.Name (Match.Contains "MyClass2"))
"xyz", Filter.FullyQualifiedName (Match.Contains "xyz")
"FullyQualifiedName~xyz", Filter.FullyQualifiedName (Match.Contains "xyz")
"FullyQualifiedName!~IntegrationTests",
Filter.Not (Filter.FullyQualifiedName (Match.Contains "IntegrationTests"))
"FullyQualifiedName=MyNamespace.MyTestsClass<ParameterType1%2CParameterType2>.MyTestMethod",
Filter.FullyQualifiedName (
Match.Exact "MyNamespace.MyTestsClass<ParameterType1%2CParameterType2>.MyTestMethod"
)
"Name~Method", Filter.Name (Match.Contains "Method")
"FullyQualifiedName!=MSTestNamespace.UnitTest1.TestMethod1",
Filter.Not (Filter.FullyQualifiedName (Match.Exact "MSTestNamespace.UnitTest1.TestMethod1"))
"TestCategory=CategoryA", Filter.TestCategory (Match.Exact "CategoryA")
"FullyQualifiedName~UnitTest1|TestCategory=CategoryA",
Filter.Or (
Filter.FullyQualifiedName (Match.Contains "UnitTest1"),
Filter.TestCategory (Match.Exact "CategoryA")
)
"FullyQualifiedName~UnitTest1&TestCategory=CategoryA",
Filter.And (
Filter.FullyQualifiedName (Match.Contains "UnitTest1"),
Filter.TestCategory (Match.Exact "CategoryA")
)
"(FullyQualifiedName~UnitTest1&TestCategory=CategoryA)|TestCategory=1",
Filter.Or (
Filter.And (
Filter.FullyQualifiedName (Match.Contains "UnitTest1"),
Filter.TestCategory (Match.Exact "CategoryA")
),
Filter.TestCategory (Match.Exact "1")
)
]
|> List.map TestCaseData
[<TestCaseSource(nameof docExamplesRefined)>]
let ``Doc examples, refined`` (example : string, expected : Filter) =
FilterIntermediate.parse example |> Filter.make |> shouldEqual expected

View File

@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<Compile Include="TestFilter.fs" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.0"/>
<PackageReference Include="FsUnit" Version="6.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
<PackageReference Include="NUnit" Version="4.1.0" />
<PackageReference Include="NUnit.Analyzers" Version="3.9.0"/>
<PackageReference Include="NUnit3TestAdapter" Version="4.5.0"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\TestRunner.fsproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<Compile Include="Filter.fs" />
<Compile Include="Program.fs" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="NUnit" Version="4.1.0" />
<PackageReference Include="WoofWare.PrattParser" Version="0.1.1" />
</ItemGroup>
</Project>