B4J=true
Group=Default Group
ModulesStructureVersion=1
Type=Class
Version=10.7
@EndOfDesignText@
' Dependency-free B4J regression suite for the public QBuilder API.
' Run from Main.bas in the QBuilder B4J project.
Sub Class_Globals
	Private mAssertions As Int
	Private mFailures As List
	Private mCurrentTest As String
End Sub

Public Sub Initialize
	mFailures.Initialize
End Sub

Public Sub RunAll As Map
	mAssertions = 0
	mFailures.Clear
	RunCase("dialect and identifier quoting", "TestDialects")
	RunCase("select, conditions, joins, grouping, and sorting", "TestSelectAndConditions")
	RunCase("aggregates and result modes", "TestReadHelpers")
	RunCase("insert, update, counters, delete, and returning", "TestWrites")
	RunCase("pagination, clone, clear, and snapshots", "TestStateManagement")
	RunCase("validation and unsupported features", "TestQueryErrors")
	RunCase("schema DDL, constraints, indexes, and types", "TestSchemaCreate")
	RunCase("schema alter and catalog operations", "TestSchemaOperations")
	Dim Summary As String = "Assertions: " & mAssertions & ", failures: " & mFailures.Size
	Return CreateMap("assertions": mAssertions, "failed": mFailures.Size, "failures": mFailures, "summary": Summary)
End Sub

Private Sub RunCase(TestName As String, MethodName As String)
	mCurrentTest = TestName
	Try
		CallSub(Me, MethodName)
	Catch
		mFailures.Add(TestName & ": unexpected exception: " & LastException.Message & " | " & LastException.StackTrace)
	End Try
End Sub

Public Sub TestDialects
	Dim Names As List
	Names.Initialize
	Names.AddAll(Array As String("sqlite", "sqlite3", "postgres", "pg", "mysql", "mariadb", "mssql", "sqlserver", "oracle"))
	For Each Dialect As String In Names
		Dim D As QSqlDialect
		D.Initialize(Dialect)
		AssertEqual("dialect " & Dialect & " normalizes", D.Name, CanonicalDialect(Dialect))
		AssertEqual("qualified identifier " & Dialect, D.QuoteIdentifier("app.users"), ExpectedQualified(D.Name))
	Next
	Dim D As QSqlDialect
	D.Initialize("mysql")
	AssertEqual("mysql escapes identifier delimiters", D.QuoteIdentifier("user`name"), "`user``name`")
	D.Initialize("mssql")
	AssertEqual("mssql escapes identifier delimiters", D.QuoteIdentifier("user]name"), "[user]]name]")
	D.Initialize("postgres")
	AssertEqual("postgres escapes double quotes", D.QuoteIdentifier("say" & Chr(34) & "hi"), Chr(34) & "say" & Chr(34) & Chr(34) & "hi" & Chr(34))
	AssertEqual("wildcard is not quoted", D.QuoteIdentifier("*"), "*")
End Sub

Private Sub CanonicalDialect(Dialect As String) As String
	Select Dialect
		Case "sqlite3": Return "sqlite"
		Case "pg": Return "postgres"
		Case "sqlserver": Return "mssql"
		Case Else: Return Dialect
	End Select
End Sub

Private Sub ExpectedQualified(Dialect As String) As String
	Select Dialect
		Case "mysql", "mariadb": Return "`app`.`users`"
		Case "mssql": Return "[app].[users]"
		Case Else: Return Chr(34) & "app" & Chr(34) & "." & Chr(34) & "users" & Chr(34)
	End Select
End Sub

