diff --git a/Documentation/prolog-cookbook.txt b/Documentation/prolog-cookbook.txt index 9a23a27b36..f291920d4a 100644 --- a/Documentation/prolog-cookbook.txt +++ b/Documentation/prolog-cookbook.txt @@ -74,6 +74,9 @@ For interactive testing and playing with Prolog, Gerrit provides the 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/prologtests/examples/BUILD b/prologtests/examples/BUILD new file mode 100644 index 0000000000..4048bc78d2 --- /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 0000000000..12eb256ec6 --- /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 0000000000..18e8a73fd6 --- /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 0000000000..f5b49e8aa0 --- /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 0000000000..1a7b17cecf --- /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 0000000000..947c1532f6 --- /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 0000000000..caf906192c --- /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 0000000000..9424b5330d --- /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 0000000000..02badc040d --- /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 0000000000..8d15067789 --- /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)).