Merge "Expand instructions when change has too many updates"
diff --git a/Documentation/prolog-cookbook.txt b/Documentation/prolog-cookbook.txt
index 9a23a27..f291920 100644
--- a/Documentation/prolog-cookbook.txt
+++ b/Documentation/prolog-cookbook.txt
@@ -74,6 +74,9 @@
link:pgm-prolog-shell.html[prolog-shell] program which opens an interactive
Prolog interpreter shell.
+For batch or unit tests, see the examples in Gerrit source directory
+link:https://gerrit.googlesource.com/gerrit/+/refs/heads/master/prologtests/examples/[prologtests/examples].
+
[NOTE]
The interactive shell is just a prolog shell, it does not load
a gerrit server environment and thus is not intended for
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
index 6fc1984..82ea692 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
@@ -47,7 +47,6 @@
import com.google.common.collect.Iterables;
import com.google.common.io.BaseEncoding;
import com.google.common.truth.Correspondence;
-import com.google.common.truth.Correspondence.BinaryPredicate;
import com.google.common.util.concurrent.AtomicLongMap;
import com.google.common.util.concurrent.Runnables;
import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -2743,12 +2742,9 @@
private static Correspondence<GroupInfo, String> getGroupToNameCorrespondence() {
return Correspondence.from(
- new BinaryPredicate<GroupInfo, String>() {
- @Override
- public boolean apply(GroupInfo actualGroup, String expectedName) {
- String groupName = actualGroup == null ? null : actualGroup.name;
- return Objects.equals(groupName, expectedName);
- }
+ (actualGroup, expectedName) -> {
+ String groupName = actualGroup == null ? null : actualGroup.name;
+ return Objects.equals(groupName, expectedName);
},
"has name");
}
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
index 47ac7a9..fca94af 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
@@ -31,7 +31,6 @@
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Iterables;
import com.google.common.truth.Correspondence;
-import com.google.common.truth.Correspondence.BinaryPredicate;
import com.google.common.util.concurrent.AtomicLongMap;
import com.google.gerrit.acceptance.AbstractDaemonTest;
import com.google.gerrit.acceptance.GerritConfig;
@@ -1395,12 +1394,9 @@
private static Correspondence<AccountInfo, String> getAccountToUsernameCorrespondence() {
return Correspondence.from(
- new BinaryPredicate<AccountInfo, String>() {
- @Override
- public boolean apply(AccountInfo actualAccount, String expectedName) {
- String username = actualAccount == null ? null : actualAccount.username;
- return Objects.equals(username, expectedName);
- }
+ (actualAccount, expectedName) -> {
+ String username = actualAccount == null ? null : actualAccount.username;
+ return Objects.equals(username, expectedName);
},
"has username");
}
diff --git a/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java b/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
index b9b7ab3..150c0a7 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
@@ -23,7 +23,6 @@
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Iterables;
import com.google.common.truth.Correspondence;
-import com.google.common.truth.Correspondence.BinaryPredicate;
import com.google.gerrit.acceptance.AbstractDaemonTest;
import com.google.gerrit.acceptance.GerritConfig;
import com.google.gerrit.acceptance.NoHttpd;
@@ -651,13 +650,8 @@
private static Correspondence<RelatedChangeAndCommitInfo, String>
getRelatedChangeToStatusCorrespondence() {
return Correspondence.from(
- new BinaryPredicate<RelatedChangeAndCommitInfo, String>() {
- @Override
- public boolean apply(
- RelatedChangeAndCommitInfo relatedChangeAndCommitInfo, String status) {
- return Objects.equals(relatedChangeAndCommitInfo.status, status);
- }
- },
+ (relatedChangeAndCommitInfo, status) ->
+ Objects.equals(relatedChangeAndCommitInfo.status, status),
"has status");
}
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImplTest.java b/javatests/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImplTest.java
index e0d6593..f01ac00 100644
--- a/javatests/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImplTest.java
+++ b/javatests/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImplTest.java
@@ -19,7 +19,6 @@
import com.google.common.collect.ImmutableSet;
import com.google.common.truth.Correspondence;
-import com.google.common.truth.Correspondence.BinaryPredicate;
import com.google.gerrit.acceptance.AbstractDaemonTest;
import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
import com.google.gerrit.extensions.api.groups.GroupInput;
@@ -615,32 +614,26 @@
private static Correspondence<AccountInfo, Account.Id> getAccountToIdCorrespondence() {
return Correspondence.from(
- new BinaryPredicate<AccountInfo, Account.Id>() {
- @Override
- public boolean apply(AccountInfo actualAccount, Account.Id expectedId) {
- Account.Id accountId =
- Optional.ofNullable(actualAccount)
- .map(account -> account._accountId)
- .map(Account.Id::new)
- .orElse(null);
- return Objects.equals(accountId, expectedId);
- }
+ (actualAccount, expectedId) -> {
+ Account.Id accountId =
+ Optional.ofNullable(actualAccount)
+ .map(account -> account._accountId)
+ .map(Account.Id::new)
+ .orElse(null);
+ return Objects.equals(accountId, expectedId);
},
"has ID");
}
private static Correspondence<GroupInfo, AccountGroup.UUID> getGroupToUuidCorrespondence() {
return Correspondence.from(
- new BinaryPredicate<GroupInfo, AccountGroup.UUID>() {
- @Override
- public boolean apply(GroupInfo actualGroup, AccountGroup.UUID expectedUuid) {
- AccountGroup.UUID groupUuid =
- Optional.ofNullable(actualGroup)
- .map(group -> group.id)
- .map(AccountGroup.UUID::new)
- .orElse(null);
- return Objects.equals(groupUuid, expectedUuid);
- }
+ (actualGroup, expectedUuid) -> {
+ AccountGroup.UUID groupUuid =
+ Optional.ofNullable(actualGroup)
+ .map(group -> group.id)
+ .map(AccountGroup.UUID::new)
+ .orElse(null);
+ return Objects.equals(groupUuid, expectedUuid);
},
"has UUID");
}
diff --git a/prologtests/examples/BUILD b/prologtests/examples/BUILD
new file mode 100644
index 0000000..4048bc7
--- /dev/null
+++ b/prologtests/examples/BUILD
@@ -0,0 +1,7 @@
+package(default_visibility = ["//visibility:public"])
+
+sh_test(
+ name = "test_examples",
+ srcs = ["run.sh"],
+ data = glob(["*.pl"]) + ["//:gerrit.war"],
+)
diff --git a/prologtests/examples/README.md b/prologtests/examples/README.md
new file mode 100644
index 0000000..12eb256e
--- /dev/null
+++ b/prologtests/examples/README.md
@@ -0,0 +1,54 @@
+# Prolog Unit Test Examples
+
+## Run all examples
+
+Build a local gerrit.war and then run the script:
+
+ ./run.sh
+
+Note that a local Gerrit server is not needed because
+these unit test examples redefine wrappers of the `gerrit:change\*`
+rules to provide mocked change data.
+
+## Add a new unit test
+
+Please follow the pattern in `t1.pl`, `t2.pl`, or `t3.pl`.
+
+* Put code to be tested in a file, e.g. `rules.pl`.
+ For easy unit testing, split long clauses into short ones
+ and test every positive and negative path.
+
+* Create a new unit test file, e.g. `t1.pl`,
+ which should _load_ the test source file and `utils.pl`.
+
+ % First load all source files and the utils.pl.
+ :- load([aosp_rules,utils]).
+
+ :- begin_tests(t1). % give this test any name
+
+ % Use test0/1 or test1/1 to verify failed/passed goals.
+
+ :- end_tests(_,0). % check total pass/fail counts
+
+* Optionally replace calls to gerrit functions that depend on repository.
+ For example, define the following wrappers and in source code, use
+ `change_branch/1` instead of `gerrti:change_branch/1`.
+
+ change_branch(X) :- gerrit:change_branch(X).
+ commit_label(L,U) :- gerrit:commit_label(L,U).
+
+* In unit test file, redefine the gerrit function wrappers and test.
+ For example, in `t3.pl`, we have:
+
+ :- redefine(uploader,1,uploader(user(42))). % mocked uploader
+ :- test1(uploader(user(42))).
+ :- test0(is_exempt_uploader).
+
+ % is_exempt_uploader/0 is expected to fail because it is
+ % is_exempt_uploader :- uploader(user(Id)), memberchk(Id, [104, 106]).
+
+ % Note that gerrit:remove_label does not depend on Gerrit repository,
+ % so its caller remove_label/1 is tested without any redefinition.
+
+ :- test1(remove_label('MyReview',[],[])).
+ :- test1(remove_label('MyReview',submit(),submit())).
diff --git a/prologtests/examples/aosp_rules.pl b/prologtests/examples/aosp_rules.pl
new file mode 100644
index 0000000..18e8a73
--- /dev/null
+++ b/prologtests/examples/aosp_rules.pl
@@ -0,0 +1,148 @@
+% A simplified and mocked AOSP rules.pl
+
+%%%%% wrapper functions for unit tests
+
+change_branch(X) :- gerrit:change_branch(X).
+change_project(X) :- gerrit:change_project(X).
+commit_author(U,N,M) :- gerrit:commit_author(U,N,M).
+commit_delta(X) :- gerrit:commit_delta(X).
+commit_label(L,U) :- gerrit:commit_label(L,U).
+uploader(X) :- gerrit:uploader(X).
+
+%%%%% true/false conditions
+
+% Special auto-merger accounts.
+is_exempt_uploader :-
+ uploader(user(Id)),
+ memberchk(Id, [104, 106]).
+
+% Build cop overrides everything.
+has_build_cop_override :-
+ commit_label(label('Build-Cop-Override', 1), _).
+
+is_exempt_from_reviews :-
+ or(is_exempt_uploader, has_build_cop_override).
+
+% Some files in selected projects need API review.
+needs_api_review :-
+ commit_delta('^(.*/)?api/|^(system-api/)'),
+ change_project(Project),
+ memberchk(Project, [
+ 'platform/external/apache-http',
+ 'platform/frameworks/base',
+ 'platform/frameworks/support',
+ 'platform/packages/services/Car',
+ 'platform/prebuilts/sdk'
+ ]).
+
+% Some branches need DrNo review.
+needs_drno_review :-
+ change_branch(Branch),
+ memberchk(Branch, [
+ 'refs/heads/my-alpha-dev',
+ 'refs/heads/my-beta-dev'
+ ]).
+
+% Some author email addresses need Qualcomm-Review.
+needs_qualcomm_review :-
+ commit_author(_, _, M),
+ regex_matches(
+'.*@(qti.qualcomm.com|qca.qualcomm.com|quicinc.com|qualcomm.com)', M).
+
+% Special projects, branches, user accounts
+% can opt out owners review.
+opt_out_find_owners :-
+ change_branch(Branch),
+ memberchk(Branch, [
+ 'refs/heads/my-beta-testing',
+ 'refs/heads/my-testing'
+ ]).
+
+% Special projects, branches, user accounts
+% can opt in owners review.
+% Note that opt_out overrides opt_in.
+opt_in_find_owners :- true.
+
+
+%%%%% Simple list filters.
+
+remove_label(X, In, Out) :-
+ gerrit:remove_label(In, label(X, _), Out).
+
+% Slow but simple for short input list.
+remove_review_categories(In, Out) :-
+ remove_label('API-Review', In, L1),
+ remove_label('Code-Review', L1, L2),
+ remove_label('DrNo-Review', L2, L3),
+ remove_label('Owner-Review-Vote', L3, L4),
+ remove_label('Qualcomm-Review', L4, L5),
+ remove_label('Verified', L5, Out).
+
+
+%%%%% Missing rules in Gerrit Prolog Cafe.
+
+or(InA, InB) :- once((A;B)).
+
+not(Goal) :- Goal -> false ; true.
+
+% memberchk(+Element, +List)
+memberchk(X, [H|T]) :-
+ (X = H -> true ; memberchk(X, T)).
+
+maplist(Functor, In, Out) :-
+ (In = []
+ -> Out = []
+ ; (In = [X1|T1],
+ Out = [X2|T2],
+ Goal =.. [Functor, X1, X2],
+ once(Goal),
+ maplist(Functor, T1, T2)
+ )
+ ).
+
+
+%%%%% Conditional rules and filters.
+
+submit_filter(In, Out) :-
+ (is_exempt_from_reviews
+ -> remove_review_categories(In, Out)
+ ; (check_review(needs_api_review,
+ 'API_Review', In, L1),
+ check_review(needs_drno_review,
+ 'DrNo-Review', L1, L2),
+ check_review(needs_qualcomm_review,
+ 'Qualcomm-Review', L2, L3),
+ check_find_owners(L3, Out)
+ )
+ ).
+
+check_review(NeedReview, Label, In, Out) :-
+ (NeedReview
+ -> Out = In
+ ; remove_label(Label, In, Out)
+ ).
+
+% If opt_out_find_owners is true,
+% remove all 'Owner-Review-Vote' label;
+% else if opt_in_find_owners is true,
+% call find_owners:submit_filter;
+% else default to no find_owners filter.
+check_find_owners(In, Out) :-
+ (opt_out_find_owners
+ -> remove_label('Owner-Review-Vote', In, Temp)
+ ; (opt_in_find_owners
+ -> find_owners:submit_filter(In, Temp)
+ ; In = Temp
+ )
+ ),
+ Temp =.. [submit | L1],
+ remove_label('Owner-Approved', L1, L2),
+ maplist(owner_may_to_need, L2, L3),
+ Out =.. [submit | L3].
+
+% change may(_) to need(_) to block submit.
+owner_may_to_need(In, Out) :-
+ (In = label('Owner-Review-Vote', may(_))
+ -> Out = label('Owner-Review-Vote', need(_))
+ ; Out = In
+ ).
diff --git a/prologtests/examples/load.pl b/prologtests/examples/load.pl
new file mode 100644
index 0000000..f5b49e8
--- /dev/null
+++ b/prologtests/examples/load.pl
@@ -0,0 +1,26 @@
+% If you have 1.4.3 or older Prolog-Cafe, you need to
+% use (consult(load), load(load)) to get definition of load.
+% Then use load([f1,f2,...]) to load multiple source files.
+
+% Input is a list of file names or a single file name.
+% Use a conditional expression style without cut operator.
+load(X) :-
+ ( (X = [])
+ -> true
+ ; ( (X = [H|T])
+ -> (load_file(H), load(T))
+ ; load_file(X)
+ )
+ ).
+
+% load_file is '$consult' without the bug of unbound 'File' variable.
+% For repeated unit tests, skip statistics and print_message.
+load_file(F) :- atom(F), !,
+ '$prolog_file_name'(F, PF),
+ open(PF, read, In),
+ % print_message(info, [loading,PF,'...']),
+ % statistics(runtime, _),
+ consult_stream(PF, In),
+ % statistics(runtime, [_,T]),
+ % print_message(info, [PF,'loaded in',T,msec]),
+ close(In).
diff --git a/prologtests/examples/rules.pl b/prologtests/examples/rules.pl
new file mode 100644
index 0000000..1a7b17c
--- /dev/null
+++ b/prologtests/examples/rules.pl
@@ -0,0 +1,29 @@
+% An example source file to be tested.
+
+% Add common rules missing in Prolog Cafe.
+memberchk(X, [H|T]) :-
+ (X = H) -> true ; memberchk(X, T).
+
+% A rule that can succeed/backtrack multiple times.
+super_users(1001).
+super_users(1002).
+
+% Deterministic rule that pass/fail only once.
+is_super_user(X) :- memberchk(X, [1001, 1002]).
+
+% Another rule that can pass 5 times.
+multi_users(101).
+multi_users(102).
+multi_users(103).
+multi_users(104).
+multi_users(105).
+
+% Okay, single deterministic fact.
+single_user(abc).
+
+% Wrap calls to gerrit repository, to be redefined in tests.
+change_owner(X) :- gerrit:change_owner(X).
+
+% To test is_owner without gerrit:change_owner,
+% we should redefine change_owner.
+is_owner(X) :- change_owner(X).
diff --git a/prologtests/examples/run.sh b/prologtests/examples/run.sh
new file mode 100755
index 0000000..947c153
--- /dev/null
+++ b/prologtests/examples/run.sh
@@ -0,0 +1,62 @@
+#!/bin/bash
+
+TESTS="t1 t2 t3"
+
+# Note that both t1.pl and t2.pl test code in rules.pl.
+# Unit tests are usually longer than the tested code.
+# So it is common to test one source file with multiple
+# unit test files.
+
+LF=$'\n'
+PASS=""
+FAIL=""
+
+echo "#### TEST_SRCDIR = ${TEST_SRCDIR}"
+
+if [ "${TEST_SRCDIR}" == "" ]; then
+ # Assume running alone
+ GERRIT_WAR="../../bazel-bin/gerrit.war"
+ SRCDIR="."
+else
+ # Assume running from bazel
+ GERRIT_WAR=`pwd`/gerrit.war
+ SRCDIR="prologtests/examples"
+fi
+
+# Default GERRIT_TMP is ~/.gerritcodereview/tmp,
+# which won't be writable in a bazel test sandbox.
+/bin/mkdir -p /tmp/gerrit
+export GERRIT_TMP=/tmp/gerrit
+
+for T in $TESTS
+do
+
+ pushd $SRCDIR
+
+ # Unit tests do not need to define clauses in packages.
+ # Use one prolog-shell per unit test, to avoid name collision.
+ echo "### Running test ${T}.pl"
+ echo "[$T]." | java -jar ${GERRIT_WAR} prolog-shell -q -s load.pl
+
+ if [ "x$?" != "x0" ]; then
+ echo "### Test ${T}.pl failed."
+ FAIL="${FAIL}${LF}FAIL: Test ${T}.pl"
+ else
+ PASS="${PASS}${LF}PASS: Test ${T}.pl"
+ fi
+
+ popd
+
+ # java -jar ../../bazel-bin/gerrit.war prolog-shell -s $T < /dev/null
+ # Calling prolog-shell with -s flag works for small files,
+ # but got run-time exception with t3.pl.
+ # com.googlecode.prolog_cafe.exceptions.ReductionLimitException:
+ # exceeded reduction limit of 1048576
+done
+
+echo "$PASS"
+
+if [ "$FAIL" != "" ]; then
+ echo "$FAIL"
+ exit 1
+fi
diff --git a/prologtests/examples/t1.pl b/prologtests/examples/t1.pl
new file mode 100644
index 0000000..caf9061
--- /dev/null
+++ b/prologtests/examples/t1.pl
@@ -0,0 +1,20 @@
+:- load([rules,utils]).
+:- begin_tests(t1).
+
+:- test1(true). % expect true to pass
+:- test0(false). % expect false to fail
+
+:- test1(X = 3). % unification should pass
+:- test1(_ = 3). % unification should pass
+:- test0(X \= 3). % not-unified should fail
+
+% (7-4) should have expected result
+:- test1((X is (7-4), X =:= 3)).
+:- test1((X is (7-4), X =\= 4)).
+
+% memberchk should pass/fail exactly once
+:- test1(memberchk(3,[1,3,5,3])).
+:- test0(memberchk(2,[1,3,5,3])).
+:- test0(memberchk(2,[])).
+
+:- end_tests_or_halt(0). % expect no failure
diff --git a/prologtests/examples/t2.pl b/prologtests/examples/t2.pl
new file mode 100644
index 0000000..9424b53
--- /dev/null
+++ b/prologtests/examples/t2.pl
@@ -0,0 +1,25 @@
+:- load([rules,utils]).
+:- begin_tests(t2).
+
+% expected to pass or fail once.
+:- test0(super_users(1000)).
+:- test1(super_users(1001)).
+
+:- test1(is_super_user(1001)).
+:- test1(is_super_user(1002)).
+:- test0(is_super_user(1003)).
+
+:- test1(super_users(X)). % expected fail (pass twice)
+:- test1(multi_users(X)). % expected fail (pass many times)
+
+:- test1(single_user(X)). % expected pass once
+
+% Redefine change_owner, skip gerrit:change_owner,
+% then test is_owner without a gerrit repository.
+
+:- redefine(change_owner,1,(change_owner(42))).
+:- test1(is_owner(42)).
+:- test1(is_owner(X)).
+:- test0(is_owner(24)).
+
+:- end_tests_or_halt(2). % expect 2 failures
diff --git a/prologtests/examples/t3.pl b/prologtests/examples/t3.pl
new file mode 100644
index 0000000..02badc0
--- /dev/null
+++ b/prologtests/examples/t3.pl
@@ -0,0 +1,69 @@
+:- load([aosp_rules,utils]).
+
+:- begin_tests(t3_basic_conditions).
+
+%% A negative test of is_exempt_uploader.
+:- redefine(uploader,1,uploader(user(42))). % mocked uploader
+:- test1(uploader(user(42))).
+:- test0(is_exempt_uploader).
+
+%% Helper functions for positive test of is_exempt_uploader.
+test_is_exempt_uploader(List) :- maplist(test1_uploader, List, _).
+test1_uploader(X,_) :-
+ redefine(uploader,1,uploader(user(X))),
+ test1(uploader(user(X))),
+ test1(is_exempt_uploader).
+:- test_is_exempt_uploader([104, 106]).
+
+%% Test has_build_cop_override.
+:- redefine(commit_label,2,commit_label(label('Code-Review',1),user(102))).
+:- test0(has_build_cop_override).
+commit_label(label('Build-Cop-Override',1),user(101)). % mocked 2nd label
+:- test1(has_build_cop_override).
+:- test1(commit_label(label(_,_),_)). % expect fail, two matches
+:- test1(commit_label(label('Build-Cop-Override',_),_)). % good, one pass
+
+%% TODO: more test for is_exempt_from_reviews.
+
+%% Test needs_api_review, which checks commit_delta and project.
+% Helper functions:
+test_needs_api_review(File, Project, Tester) :-
+ redefine(commit_delta,1,(commit_delta(R) :- regex_matches(R, File))),
+ redefine(change_project,1,change_project(Project)),
+ Goal =.. [Tester, needs_api_review],
+ msg('# check CL with changed file ', File, ' in ', Project),
+ once((Goal ; true)). % do not backtrack
+
+:- test_needs_api_review('apio/test.cc', 'platform/art', test0).
+:- test_needs_api_review('api/test.cc', 'platform/art', test0).
+:- test_needs_api_review('api/test.cc', 'platform/prebuilts/sdk', test1).
+:- test_needs_api_review('d1/d2/api/test.cc', 'platform/prebuilts/sdk', test1).
+:- test_needs_api_review('system-api/d/t.c', 'platform/external/apache-http', test1).
+
+%% TODO: Test needs_drno_review, needs_qualcomm_review
+
+%% TODO: Test opt_out_find_owners.
+
+:- test1(opt_in_find_owners). % default, unless opt_out_find_owners
+
+:- end_tests_or_halt(1). % expect 1 failure of multiple commit_label
+
+%% Test remove_label
+:- begin_tests(t3_remove_label).
+
+:- test1(remove_label('MyReview',[],[])).
+:- test1(remove_label('MyReview',submit(),submit())).
+:- test1(remove_label(myR,[label(a,X)],[label(a,X)])).
+:- test1(remove_label(myR,[label(myR,_)],[])).
+:- test1(remove_label(myR,[label(a,X),label(myR,_)],[label(a,X)])).
+:- test1(remove_label(myR,submit(label(a,X)),submit(label(a,X)))).
+:- test1(remove_label(myR,submit(label(myR,_)),submit())).
+
+%% Test maplist
+double(X,Y) :- Y is X * X.
+:- test1(maplist(double, [2,4,6], [4,16,36])).
+:- test1(maplist(double, [], [])).
+
+:- end_tests_or_halt(0). % expect no failure
+
+%% TODO: Add more tests.
diff --git a/prologtests/examples/utils.pl b/prologtests/examples/utils.pl
new file mode 100644
index 0000000..8d15067
--- /dev/null
+++ b/prologtests/examples/utils.pl
@@ -0,0 +1,78 @@
+%% Unit test helpers
+
+% Write one line message.
+msg(A) :- write(A), nl.
+msg(A,B) :- write(A), msg(B).
+msg(A,B,C) :- write(A), msg(B,C).
+msg(A,B,C,D) :- write(A), msg(B,C,D).
+msg(A,B,C,D,E) :- write(A), msg(B,C,D,E).
+msg(A,B,C,D,E,F) :- write(A), msg(B,C,D,E,F).
+
+% Redefine a caluse.
+redefine(Atom,Arity,Clause) :- abolish(Atom/Arity), assertz(Clause).
+
+% Increment/decrement of pass/fail counters.
+set_counters(N,X,Y) :- redefine(test_count,3,test_count(N,X,Y)).
+get_counters(N,X,Y) :- clause(test_count(N,X,Y), _) -> true ; (X=0, Y=0).
+inc_pass_count :- get_counters(N,P,F), P1 is P + 1, set_counters(N,P1,F).
+inc_fail_count :- get_counters(N,P,F), F1 is F + 1, set_counters(N,P,F1).
+
+% Report pass or fail of G.
+pass_1(G) :- msg('PASS: ', G), inc_pass_count.
+fail_1(G) :- msg('FAIL: ', G), inc_fail_count.
+
+% Report pass or fail of not(G).
+pass_0(G) :- msg('PASS: not(', G, ')'), inc_pass_count.
+fail_0(G) :- msg('FAIL: not(', G, ')'), inc_fail_count.
+
+% Report a test as failed if it passed 2 or more times
+pass_twice(G) :-
+ msg('FAIL: (pass twice): ', G),
+ inc_fail_count.
+pass_many(G) :-
+ G = [A,B|_],
+ length(G, N),
+ msg('FAIL: (pass ', N, ' times): ', [A,B,'...']),
+ inc_fail_count.
+
+% Test if G fails.
+test0(G) :- once(G) -> fail_0(G) ; pass_0(G).
+
+% Test if G passes exactly once.
+test1(G) :-
+ findall(G, G, S), length(S, N),
+ (N == 0
+ -> fail_1(G)
+ ; (N == 1
+ -> pass_1(S)
+ ; (N == 2 -> pass_twice(S) ; pass_many(S))
+ )
+ ).
+
+% Report the begin of test N.
+begin_tests(N) :-
+ nl,
+ msg('BEGIN test ',N),
+ set_counters(N,0,0).
+
+% Repot the end of test N and total pass/fail counts,
+% and check if the numbers are as exected OutP/OutF.
+end_tests(OutP,OutF) :-
+ get_counters(N,P,F),
+ (OutP = P
+ -> msg('Expected #PASS: ', OutP)
+ ; (msg('ERROR: expected #PASS is ',OutP), !, fail)
+ ),
+ (OutF = F
+ -> msg('Expected #FAIL: ', OutF)
+ ; (msg('ERROR: expected #FAIL is ',OutF), !, fail)
+ ),
+ msg('END test ', N),
+ nl.
+
+% Repot the end of test N and total pass/fail counts.
+end_tests(N) :- end_tests(N,_,_).
+
+% Call end_tests/2 and halt if the fail count is unexpected.
+end_tests_or_halt(ExpectedFails) :-
+ end_tests(_,ExpectedFails); (flush_output, halt(1)).