Public Sub TestSelectAndConditions
	Dim Q As QBuilder
	Q.Initialize("pg")
	Dim Columns As List
	Columns.Initialize
	Columns.AddAll(Array As String("users.id", "users.name"))
	Dim IDs As List
	IDs.Initialize
	IDs.AddAll(Array As Int(3, 7))
	Q.SelectColumns(Columns).Distinct.From("users").WithSchema("app") _
		.Join("roles", "users.role_id", "=", "roles.id") _
		.LeftJoin("teams", "users.team_id", "=", "teams.id") _
		.RightJoin("orgs", "users.org_id", "=", "orgs.id") _
		.FullOuterJoin("tenants", "users.tenant_id", "=", "tenants.id") _
		.CrossJoin("regions") _
		.WhereEq("users.active", True).OrWhereNot("users.deleted", "=", True) _
		.WhereNull("users.archived_at").WhereNotNull("users.email") _
		.OrWhereNull("users.disabled_at").WhereIn("users.id", IDs) _
		.WhereNotIn("users.state", EmptyList).WhereBetween("users.age", 18, 65) _
		.WhereNotBetween("users.score", 0, 10).WhereLike("users.name", "%Ada%") _
		.WhereRaw("users.rank > ?", ObjectList(Array As Object(2))) _
		.GroupBy(StringList(Array As String("users.id", "users.name"))) _
		.Having("users.id", ">", 1).OrderBy("users.name", "asc").OrderBy("users.id", "desc")
	Dim R As Map = Q.ToSQL
	AssertNoError("complex select compiles", R)
	AssertContains("qualified schema and table", R.Get("sql"), "from " & Chr(34) & "app" & Chr(34) & "." & Chr(34) & "users" & Chr(34))
	AssertContains("all join types compile", R.Get("sql"), "cross join " & Chr(34) & "app" & Chr(34) & "." & Chr(34) & "regions" & Chr(34))
	AssertContains("right join compiles", R.Get("sql"), "right join")
	AssertContains("full outer join compiles", R.Get("sql"), "full outer join")
	AssertContains("group and having compile", R.Get("sql"), "group by " & Chr(34) & "users" & Chr(34) & "." & Chr(34) & "id" & Chr(34))
	AssertContains("empty NOT IN is true", R.Get("sql"), "1 = 1")
	AssertContains("between compile", R.Get("sql"), "not between ? and ?")
	AssertContains("raw condition compile", R.Get("sql"), "users.rank > ?")
	AssertEqual("complex select binding order", ResultListSize(R, "bindings"), 11)
	AssertEqual("method metadata", R.Get("method"), "select")
	AssertEqual("placeholder metadata", R.Get("placeholder"), "?")
	AssertEqual("dialect alias metadata", R.Get("dialect"), "postgres")

	Q.Initialize("sqlite")
	Q.Table("items").WhereNotIn("id", EmptyList)
	R = Q.ToSQL
	AssertContains("empty NOT IN compiles to tautology", R.Get("sql"), "1 = 1")
	Q.Clear
	Q.From("items").WhereIn("id", EmptyList)
	R = Q.ToSQL
	AssertContains("empty IN compiles to contradiction", R.Get("sql"), "1 = 0")

	Q.Clear
	Q.From("items").JoinAs("right", "groups", "items.group_id", "=", "groups.id") _
		.OrWhereEq("name", "x").AndWhere("qty", ">", 0)
	R = Q.ToSQL
	AssertContains("join alias method", R.Get("sql"), "right join")
	AssertEqual("AndWhere and OrWhereEq bindings", ResultListSize(R, "bindings"), 2)
	Q.Clear
	Q.From("items").Where("qty", ">", 1).OrWhere("qty", "<", 0)
	R = Q.ToSQL
	AssertContains("Where and OrWhere basic conditions", R.Get("sql"), " or ")
	AssertEqual("Where and OrWhere bindings", ResultListSize(R, "bindings"), 2)
	Q.Clear
	Q.From("items").WhereNot("hidden", "=", True)
	AssertContains("WhereNot compiles negated condition", (Q.ToSQL).Get("sql"), "not (")
	Q.Initialize("mssql")
	Q.From("items").Limit(5).Offset(10)
	R = Q.ToSQL
	AssertContains("SQL Server pagination", R.Get("sql"), "offset ? rows fetch next ? rows only")
	AssertEqual("SQL Server pagination bindings", ResultListSize(R, "bindings"), 2)
	Q.Initialize("oracle")
	Q.From("items").Limit(3)
	R = Q.ToSQL
	AssertContains("Oracle pagination adds deterministic order", R.Get("sql"), "order by (select 0) offset ? rows fetch next ? rows only")
	AssertEqual("Oracle limit without offset binds zero offset", ResultListItem(R, "bindings", 0), 0)
