frontend: split staging tests up into many small file tests#469
Conversation
This commit starts the process of splitting tests that use the
*assertModuleAtStage* test harness into using many small files
so that changes to the IR that affect many tests in cosmetic ways
can be handled more efficiently.
The process for deriving this change is more informative than the
exact changes, so a step by step description of that process follows.
This commit aims to define the file conventions, introduce the
machinery for running test from file trees, and creates the files
from the existing tests, but it leaves using the file trees to
specify test behaviour to a follow-on commit in the same PR.
----
I created a README for the resources sub-directory containing stage
test files so that the plan lives somewhere well documented.
I added an argument to express the subdirectory to assertModuleAtStage
and a function to decide whether to regenerate test files for test to
AssertModuleAtStage.kt. I threaded that through both
assertModuleAtStage variants. I also adjusted the module provisioning
block function to take a list of edits so that I can leverage calls to
assertModuleAtStage to regenerate both input files and outputs.
I factored out the default module provisioning block into a function
and refactored most tests that used the custom provisioning block to
delegate to it. The custom provisioning block is what will change to
read input files from under work.
I looked for existing test files.
```sh
git ls-files | grep frontend | xargs egrep -c assertModuleAtStage | \
egrep -v :0 | perl -pe 's/:\d+$//' > /tmp/stage-test-files
```
I manually edited that to remove the test harness file,
*AssertModuleAtStage.kt*, and removed *EndToEndTest.kt* after moving
its one useful test into *GenerateCodeStageTest.kt*.
I wrote a Perl file to insert the extra directory argument into each
test case. It started off just making sure that it can find files.
```perl
my $f = $ARGV[0];
open(IN, "<$f") or die "$!";
while (<IN>) {
if (m/assertModuleAtStage\(/) {
if (!m/^(\s*)fun (\w+)\(\) = assertModuleAtStage\($/) {
print "$f:$.: $_";
}
}
}
close(IN) or die "$!";
```
This just prints out a list of line:file entries for each one
that does not match an obvious pattern.
I ran it on each of the files from above:
```sh
for f in $(cat /tmp/stage-test-files); do perl /tmp/add-stage-dirs.perl $f; done
```
I iterated on that until there was nothing that did not match the pattern.
In the cases where a test was calling *assertModuleAtStage* inside its
body instead of the same line as the function name, I used the following
pattern:
```diff
@test
fun myTest() {
- assertModuleAtStage(
+ fun myTest() = assertModuleAtStage(
...
)
+ myTest().doNotCommit
}
```
Then I adjusted the perl script to pick a test directory for each test case.
```perl
my $f = $ARGV[0];
open(IN, "<$f") or die "$!";
my $stage = $f;
$stage =~ s/^.*[\/]//;
$stage =~ s/StageTest.kt$//;
$stage = "\l$stage";
$stage =~ s/[A-Z]/-\l$&/g;
my $content = "";
my $changed = 0;
while (<IN>) {
$content .= $_;
if (m/^(\s*)fun (\w+)\(\) = assertModuleAtStage\($/) {
$changed = 1;
my $ws = $1;
my $testName = $2;
$testName =~ s/[A-Z]/-\l$&/g;
my $std = "$stage/$testName";
$content .= qq'$ws stageTestDir = StageTestDir("$std"),\n';
}
}
close(IN) or die "$!";
if ($changed) {
my $outf = "$f.changed";
open(OUT, ">$outf") or die "$!";
print OUT $content;
close(OUT) or die "$!";
}
```
That spits out a `$f.changed` file for each input.
Given a test named `myTest` in `ParseStageTest` it will make these changes
```diff
fun myTest() = assertModuleAtStage(
+ stageTestDir = StageTestDir("parse/my-test"),
input = """
```
I manually diffed and replaced the test files with the `*.changed`
variants, ran the tests to make sure they still passed, and backed out
the *.doNotCommit* tweaks.
I made the new *stageTestDir* input mandatory and reran `gradle
frontend:check`.
I added some machinery for scanning test directories into file bundles
to `test-helpers/`. This includes definitions so that a test run can
inspect everything it has:
1. It can use the `input: String` and `languageConfig` parameters to spit out one or more `*.temper` or `*.temper.md` files.
2. Once that's done, we can get rid of those parameters and read files under `work/` to provision modules.
3. It can parse the `want: String` as JSON and factor that out into files under `expect/` files until we can get rid of that input.
4. Once we have the output files, it can look at those to figure out the maximum stage to advance to, getting rid of the `stage` parameter, and use the existence of files to drive building the `got` JSON bundle, and combine them into a `want` bundle to compare to `got`.
I reworked *assertModuleAtStage* and its helpers to do 1 and 3. I ran
it on some inputs in a mode where instead of writing files, it just
dumps the collected files to the console.
For step 1, I reused the code that splits a single input string into multiple mutually importable modules and just spit them out.
For step 3, I started with code like the below and iterated on it.
```kt
// key is like ".parse.body"
// Look for leaves and fail hard.
// I'll add entries until all tests pass.
// Then I'll have something that produces the files,
// and I'll have a function to reverse to read the
// files in.
fun walk(stage: Stage?, key: String, value: JsonValue) {
if (value is JsonLeaf<*>) {
when (value) {
is JsonString -> when (key) {
else -> TODO("$key got string")
}
is JsonBoolean -> when (key) {
else -> TODO("$key got bool")
}
is JsonDouble -> when (key) {
else -> TODO("$key got double")
}
is JsonLong -> when (key) {
else -> TODO("$key got long")
}
JsonNull -> when (key) {
else -> TODO("$key got null")
}
}
} else if (value is JsonObject) {
for (p in value) {
var nextStage = stage
var keySuffix = ".${p.key}"
if (key == "" && nextStage == null) {
nextStage = try {
Stage.valueOf(p.key.asciiTitleCase())
} catch (_: IllegalArgumentException) {
null
}
if (nextStage != null) {
keySuffix = ""
}
}
val nextKey = "$key$keySuffix"
walk(nextStage, nextKey, p.value)
}
} else {
TODO("$key $wantJson")
}
}
walk("", wantJson)
```
That let me piece together enough rules to decompose existing *want*
JSON dumps into strings.
I realized that `.stripDoubleHashCommentLinesToPutCommentsInlineBelow`
commentary is important in *input* and *want* strings.
The below reports 35 uses.
```sh
egrep -n '[.]stripDoubleHash' $(cat /tmp/stage-test-files) | \
grep -v import | wc -l
```
On inspection, all occurred inside the expected Temper pseudocode
string blocks.
The convention I established is, for `stage.temper` pseudocode files,
if the file is indented by two spaces ignoring `##` lines, then I'll
just strip out lines matching `/^##/` on reading and strip the
indented spaces off too.
I hacked together a JSON with triple-backquoted-string munger to
re-indent the `##` lines that appear inside them to get the right
output when generating files.
Future commits will switch to reading from the set of files under
the stage test root, use those to reconstruct the input and outputs,
and retire those input parameters to assertStageTest.
Signed-off-by: Mike Samuel <mikesamuel@gmail.com>
|
Seeing lots of 1-3 line files, I'm wondering if pick-and-choose for whether to break out a file might be nice. |
Signed-off-by: Mike Samuel <mikesamuel@gmail.com>
After switching parsing to use the input files, any TestFileBundle
entry that starts with `work`, I cut out the input strings.
I ran the following Perl script over the stage test Kotlin files.
```perl
use strict;
my $f = $ARGV[0];
open(IN, "<$f") or die "$f: $!";
my $content = "";
my $inParam = 0;
my $deferred = "";
while (<IN>) {
if (!$inParam && m/^\s*input\s*=\s*[\$]*"""$/) {
#print "switching to in param: $.: $_";
$inParam = 1;
$deferred = $_;
} elsif ($inParam) {
$deferred .= $_;
if (m/^\s*"""/) {
#print "switching to out param: $.: $_";
$inParam = 0;
if (m/^\s*"""(?:[.].*)?,$/) {
#print "dropping param";
$deferred = "";
} else {
$content .= $deferred;
}
}
} elsif (m/^\s*input\s*=\s*\$*"(?:[^"\\]|\\.)*",$/) {
# Skip it
} elsif (m/^\s*input\s*=\s*\$*""".*?""",$/) {
# Skip it
} else {
if (m/^\s*input\s*=/) {
print "$f:$.: Not matched: $_";
}
$content .= $_;
}
}
$content .= $deferred;
close(IN) or die "$f: $!";
open(OUT, ">$f.changed") or die "$f.changed: $!";
print OUT $content;
close(OUT) or die "$f: $!";
```
I used that on each of the stage test files manually.
```sh
perl /tmp/excise-inputs.perl frontend/src/commonTest/kotlin/lang/temper/frontend/GenerateCodeStageTest.kt
diff -uw frontend/src/commonTest/kotlin/lang/temper/frontend/GenerateCodeStageTest.kt{.changed,} | less
mv frontend/src/commonTest/kotlin/lang/temper/frontend/GenerateCodeStageTest.kt{.changed,}
```
Some inputs I had to fix up by hand like the below from GenerateCodeStageTest:
```kt
input = buildString {
append("export let numbers: Map<String, Int> = new Map<String, Int>([")
for (i in 0 until 1000) {
append(" new Pair<String, Int>(\"$i\", $i),")
}
append("]);")
},
```
Signed-off-by: Mike Samuel <mikesamuel@gmail.com>
After implementing support for building the wanted JSON from the data
files under expect, I modified the excise-inputs perl script to get
rid of existing want and stage inputs.
```perl
use strict;
my $f = $ARGV[0];
open(IN, "<$f") or die "$f: $!";
my $content = "";
my $inParam = 0;
my $deferred = "";
while (<IN>) {
if (!$inParam && m/^\s*want\s*=\s*[\$]*"""$/) {
#print "switching to in param: $.: $_";
$inParam = 1;
$deferred = $_;
} elsif ($inParam) {
$deferred .= $_;
if (m/\s*\@Test/) {
print "$f:$.: probable error $_";
}
if (m/^\s*"""/) {
#print "switching to out param: $.: $_";
$inParam = 0;
if (m/^\s*"""(?:[.].*)?,$/) {
#print "dropping param";
$deferred = "";
} else {
$content .= $deferred;
}
}
} elsif (m/^\s*want\s*=\s*\$*"(?:[^"\\]|\\.)*",$/) {
# Skip it
} elsif (m/^\s*want\s*=\s*\$*""".*?""",$/) {
# Skip it
} elsif (m/^\s*stage\s*=\s*\$*Stage[.]\w+,$/) {
# Skip it
} else {
if (m/^\s*want\s*=/) {
print "$f:$.: Not matched: $_";
}
$content .= $_;
}
}
$content .= $deferred;
close(IN) or die "$f: $!";
open(OUT, ">$f.changed") or die "$f.changed: $!";
print OUT $content;
close(OUT) or die "$f: $!";
```
Signed-off-by: Mike Samuel <mikesamuel@gmail.com>
Signed-off-by: Mike Samuel <mikesamuel@gmail.com>
Signed-off-by: Mike Samuel <mikesamuel@gmail.com>
|
I tweaked a test file, changing an I changed fun shouldRegenerateStageTest to return true and reran the tests and now there is a diff for an additional file |
|
Adding a test that the set of defined test directories matched the set of used ones and found out that my export of directories did not include |
…ctories for ignored tests that were not extracted during earlier steps Signed-off-by: Mike Samuel <mikesamuel@gmail.com>
| import lang.temper.common.structure.Hints | ||
| import kotlin.math.min | ||
|
|
||
| class JsonNestedObjectBuilder { |
There was a problem hiding this comment.
I combine a bunch of test files from the expect directory into a JSON object so that we can still get one-diff-to-expose-all-problems style failure.
To that end, I keep a mapping from relative file paths to JSON property chains (and a bit of other info) and feed them into this.
The set of small file definitions are in the README added in this which is probably a good place to start any review.
|
|
||
| private val crlfOrLfPattern = Regex("""\r\n?|\n""") | ||
|
|
||
| fun CharSequence.splitLinesPreservingTerminators(): List<String> { |
There was a problem hiding this comment.
Kotlin's split lines drops terminators so I just thought I'd write one that's good.
| pseudoCodeDetail: PseudoCodeDetail, | ||
| ) { | ||
| sink.key("code", Hints.s) { | ||
| sink.key("code", Hints.su) { |
There was a problem hiding this comment.
Make it more flexible as to which of lispy/pseudocode form is required.
There was a problem hiding this comment.
Here's where the bulk of the changes happen.
| * and comparing which allows using `git diff` to understand the consequences of a change to details of | ||
| * the frontend's intermediate representation. | ||
| */ | ||
| data class StageTestDir(val url: Url) { |
There was a problem hiding this comment.
StageTestDir is just a wrapper around a relative file path. It's the primary and only required input for stage tests now.
| } | ||
| } | ||
|
|
||
| internal fun provisionModuleForStageTest( |
There was a problem hiding this comment.
Tests that want to customize the builtin environment were doing a bunch of different hacks to provision the test module, but now they just call into here.
That cleanup was useful to extract test inputs out into files, but seems generally cleaner.
| val converter: DataFileConverter, | ||
| ) | ||
|
|
||
| private val testResourceFileRelationships: Map<FilePath, TestResourceFileRelationship> = |
There was a problem hiding this comment.
This mimics the file structure explained in the README file farther down.
| @Test | ||
| fun compileLogExecutionOrder() = assertModuleAtStage( | ||
| stageTestDir = StageTestDir("function-macro/compile-log-execution-order"), | ||
| stage = Stage.FunctionMacro, |
There was a problem hiding this comment.
Still specifies a stage so that it doesn't conclude Stage.Run just because there's an expected stdout.
There was a problem hiding this comment.
A lot of added test files. I describe the process for extracting them so that's something you could look at instead of looking at each file.
The README file is worth a look though.
There was a problem hiding this comment.
And here's the README I keep banging on about.
Signed-off-by: Mike Samuel <mikesamuel@gmail.com>
| ignore(stageTestDir) | ||
| ignore(isEmpty) | ||
|
|
||
| return false |
There was a problem hiding this comment.
You can flip this to true and the test runner will regenerate
expect/*files for tests that fail.
So shouldRegenerateStageTest becomes an important name to remember?
| theCount__0 = type (TheCount__0); | ||
| do { | ||
| let accumulator#0; | ||
| ## The tag is used to create an accumulator |
There was a problem hiding this comment.
I skimmed the description but likely missed some info. How do ## comments get generated?
There was a problem hiding this comment.
I skimmed the description but likely missed some info. How do
##comments get generated?
Went back to the description and found this now:
I hacked together a JSON with triple-backquoted-string munger to re-indent the
##lines that appear inside them to get the right output when generating files.
This commit starts the process of splitting tests that use the assertModuleAtStage test harness into using many small files so that changes to the IR that affect many tests in cosmetic ways can be handled more efficiently.
The process for deriving this change is more informative than the exact changes, so a step by step description of that process follows.
This commit aims to define the file conventions, introduce the machinery for running test from file trees, and creates the files from the existing tests, but it leaves using the file trees to specify test behaviour to a follow-on commit in the same PR.
I created a README for the resources sub-directory containing stage test files so that the plan lives somewhere well documented.
I added an argument to express the subdirectory to assertModuleAtStage and a function to decide whether to regenerate test files for test to AssertModuleAtStage.kt. I threaded that through both assertModuleAtStage variants. I also adjusted the module provisioning block function to take a list of edits so that I can leverage calls to assertModuleAtStage to regenerate both input files and outputs.
I factored out the default module provisioning block into a function and refactored most tests that used the custom provisioning block to delegate to it. The custom provisioning block is what will change to read input files from under work.
I looked for existing test files.
I manually edited that to remove the test harness file, AssertModuleAtStage.kt, and removed EndToEndTest.kt after moving its one useful test into GenerateCodeStageTest.kt.
I wrote a Perl file to insert the extra directory argument into each test case. It started off just making sure that it can find files.
This just prints out a list of line:file entries for each one that does not match an obvious pattern.
I ran it on each of the files from above:
I iterated on that until there was nothing that did not match the pattern.
In the cases where a test was calling assertModuleAtStage inside its body instead of the same line as the function name, I used the following pattern:
@Test fun myTest() { - assertModuleAtStage( + fun myTest() = assertModuleAtStage( ... ) + myTest().doNotCommit }Then I adjusted the perl script to pick a test directory for each test case.
That spits out a
$f.changedfile for each input. Given a test namedmyTestinParseStageTestit will make these changesfun myTest() = assertModuleAtStage( + stageTestDir = StageTestDir("parse/my-test"), input = """I manually diffed and replaced the test files with the
*.changedvariants, ran the tests to make sure they still passed, and backed out the .doNotCommit tweaks.I made the new stageTestDir input mandatory and reran
gradle frontend:check.I added some machinery for scanning test directories into file bundles to
test-helpers/. This includes definitions so that a test run can inspect everything it has:input: StringandlanguageConfigparameters to spit out one or more*.temperor*.temper.mdfiles.work/to provision modules.want: Stringas JSON and factor that out into files underexpect/files until we can get rid of that input.stageparameter, and use the existence of files to drive building thegotJSON bundle, and combine them into awantbundle to compare togot.I reworked assertModuleAtStage and its helpers to do 1 and 3. I ran it on some inputs in a mode where instead of writing files, it just dumps the collected files to the console.
For step 1, I reused the code that splits a single input string into multiple mutually importable modules and just spit them out.
For step 3, I started with code like the below and iterated on it.
That let me piece together enough rules to decompose existing want JSON dumps into strings.
I realized that
.stripDoubleHashCommentLinesToPutCommentsInlineBelowcommentary is important in input and want strings. The below reports 35 uses.On inspection, all occurred inside the expected Temper pseudocode string blocks.
The convention I established is, for
stage.temperpseudocode files, if the file is indented by two spaces ignoring##lines, then I'll just strip out lines matching/^##/on reading and strip the indented spaces off too.I hacked together a JSON with triple-backquoted-string munger to re-indent the
##lines that appear inside them to get the right output when generating files.Future commits will switch to reading from the set of files under the stage test root, use those to reconstruct the input and outputs, and retire those input parameters to assertStageTest.
I refactored assertModuleAtStage to use input files instead of input strings.
After switching parsing to use the input files, any TestFileBundle
entry that starts with
work, I cut out the input strings.I ran the following Perl script over the stage test Kotlin files.
I used that on each of the stage test files manually.
perl /tmp/excise-inputs.perl frontend/src/commonTest/kotlin/lang/temper/frontend/GenerateCodeStageTest.kt diff -uw frontend/src/commonTest/kotlin/lang/temper/frontend/GenerateCodeStageTest.kt{.changed,} | less mv frontend/src/commonTest/kotlin/lang/temper/frontend/GenerateCodeStageTest.kt{.changed,}Some inputs I had to fix up by hand like the below from GenerateCodeStageTest:
After implementing support for building the wanted JSON from the data
files under expect, I modified the excise-inputs perl script to get
rid of existing want and stage inputs.
I implemented regeneration of
expect/*staging test files from the "got" JSON.I added a test that all stage test dirs are used and revivified test
directories for
@Ignored tests that were not extracted during earliersteps.