End Sub

Private Sub EmptyList As List
	Dim L As List
	L.Initialize
	Return L
End Sub

Public Sub TestReadHelpers
	Dim Q As QBuilder
	Q.Initialize("sqlite")
	Q.From("orders").CountAll
	Dim R As Map = Q.ToSQL
	AssertContains("CountAll", R.Get("sql"), "count(*)")
	Q.Clear
	Q.From("orders").CountAs("id", "total")
	R = Q.ToSQL
	AssertContains("CountAs alias", R.Get("sql"), "count(" & Chr(34) & "id" & Chr(34) & ") as " & Chr(34) & "total" & Chr(34))
	Q.Clear
	Q.From("orders").Count("id").MinValue("amount").MaxValue("amount").Sum("amount").Avg("amount")
	R = Q.ToSQL
	AssertContains("count aggregate", R.Get("sql"), "count(" & Chr(34) & "id" & Chr(34) & ")")
	AssertContains("min aggregate", R.Get("sql"), "min(" & Chr(34) & "amount" & Chr(34) & ")")
	AssertContains("max aggregate", R.Get("sql"), "max(" & Chr(34) & "amount" & Chr(34) & ")")
	AssertContains("sum aggregate", R.Get("sql"), "sum(" & Chr(34) & "amount" & Chr(34) & ")")
	AssertContains("avg aggregate", R.Get("sql"), "avg(" & Chr(34) & "amount" & Chr(34) & ")")
	Q.Clear
	Q.From("orders").SelectAll
	R = Q.ToSQL
	AssertContains("SelectAll", R.Get("sql"), "select * from")
	Q.Clear
	Q.From("orders").Pluck("email")
	R = Q.ToSQL
	AssertEqual("Pluck mode", R.Get("resultMode"), "pluck")
	Q.Clear
	Q.From("orders").First
	R = Q.ToSQL
	AssertEqual("First result mode", R.Get("resultMode"), "first")
	AssertContains("First applies limit", R.Get("sql"), "limit ?")
	Q.Clear
	Q.From("orders").SelectRaw("coalesce(?, 0) as score", ObjectList(Array As Object(5)))
	R = Q.ToSQL
	AssertNoError("SelectRaw compiles", R)
	AssertEqual("SelectRaw binding", ResultListItem(R, "bindings", 0), 5)
End Sub

Public Sub TestWrites
	Dim Q As QBuilder
	Q.Initialize("postgres")
	Q.Table("users").Insert(CreateMap("name": "Ada", "id": 4)).Returning(StringList(Array As String("id")))
	Dim R As Map = Q.ToSQL
	AssertNoError("insert compiles", R)
	AssertContains("insert returning", R.Get("sql"), "returning " & Chr(34) & "id" & Chr(34))
	AssertContains("insert map keys sorted", R.Get("sql"), "(" & Chr(34) & "id" & Chr(34) & ", " & Chr(34) & "name" & Chr(34) & ")")
	AssertEqual("insert bindings are sorted", ResultListItem(R, "bindings", 0), 4)

	Q.Initialize("sqlite")
	Dim Rows As List
	Rows.Initialize
	Rows.Add(CreateMap("b": 2, "a": 1))
	Rows.Add(CreateMap("a": 3, "b": 4))
	Q.From("pairs").InsertRows(Rows).Returning(StringList(Array As String("a")))
	R = Q.ToSQL
	AssertNoError("multirow insert compiles", R)
	AssertContains("multirow SQL", R.Get("sql"), "values (?, ?), (?, ?)")
	AssertEqual("multirow bindings", ResultListSize(R, "bindings"), 4)

	Q.Initialize("mssql")
	Q.From("users").Update(CreateMap("name": "Grace")).Increment("visits", 2) _
		.Decrement("credits", 1).WhereEq("id", 8).Returning(StringList(Array As String("id")))
	R = Q.ToSQL
	AssertNoError("update with counters compiles", R)
	AssertContains("SQL Server output clause", R.Get("sql"), "output inserted.")
	AssertContains("update increment", R.Get("sql"), "[visits] = [visits] + ?")
	AssertContains("update decrement", R.Get("sql"), "[credits] = [credits] - ?")
	AssertEqual("update bindings", ResultListSize(R, "bindings"), 4)

	Q.Initialize("postgres")
	Q.From("users").Delete.WhereEq("id", 3).Returning(StringList(Array As String("id")))
	R = Q.ToSQL
	AssertContains("delete returning", R.Get("sql"), "delete from")
	AssertContains("delete returning clause", R.Get("sql"), "returning " & Chr(34) & "id" & Chr(34))
	Q.Clear
	Q.From("users").Del
	AssertEqual("Del alias emits delete", (Q.ToSQL).Get("method"), "delete")

	Q.Initialize("mysql")
	Q.From("users").Insert(CreateMap("id": 1)).Returning(StringList(Array As String("id")))
	AssertContains("unsupported MySQL returning is reported", (Q.ToSQL).Get("error"), "RETURNING is not supported")
	Q.Clear
	Q.From("users").ClearReturning
	AssertEqual("ClearReturning resets state", (Q.ToSQL).Get("error"), "")
End Sub

Public Sub TestStateManagement
	Dim Q As QBuilder
	Q.Initialize("sqlite")
	Q.From("events").WhereEq("kind", "click").OrderBy("id", "asc").Limit(5).Offset(10)
	Dim Copy As QBuilder = Q.Clone
	Copy.ClearWhere.ClearOrder.ClearLimit
	Dim R As Map = Copy.ToSQL
	AssertContains("clear clauses removes conditions", R.Get("sql"), "from " & Chr(34) & "events" & Chr(34))
	AssertFalseContains("clear conditions", R.Get("sql"), " where ")
	AssertFalseContains("clear sort", R.Get("sql"), "order by")
	AssertFalseContains("clear limit", R.Get("sql"), "limit")
	R = Q.ToSQL
	AssertContains("clone leaves source condition intact", R.Get("sql"), " where ")
	AssertContains("sqlite pagination", R.Get("sql"), "limit ? offset ?")

	Q.Clear
	Q.From("events").SelectColumns(StringList(Array As String("id"))).GroupBy(StringList(Array As String("id"))) _
		.Having("id", ">", 0).OrderBy("id", "asc").Join("users", "events.user_id", "=", "users.id") _
		.WhereEq("id", 1).Limit(2).Returning(StringList(Array As String("id")))
	Q.ClearSelect.ClearGroup.ClearHaving.ClearJoins.ClearCounters.ClearReturning
	R = Q.ToSQL
	AssertFalseContains("clear select", R.Get("sql"), "group by")
	AssertFalseContains("clear having", R.Get("sql"), " having ")
	AssertFalseContains("clear joins", R.Get("sql"), " join ")
	AssertFalseContains("clear returning", R.Get("sql"), "returning")
	AssertContains("clear leaves where clause", R.Get("sql"), " where ")
	Q.Clear
	Q.From("events").Increment("visits", 1).ClearCounters
	AssertContains("clear counters removes the only update assignment", (Q.ToSQL).Get("error"), "Update requires")

	Dim InputMap As Map = CreateMap("value": 10)
	Q.Initialize("sqlite")
	Q.From("snapshots").Insert(InputMap)
	InputMap.Put("value", 99)
	R = Q.ToSQL
	AssertEqual("insert snapshots input map", ResultListItem(R, "bindings", 0), 10)
	Dim RawValues As List
	RawValues.Initialize
	RawValues.Add("original")
	Q.Initialize("sqlite")
	Q.From("snapshots").WhereRaw("value = ?", RawValues)
	RawValues.Set(0, "changed")
	R = Q.ToSQL
	AssertEqual("raw snapshots input list", ResultListItem(R, "bindings", 0), "original")
End Sub

Public Sub TestQueryErrors
	Dim Q As QBuilder
	Q.Initialize("sqlite")
	AssertContains("missing table validation", (Q.ToSQL).Get("error"), "table name is required")
	Q.Initialize("not-a-dialect")
	Q.From("t")
	AssertContains("unknown dialect validation", (Q.ToSQL).Get("error"), "Unknown dialect")
	Q.Initialize("sqlite")
	Q.From("t").SelectRaw("x = ?", EmptyList)
	AssertContains("raw placeholder mismatch", (Q.ToSQL).Get("error"), "placeholder count")
	Q.Clear
	Q.From("t").WhereRaw("x = ?", EmptyList)
	AssertContains("where raw placeholder mismatch", (Q.ToSQL).Get("error"), "placeholder count")
	Q.Initialize("sqlite")
	Q.From("t").Insert(EmptyMap)
	AssertContains("empty insert validation", (Q.ToSQL).Get("error"), "Insert requires")
	Q.Initialize("sqlite")
	Q.From("t").Update(EmptyMap)
	AssertContains("empty update validation", (Q.ToSQL).Get("error"), "Update requires")
	Q.Initialize("sqlite")
	Q.From("t").OrderBy("id", "random")
	AssertContains("invalid ordering validation", (Q.ToSQL).Get("error"), "asc or desc")
	Q.Initialize("sqlite")
	Q.From("t").Limit(-1)
	AssertContains("negative limit validation", (Q.ToSQL).Get("error"), "Limit cannot be negative")
	Q.Initialize("sqlite")
	Q.From("t").Offset(-1)
	AssertContains("negative offset validation", (Q.ToSQL).Get("error"), "Offset cannot be negative")
	Dim BadRows As List
	BadRows.Initialize
	BadRows.Add(CreateMap("a": 1))
	BadRows.Add(CreateMap("b": 2))
	Q.Initialize("sqlite")
	Q.From("t").InsertRows(BadRows)
	AssertContains("inconsistent insert row validation", (Q.ToSQL).Get("error"), "same columns")
	Q.Initialize("oracle")
	Q.From("t").Returning(StringList(Array As String("id")))
	Q.Delete
	AssertContains("oracle returning limitation", (Q.ToSQL).Get("error"), "Oracle RETURNING INTO")
End Sub

Private Sub EmptyMap As Map
	Dim M As Map
	M.Initialize
	Return M
End Sub

Private Sub StringList(Items() As String) As List
	Dim Result As List
	Result.Initialize
	Result.AddAll(Items)
	Return Result
End Sub

Private Sub ObjectList(Items() As Object) As List
	Dim Result As List
	Result.Initialize
	Result.AddAll(Items)
	Return Result
End Sub

Public Sub TestSchemaCreate
	Dim S As QSchemaBuilder
	S.Initialize("sqlite")
	S.WithSchema("app").CreateTable("users").Increments("id") _
		.StringColumn("name", 120).NotNullable.DefaultTo("guest") _
		.TextColumn("bio").Nullable.BooleanColumn("enabled").DefaultTo(True) _
		.DecimalColumn("balance", 10, 2).FloatColumn("f").DoubleColumn("d") _
		.IntegerColumn("i").BigIntegerColumn("bi").SmallIntegerColumn("si") _
		.DateColumn("day").DateTimeColumn("dt").TimeColumn("tm").TimestampColumn("ts") _
		.BinaryColumn("blob").JsonColumn("data").UuidColumn("uuid").Timestamps _
		.IntegerColumn("primary_value").Primary.StringColumn("handle", 80).Unique
	Dim KeyCols As List
	KeyCols.Initialize
	KeyCols.Add("name")
	Dim IndexCols As List
	IndexCols.Initialize
	IndexCols.Add("enabled")
	S.PrimaryKey(KeyCols, "pk_users").UniqueKey(KeyCols, "uq_users_name") _
		.Index(IndexCols, "ix_users_enabled").UniqueIndex(KeyCols, "ux_users_name")
	Dim FKCols As List
	FKCols.Initialize
	FKCols.Add("role_id")
	Dim FKTarget As List
	FKTarget.Initialize
	FKTarget.Add("id")
	S.ForeignKey(FKCols, "fk_users_role").References("roles", FKTarget).OnDelete("cascade").OnUpdate("restrict")
	S.Check("balance >= 0", "ck_balance")
	Dim R As Map = S.ToSQL
	AssertNoError("complete SQLite create table", R)
	AssertContains("column modifier/default", R.Get("sql"), "default 'guest'")
	AssertContains("boolean default", R.Get("sql"), "default 1")
	AssertContains("inline primary modifier", R.Get("sql"), Chr(34) & "primary_value" & Chr(34) & " integer primary key")
	AssertContains("inline unique modifier", R.Get("sql"), Chr(34) & "handle" & Chr(34) & " varchar(80) unique")
	AssertContains("primary key constraint", R.Get("sql"), "constraint " & Chr(34) & "pk_users" & Chr(34) & " primary key")
	AssertContains("foreign key actions", R.Get("sql"), "on delete cascade on update restrict")
	AssertContains("check constraint", R.Get("sql"), "check (balance >= 0)")
	AssertEqual("indexes are emitted as additional statements", ResultListSize(R, "statements"), 3)
	AssertContains("unique index statement", ResultListString(R, "statements", 2), "create unique index")

	Dim Dialects As List
	Dialects.Initialize
	Dialects.AddAll(Array As String("sqlite", "postgres", "mysql", "mariadb", "mssql", "oracle"))
	For Each Dialect As String In Dialects
		S.Initialize(Dialect)
		S.CreateTable("types").BigIncrements("id").StringColumn("label", 50) _
			.BooleanColumn("flag").DecimalColumn("amount", 8, 2).JsonColumn("payload").UuidColumn("uuid")
		R = S.ToSQL
		AssertNoError("schema types compile for " & Dialect, R)
	Next
End Sub

Public Sub TestSchemaOperations
	Dim S As QSchemaBuilder
	S.Initialize("postgres")
	S.AlterTable("users").IntegerColumn("age").NotNullable.DropColumn("old") _
		.RenameColumn("name", "display_name").Index(StringList(Array As String("age")), "ix_age")
	Dim R As Map = S.ToSQL
	AssertNoError("alter table compiles", R)
	AssertEqual("alter returns all statements", ResultListSize(R, "statements"), 4)
	AssertContains("alter add column", ResultListString(R, "statements", 0), " add " & Chr(34) & "age" & Chr(34))
	AssertContains("alter drop column", ResultListString(R, "statements", 1), "drop column")
	AssertContains("alter rename column", ResultListString(R, "statements", 2), "rename column")
	AssertContains("alter add index", ResultListString(R, "statements", 3), "create index")

	S.Initialize("mysql")
	S.DropTableIfExists("logs")
	AssertContains("drop-if-exists compiles", (S.ToSQL).Get("sql"), "drop table if exists")
	S.Initialize("mssql")
	S.DropTableIfExists("logs")
	AssertContains("SQL Server drop-if-exists uses catalog guard", (S.ToSQL).Get("sql"), "object_id")
	S.Initialize("mysql")
	S.RenameTable("old", "new")
	AssertContains("MySQL rename compiles", (S.ToSQL).Get("sql"), "rename table")
	S.Initialize("sqlite")
	S.HasTable("users")
	R = S.ToSQL
	AssertContains("SQLite has-table query", R.Get("sql"), "sqlite_master")
	AssertEqual("catalog table name is bound", ResultListItem(R, "bindings", 0), "users")
	S.Clear
	S.HasColumn("users", "name")
	R = S.ToSQL
	AssertContains("SQLite has-column query", R.Get("sql"), "pragma_table_info(?)")
	AssertEqual("catalog binds table and column", ResultListSize(R, "bindings"), 2)
	S.Initialize("oracle")
	S.WithSchema("app").HasColumn("users", "id")
	R = S.ToSQL
	AssertContains("Oracle catalog lookup", R.Get("sql"), "all_tab_columns")
	AssertEqual("Oracle catalog normalizes names", ResultListItem(R, "bindings", 0), "APP")
	S.Initialize("sqlite")
	S.DropTable("users")
	AssertContains("drop table", (S.ToSQL).Get("sql"), "drop table")
	S.Initialize("sqlite")
	S.RenameTable("old", "new")
	AssertContains("SQLite rename table", (S.ToSQL).Get("sql"), "rename to")

	S.Initialize("oracle")
	S.DropTableIfExists("users")
	AssertContains("unsupported Oracle drop-if-exists", (S.ToSQL).Get("error"), "not supported")
	S.Initialize("sqlite")
	S.CreateTable("empty")
	AssertContains("empty create table validation", (S.ToSQL).Get("error"), "at least one column")
	S.Initialize("sqlite")
	S.AlterTable("users")
	AssertContains("empty alter validation", (S.ToSQL).Get("error"), "no changes")
	S.Initialize("sqlite")
	S.CreateTable("t").StringColumn("x", -1)
	AssertContains("negative string length validation", (S.ToSQL).Get("error"), "cannot be negative")
	S.Initialize("sqlite")
	S.CreateTable("t").DecimalColumn("x", 2, 3)
	AssertContains("invalid decimal validation", (S.ToSQL).Get("error"), "cannot exceed precision")
End Sub

Private Sub AssertNoError(Label As String, Result As Map)
	AssertEqual(Label, Result.Get("error"), "")
End Sub

Private Sub ResultListSize(Result As Map, Key As String) As Int
	Dim Values As List = Result.Get(Key)
	Return Values.Size
End Sub

Private Sub ResultListItem(Result As Map, Key As String, ItemIndex As Int) As Object
	Dim Values As List = Result.Get(Key)
	Return Values.Get(ItemIndex)
End Sub

Private Sub ResultListString(Result As Map, Key As String, ItemIndex As Int) As String
	Dim Values As List = Result.Get(Key)
	Return Values.Get(ItemIndex)
End Sub

Private Sub AssertContains(Label As String, Actual As String, ExpectedPart As String)
	AssertTrue(Label, Actual.Contains(ExpectedPart))
End Sub

Private Sub AssertFalseContains(Label As String, Actual As String, UnexpectedPart As String)
	AssertTrue(Label, Actual.Contains(UnexpectedPart) = False)
End Sub

Private Sub AssertEqual(Label As String, Actual As Object, Expected As Object)
	If ("" & Actual) = ("" & Expected) Then
		mAssertions = mAssertions + 1
	Else
		RecordFailure(Label & " (expected: " & Expected & ", got: " & Actual & ")")
	End If
End Sub

Private Sub AssertTrue(Label As String, Condition As Boolean)
	If Condition Then
		mAssertions = mAssertions + 1
	Else
		RecordFailure(Label)
	End If
End Sub

Private Sub RecordFailure(Message As String)
	mAssertions = mAssertions + 1
	mFailures.Add(mCurrentTest & ": " & Message)
End Sub
