diff --git a/build.gradle b/build.gradle
index 5bdaa0561e..969ff850c4 100644
--- a/build.gradle
+++ b/build.gradle
@@ -44,13 +44,30 @@ jacocoTestReport {
dependencies {
implementation 'com.fulcrumgenomics:jlibdeflate:0.1.0'
- implementation 'commons-logging:commons-logging:1.3.0'
implementation "org.xerial.snappy:snappy-java:1.1.10.5"
implementation 'org.apache.commons:commons-compress:1.26.0'
implementation 'org.tukaani:xz:1.9'
implementation "org.json:json:20231013"
- implementation 'org.openjdk.nashorn:nashorn-core:15.4'
-
+
+ // commons-jexl 2.1.1 pulls commons-logging:1.1.1 (released 2007). htsjdk has no direct
+ // need for commons-logging itself, so we publish a version constraint rather than a real
+ // dependency: it kicks in only if commons-logging is pulled transitively, and bumps it to
+ // a maintained version. Drop this if commons-jexl is ever upgraded past 2.1.1.
+ constraints {
+ implementation('commons-logging:commons-logging:1.3.0') {
+ because 'jexl 2.1.1 pulls commons-logging 1.1.1 transitively; pin a maintained version'
+ }
+ }
+
+ // Nashorn is the JSR-223 "js" engine used by the optional JavaScript filter classes
+ // (htsjdk.samtools.filter.JavascriptSamRecordFilter, htsjdk.variant.variantcontext.filter.JavascriptVariantFilter).
+ // It's compileOnly so downstream consumers who don't use those filter classes don't pay the
+ // cost of nashorn-core + 5 ASM artifacts on their runtime classpath. Consumers who do use
+ // them must add nashorn-core to their own runtime classpath; see the error message thrown
+ // by AbstractJavascriptFilter when no JS engine is found.
+ compileOnly 'org.openjdk.nashorn:nashorn-core:15.7'
+ testImplementation 'org.openjdk.nashorn:nashorn-core:15.7'
+
api "org.apache.commons:commons-jexl:2.1.1"
testImplementation 'org.testng:testng:7.8.0'
diff --git a/src/main/java/htsjdk/samtools/filter/AbstractJavascriptFilter.java b/src/main/java/htsjdk/samtools/filter/AbstractJavascriptFilter.java
index 5a2668d2c4..a74c96d1c7 100644
--- a/src/main/java/htsjdk/samtools/filter/AbstractJavascriptFilter.java
+++ b/src/main/java/htsjdk/samtools/filter/AbstractJavascriptFilter.java
@@ -85,8 +85,7 @@ protected AbstractJavascriptFilter(final Reader scriptReader, final HEADER heade
final ScriptEngine engine = manager.getEngineByName("js");
if (engine == null) {
CloserUtil.close(scriptReader);
- throw new RuntimeScriptException("The embedded 'javascript' engine is not available in java. "
- + "Do you use the SUN/Oracle Java Runtime ?");
+ throw new RuntimeScriptException(noJsEngineMessage(this.getClass().getSimpleName()));
}
if (scriptReader == null) {
throw new RuntimeScriptException("missing ScriptReader.");
@@ -109,6 +108,30 @@ protected AbstractJavascriptFilter(final Reader scriptReader, final HEADER heade
this.bindings.put(DEFAULT_HEADER_KEY, header);
}
+ static String noJsEngineMessage(final String filterClassName) {
+ return String.join(
+ "\n",
+ "No JSR-223 JavaScript engine (lookup name \"js\") was found on the classpath.",
+ "",
+ "Starting with htsjdk 5.0.0, htsjdk no longer ships a JavaScript engine as a runtime",
+ "dependency, so that consumers who do not use the JavaScript filter classes do not pay",
+ "the cost of carrying ~6 extra jars (nashorn-core plus its ASM transitives, ~2.5 MB).",
+ "",
+ "To use " + filterClassName + ", add a JSR-223-compatible JavaScript engine to your",
+ "runtime classpath. The recommended choice is OpenJDK Nashorn:",
+ "",
+ " Gradle: runtimeOnly 'org.openjdk.nashorn:nashorn-core:15.7'",
+ "",
+ " Maven: ",
+ " org.openjdk.nashorn",
+ " nashorn-core",
+ " 15.7",
+ " runtime",
+ " ",
+ "",
+ "Any other JSR-223 engine that registers under the name \"js\" will also work.");
+ }
+
/** return a javascript engine as a Compilable */
private static Compilable getCompilable(final ScriptEngine engine) {
if (!(engine instanceof Compilable)) {
diff --git a/src/main/java/htsjdk/samtools/filter/JavascriptSamRecordFilter.java b/src/main/java/htsjdk/samtools/filter/JavascriptSamRecordFilter.java
index 2b10a99eb0..0a452dc3d9 100644
--- a/src/main/java/htsjdk/samtools/filter/JavascriptSamRecordFilter.java
+++ b/src/main/java/htsjdk/samtools/filter/JavascriptSamRecordFilter.java
@@ -30,16 +30,26 @@
import java.io.Reader;
/**
- * javascript based read filter
+ * JavaScript-based {@link SamRecordFilter}.
*
+ *
The user-supplied script is evaluated against each {@link SAMRecord} with the following
+ * variables in scope:
*
- * The script puts the following variables in the script context:
+ *
+ * - {@code record} - the {@link SAMRecord} being evaluated
+ * - {@code header} - the {@link SAMFileHeader} associated with the reader
+ *
*
- * - 'record' a SamRecord (
- * https://github.com/samtools/htsjdk/blob/master/src/java/htsjdk/samtools/
- * SAMRecord.java ) - 'header' (
- * https://github.com/samtools/htsjdk/blob/master/src/java/htsjdk/samtools/
- * SAMFileHeader.java )
+ * Example: keep only records with mapping quality >= 30:
+ *
{@code
+ * new JavascriptSamRecordFilter("record.getMappingQuality() >= 30;", header)
+ * }
+ *
+ * Runtime requirement: as of htsjdk 5.0.0, htsjdk does not ship a JavaScript engine as
+ * a runtime dependency. To use this class, add a JSR-223-compatible JavaScript engine
+ * (e.g. {@code org.openjdk.nashorn:nashorn-core}) to your runtime classpath. If no engine is
+ * available, the constructor throws a {@link htsjdk.samtools.util.RuntimeScriptException} whose
+ * message lists the dependency coordinates.
*
* @author Pierre Lindenbaum PhD Institut du Thorax - INSERM - Nantes - France
*/
diff --git a/src/main/java/htsjdk/variant/variantcontext/filter/JavascriptVariantFilter.java b/src/main/java/htsjdk/variant/variantcontext/filter/JavascriptVariantFilter.java
index dedfaef253..61661754d8 100644
--- a/src/main/java/htsjdk/variant/variantcontext/filter/JavascriptVariantFilter.java
+++ b/src/main/java/htsjdk/variant/variantcontext/filter/JavascriptVariantFilter.java
@@ -31,11 +31,26 @@
import java.io.Reader;
/**
- * javascript based variant filter The script puts the following variables in
- * the script context:
+ * JavaScript-based {@link VariantContextFilter}.
*
- * - 'header' a htsjdk.variant.vcf.VCFHeader
- * - 'variant' a htsjdk.variant.variantcontext.VariantContext
+ *
The user-supplied script is evaluated against each {@link VariantContext} with the following
+ * variables in scope:
+ *
+ *
+ * - {@code variant} - the {@link VariantContext} being evaluated
+ * - {@code header} - the {@link VCFHeader} associated with the reader
+ *
+ *
+ * Example: keep only variants on chromosome 1:
+ *
{@code
+ * new JavascriptVariantFilter("variant.getContig() == '1';", header)
+ * }
+ *
+ * Runtime requirement: as of htsjdk 5.0.0, htsjdk does not ship a JavaScript engine as
+ * a runtime dependency. To use this class, add a JSR-223-compatible JavaScript engine
+ * (e.g. {@code org.openjdk.nashorn:nashorn-core}) to your runtime classpath. If no engine is
+ * available, the constructor throws a {@link htsjdk.samtools.util.RuntimeScriptException} whose
+ * message lists the dependency coordinates.
*
* @author Pierre Lindenbaum PhD Institut du Thorax - INSERM - Nantes - France
*/
diff --git a/src/test/java/htsjdk/samtools/filter/JavascriptSamRecordFilterTest.java b/src/test/java/htsjdk/samtools/filter/JavascriptSamRecordFilterTest.java
index 6e15f96571..0a4958260c 100644
--- a/src/test/java/htsjdk/samtools/filter/JavascriptSamRecordFilterTest.java
+++ b/src/test/java/htsjdk/samtools/filter/JavascriptSamRecordFilterTest.java
@@ -24,50 +24,266 @@
package htsjdk.samtools.filter;
import htsjdk.HtsjdkTest;
-import htsjdk.samtools.SAMRecordIterator;
-import htsjdk.samtools.SamReader;
-import htsjdk.samtools.SamReaderFactory;
-import htsjdk.samtools.util.CloserUtil;
+import htsjdk.samtools.SAMFileHeader;
+import htsjdk.samtools.SAMRecord;
+import htsjdk.samtools.SAMSequenceRecord;
+import htsjdk.samtools.util.RuntimeScriptException;
import java.io.File;
import java.io.IOException;
+import java.io.StringReader;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.util.ArrayList;
+import java.util.List;
import org.testng.Assert;
-import org.testng.annotations.DataProvider;
import org.testng.annotations.Test;
/**
- * @author Pierre Lindenbaum PhD Institut du Thorax - INSERM - Nantes - France
+ * Unit tests for {@link JavascriptSamRecordFilter}. Tests are written as many small, focused
+ * cases against inline String scripts so the script body is visible next to the assertion.
+ *
+ *
The base contract being tested ({@link AbstractJavascriptFilter#accept}):
+ *
+ * - script returns {@code null} or {@code undefined} -> reject
+ * - script returns a {@code Boolean} -> accept iff true
+ * - script returns a {@code Number} -> accept iff intValue() == 1
+ * - script returns anything else -> reject
+ *
+ * (here "accept" / "reject" describe what {@code accept()} returns; {@code filterOut()} negates).
*/
public class JavascriptSamRecordFilterTest extends HtsjdkTest {
- final File testDir = new File("./src/test/resources/htsjdk/samtools");
- @DataProvider
- public Object[][] jsData() {
- return new Object[][] {
- {"unsorted.sam", "samFilter01.js", 8}, {"unsorted.sam", "samFilter02.js", 10},
- };
+ // --- helpers ----------------------------------------------------------------------------------
+
+ /** A minimal header with one sequence "chr1" of length 1000. */
+ private static SAMFileHeader header() {
+ SAMFileHeader h = new SAMFileHeader();
+ h.addSequence(new SAMSequenceRecord("chr1", 1000));
+ return h;
+ }
+
+ /** Build a mapped fragment record on chr1 at the given start with the given read name and bases. */
+ private static SAMRecord record(SAMFileHeader h, String name, int start, String bases) {
+ SAMRecord r = new SAMRecord(h);
+ r.setReadName(name);
+ r.setReferenceName("chr1");
+ r.setAlignmentStart(start);
+ r.setReadString(bases);
+ r.setBaseQualityString("I".repeat(bases.length()));
+ r.setMappingQuality(60);
+ r.setCigarString(bases.length() + "M");
+ r.setReadUnmappedFlag(false);
+ return r;
+ }
+
+ /** True iff the given script accepts the given record (i.e. filterOut returns false). */
+ private static boolean accepts(String script, SAMFileHeader h, SAMRecord r) {
+ return !new JavascriptSamRecordFilter(script, h).filterOut(r);
+ }
+
+ // --- constructors -----------------------------------------------------------------------------
+
+ @Test
+ public void stringConstructor_compilesAndRuns() {
+ Assert.assertTrue(accepts("true;", header(), record(header(), "r", 100, "AAAA")));
+ }
+
+ @Test
+ public void readerConstructor_compilesAndRuns() {
+ SAMFileHeader h = header();
+ JavascriptSamRecordFilter f = new JavascriptSamRecordFilter(new StringReader("true;"), h);
+ Assert.assertFalse(f.filterOut(record(h, "r", 100, "AAAA")));
+ }
+
+ @Test
+ public void fileConstructor_compilesAndRuns() throws IOException {
+ File scriptFile = File.createTempFile("filter", ".js");
+ scriptFile.deleteOnExit();
+ Files.writeString(scriptFile.toPath(), "true;", StandardCharsets.UTF_8);
+ SAMFileHeader h = header();
+ JavascriptSamRecordFilter f = new JavascriptSamRecordFilter(scriptFile, h);
+ Assert.assertFalse(f.filterOut(record(h, "r", 100, "AAAA")));
+ }
+
+ // --- return-type semantics --------------------------------------------------------------------
+
+ @Test
+ public void scriptReturnsTrue_isAccepted() {
+ Assert.assertTrue(accepts("true;", header(), record(header(), "r", 100, "A")));
+ }
+
+ @Test
+ public void scriptReturnsFalse_isRejected() {
+ Assert.assertFalse(accepts("false;", header(), record(header(), "r", 100, "A")));
+ }
+
+ @Test
+ public void scriptReturnsOne_isAccepted() {
+ Assert.assertTrue(accepts("1;", header(), record(header(), "r", 100, "A")));
+ }
+
+ @Test
+ public void scriptReturnsZero_isRejected() {
+ Assert.assertFalse(accepts("0;", header(), record(header(), "r", 100, "A")));
+ }
+
+ @Test
+ public void scriptReturnsTwo_isRejected() {
+ // accept() requires intValue() == 1, not "any truthy number".
+ Assert.assertFalse(accepts("2;", header(), record(header(), "r", 100, "A")));
+ }
+
+ @Test
+ public void scriptReturnsNegativeOne_isRejected() {
+ Assert.assertFalse(accepts("-1;", header(), record(header(), "r", 100, "A")));
+ }
+
+ @Test
+ public void scriptReturnsOnePointZero_isAccepted() {
+ // 1.0 has intValue() == 1, so this should be accepted under the documented contract.
+ Assert.assertTrue(accepts("1.0;", header(), record(header(), "r", 100, "A")));
+ }
+
+ @Test
+ public void scriptReturnsNull_isRejected() {
+ Assert.assertFalse(accepts("null;", header(), record(header(), "r", 100, "A")));
+ }
+
+ @Test
+ public void scriptReturnsUndefined_isRejected() {
+ Assert.assertFalse(accepts("undefined;", header(), record(header(), "r", 100, "A")));
+ }
+
+ @Test
+ public void scriptReturnsString_isRejected() {
+ // Non-null, non-Boolean, non-Number falls into the "anything else" branch -> reject.
+ Assert.assertFalse(accepts("'hello';", header(), record(header(), "r", 100, "A")));
}
- @Test(dataProvider = "jsData")
- public void testJavascriptFilters(final String samFile, final String javascriptFile, final int expectCount) {
- final SamReaderFactory srf = SamReaderFactory.makeDefault();
- final SamReader samReader = srf.open(new File(testDir, samFile));
- final JavascriptSamRecordFilter filter;
- try {
- filter = new JavascriptSamRecordFilter(new File(testDir, javascriptFile), samReader.getFileHeader());
- } catch (IOException err) {
- Assert.fail("Cannot read script", err);
- return;
- }
- final SAMRecordIterator iter = samReader.iterator();
- int count = 0;
- while (iter.hasNext()) {
- if (filter.filterOut(iter.next())) {
- continue;
+ // --- bindings: record key ---------------------------------------------------------------------
+
+ @Test
+ public void scriptCanCallMethodsOnRecord() {
+ SAMFileHeader h = header();
+ Assert.assertTrue(accepts("record.getReadName() == 'target';", h, record(h, "target", 100, "A")));
+ Assert.assertFalse(accepts("record.getReadName() == 'target';", h, record(h, "other", 100, "A")));
+ }
+
+ @Test
+ public void scriptCanReadAlignmentStart() {
+ SAMFileHeader h = header();
+ Assert.assertTrue(accepts("record.getAlignmentStart() == 250;", h, record(h, "r", 250, "A")));
+ Assert.assertFalse(accepts("record.getAlignmentStart() == 250;", h, record(h, "r", 100, "A")));
+ }
+
+ @Test
+ public void scriptCanReadReadString() {
+ SAMFileHeader h = header();
+ SAMRecord r = record(h, "r", 100, "ACGT");
+ Assert.assertTrue(accepts("record.getReadString() == 'ACGT';", h, r));
+ }
+
+ // --- bindings: header key ---------------------------------------------------------------------
+
+ @Test
+ public void scriptCanReachHeader() {
+ SAMFileHeader h = header();
+ // header is bound under "header"; ensure it's reachable and methods work.
+ Assert.assertTrue(accepts("header.getSequenceDictionary().size() == 1;", h, record(h, "r", 100, "A")));
+ }
+
+ @Test
+ public void scriptCanReadSequenceFromHeader() {
+ SAMFileHeader h = header();
+ Assert.assertTrue(accepts(
+ "header.getSequenceDictionary().getSequence(0).getSequenceName() == 'chr1';",
+ h,
+ record(h, "r", 100, "A")));
+ }
+
+ // --- subclass record-key override -------------------------------------------------------------
+
+ @Test
+ public void subclassCanOverrideRecordKey() {
+ SAMFileHeader h = header();
+ // Subclass that exposes the record under "rec" instead of "record".
+ JavascriptSamRecordFilter f = new JavascriptSamRecordFilter("rec.getReadName() == 'r';", h) {
+ @Override
+ public String getRecordKey() {
+ return "rec";
}
- ++count;
- }
- iter.close();
- CloserUtil.close(samReader);
- Assert.assertEquals(count, expectCount, "Expected number of reads " + expectCount + " but got " + count);
+ };
+ Assert.assertFalse(f.filterOut(record(h, "r", 100, "A")));
+ Assert.assertTrue(f.filterOut(record(h, "other", 100, "A")));
+ }
+
+ // --- error paths ------------------------------------------------------------------------------
+
+ @Test(expectedExceptions = RuntimeScriptException.class)
+ public void malformedScript_throwsAtConstruction() {
+ new JavascriptSamRecordFilter("this is not javascript ;)", header());
+ }
+
+ @Test(expectedExceptions = RuntimeException.class)
+ public void scriptThatThrowsAtEval_propagatesAsRuntimeException() {
+ SAMFileHeader h = header();
+ JavascriptSamRecordFilter f = new JavascriptSamRecordFilter("throw 'boom';", h);
+ f.filterOut(record(h, "r", 100, "A"));
+ }
+
+ // --- filterOut(first, second) AND-semantics ---------------------------------------------------
+
+ @Test
+ public void filterOutPair_bothRejected_isFilteredOut() {
+ SAMFileHeader h = header();
+ // "rejected" means accept() returns false, i.e. filterOut() returns true.
+ // Script rejects everything -> both reject -> AND -> true.
+ JavascriptSamRecordFilter f = new JavascriptSamRecordFilter("false;", h);
+ Assert.assertTrue(f.filterOut(record(h, "a", 100, "A"), record(h, "b", 200, "A")));
+ }
+
+ @Test
+ public void filterOutPair_oneAccepted_isKept() {
+ // "kept" means filterOut returns false, i.e. accept-or branch wins.
+ SAMFileHeader h = header();
+ // Script accepts only reads named "a"; pair has one of each -> AND of (false, true) -> false -> kept.
+ JavascriptSamRecordFilter f = new JavascriptSamRecordFilter("record.getReadName() == 'a';", h);
+ Assert.assertFalse(f.filterOut(record(h, "a", 100, "A"), record(h, "b", 200, "A")));
+ }
+
+ @Test
+ public void filterOutPair_bothAccepted_isKept() {
+ SAMFileHeader h = header();
+ JavascriptSamRecordFilter f = new JavascriptSamRecordFilter("true;", h);
+ Assert.assertFalse(f.filterOut(record(h, "a", 100, "A"), record(h, "b", 200, "A")));
+ }
+
+ // --- multi-record reuse -----------------------------------------------------------------------
+
+ // --- error message for the no-engine path ----------------------------------------------------
+
+ @Test
+ public void noJsEngineMessage_containsActionableInfo() {
+ // The "no engine on classpath" error is the primary UX for downstream consumers who
+ // forget the runtime dep, so verify it stays actionable.
+ String msg = AbstractJavascriptFilter.noJsEngineMessage("JavascriptSamRecordFilter");
+ Assert.assertTrue(msg.contains("JavascriptSamRecordFilter"), "message should name the filter class");
+ Assert.assertTrue(msg.contains("nashorn-core"), "message should name the recommended artifact");
+ Assert.assertTrue(msg.contains("Gradle"), "message should show how to add it via Gradle");
+ Assert.assertTrue(msg.contains("Maven"), "message should show how to add it via Maven");
+ }
+
+ @Test
+ public void filterRefreshesBindingsAcrossManyRecords() {
+ SAMFileHeader h = header();
+ JavascriptSamRecordFilter f = new JavascriptSamRecordFilter("record.getAlignmentStart() >= 200;", h);
+
+ List records = new ArrayList<>();
+ for (int i = 1; i <= 4; i++) records.add(record(h, "r" + i, i * 100, "A"));
+
+ // Expected: r1@100 -> reject, r2@200/r3@300/r4@400 -> accept.
+ int kept = 0;
+ for (SAMRecord r : records) if (!f.filterOut(r)) kept++;
+ Assert.assertEquals(kept, 3);
}
}
diff --git a/src/test/java/htsjdk/variant/variantcontext/filter/JavascriptVariantFilterTest.java b/src/test/java/htsjdk/variant/variantcontext/filter/JavascriptVariantFilterTest.java
index b1538745fe..38015e8178 100644
--- a/src/test/java/htsjdk/variant/variantcontext/filter/JavascriptVariantFilterTest.java
+++ b/src/test/java/htsjdk/variant/variantcontext/filter/JavascriptVariantFilterTest.java
@@ -24,48 +24,206 @@
package htsjdk.variant.variantcontext.filter;
import htsjdk.HtsjdkTest;
-import htsjdk.variant.vcf.VCFFileReader;
+import htsjdk.samtools.util.RuntimeScriptException;
+import htsjdk.variant.variantcontext.VariantContext;
+import htsjdk.variant.variantcontext.VariantContextBuilder;
+import htsjdk.variant.vcf.VCFContigHeaderLine;
+import htsjdk.variant.vcf.VCFHeader;
+import htsjdk.variant.vcf.VCFHeaderLine;
import java.io.File;
import java.io.IOException;
+import java.io.StringReader;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
import org.testng.Assert;
-import org.testng.annotations.DataProvider;
import org.testng.annotations.Test;
/**
- * @author Pierre Lindenbaum PhD Institut du Thorax - INSERM - Nantes - France
+ * Unit tests for {@link JavascriptVariantFilter}. Mirrors the structure of
+ * {@code JavascriptSamRecordFilterTest}: short, focused tests written against inline String scripts
+ * so the body is visible next to the assertion.
+ *
+ * The base contract being tested ({@link htsjdk.samtools.filter.AbstractJavascriptFilter#accept}):
+ *
+ * - script returns {@code null} or {@code undefined} -> reject
+ * - script returns a {@code Boolean} -> accept iff true
+ * - script returns a {@code Number} -> accept iff intValue() == 1
+ * - script returns anything else -> reject
+ *
*/
public class JavascriptVariantFilterTest extends HtsjdkTest {
- final File testDir = new File("src/test/resources/htsjdk/variant");
-
- @DataProvider
- public Object[][] jsData() {
- return new Object[][] {
- {"ILLUMINA.wex.broad_phase2_baseline.20111114.both.exome.genotypes.1000.vcf", "variantFilter01.js", 61},
- {"ILLUMINA.wex.broad_phase2_baseline.20111114.both.exome.genotypes.1000.vcf", "variantFilter02.js", 38},
- };
- }
-
- @Test(dataProvider = "jsData")
- public void testJavascriptFilters(final String vcfFile, final String javascriptFile, final int expectCount) {
- final File vcfInput = new File(testDir, vcfFile);
- final File jsInput = new File(testDir, javascriptFile);
- final VCFFileReader vcfReader = new VCFFileReader(vcfInput, false);
- final JavascriptVariantFilter filter;
- try {
- filter = new JavascriptVariantFilter(jsInput, vcfReader.getFileHeader());
- } catch (IOException err) {
- Assert.fail("cannot read script " + jsInput, err);
- vcfReader.close();
- return;
- }
- final FilteringVariantContextIterator iter = new FilteringVariantContextIterator(vcfReader.iterator(), filter);
- int count = 0;
- while (iter.hasNext()) {
- iter.next();
- ++count;
- }
- iter.close();
- vcfReader.close();
- Assert.assertEquals(count, expectCount, "Expected number of variants " + expectCount + " but got " + count);
+
+ // --- helpers ----------------------------------------------------------------------------------
+
+ /** A minimal header with one contig "chr1" of length 1000 and no samples. */
+ private static VCFHeader header() {
+ Map contigFields = new LinkedHashMap<>();
+ contigFields.put("ID", "chr1");
+ contigFields.put("length", "1000");
+ Set meta = new LinkedHashSet<>();
+ meta.add(new VCFContigHeaderLine(contigFields, 0));
+ return new VCFHeader(meta);
+ }
+
+ /** Build a SNP variant on chr1 at the given position with the given REF/ALT alleles. */
+ private static VariantContext snv(String contig, int pos, String ref, String alt) {
+ return new VariantContextBuilder()
+ .source("test")
+ .chr(contig)
+ .start(pos)
+ .stop(pos)
+ .alleles(ref, alt)
+ .make();
+ }
+
+ /** True iff the given script accepts the given variant. */
+ private static boolean accepts(String script, VCFHeader h, VariantContext v) {
+ return new JavascriptVariantFilter(script, h).test(v);
+ }
+
+ // --- constructors -----------------------------------------------------------------------------
+
+ @Test
+ public void stringConstructor_compilesAndRuns() {
+ Assert.assertTrue(accepts("true;", header(), snv("chr1", 100, "A", "C")));
+ }
+
+ @Test
+ public void readerConstructor_compilesAndRuns() throws IOException {
+ VCFHeader h = header();
+ JavascriptVariantFilter f = new JavascriptVariantFilter(new StringReader("true;"), h);
+ Assert.assertTrue(f.test(snv("chr1", 100, "A", "C")));
+ }
+
+ @Test
+ public void fileConstructor_compilesAndRuns() throws IOException {
+ File scriptFile = File.createTempFile("filter", ".js");
+ scriptFile.deleteOnExit();
+ Files.writeString(scriptFile.toPath(), "true;", StandardCharsets.UTF_8);
+ VCFHeader h = header();
+ JavascriptVariantFilter f = new JavascriptVariantFilter(scriptFile, h);
+ Assert.assertTrue(f.test(snv("chr1", 100, "A", "C")));
+ }
+
+ // --- return-type semantics --------------------------------------------------------------------
+
+ @Test
+ public void scriptReturnsTrue_isAccepted() {
+ Assert.assertTrue(accepts("true;", header(), snv("chr1", 100, "A", "C")));
+ }
+
+ @Test
+ public void scriptReturnsFalse_isRejected() {
+ Assert.assertFalse(accepts("false;", header(), snv("chr1", 100, "A", "C")));
+ }
+
+ @Test
+ public void scriptReturnsOne_isAccepted() {
+ Assert.assertTrue(accepts("1;", header(), snv("chr1", 100, "A", "C")));
+ }
+
+ @Test
+ public void scriptReturnsZero_isRejected() {
+ Assert.assertFalse(accepts("0;", header(), snv("chr1", 100, "A", "C")));
+ }
+
+ @Test
+ public void scriptReturnsTwo_isRejected() {
+ Assert.assertFalse(accepts("2;", header(), snv("chr1", 100, "A", "C")));
+ }
+
+ @Test
+ public void scriptReturnsNegativeOne_isRejected() {
+ Assert.assertFalse(accepts("-1;", header(), snv("chr1", 100, "A", "C")));
+ }
+
+ @Test
+ public void scriptReturnsOnePointZero_isAccepted() {
+ Assert.assertTrue(accepts("1.0;", header(), snv("chr1", 100, "A", "C")));
+ }
+
+ @Test
+ public void scriptReturnsNull_isRejected() {
+ Assert.assertFalse(accepts("null;", header(), snv("chr1", 100, "A", "C")));
+ }
+
+ @Test
+ public void scriptReturnsUndefined_isRejected() {
+ Assert.assertFalse(accepts("undefined;", header(), snv("chr1", 100, "A", "C")));
+ }
+
+ @Test
+ public void scriptReturnsString_isRejected() {
+ Assert.assertFalse(accepts("'hello';", header(), snv("chr1", 100, "A", "C")));
+ }
+
+ // --- bindings: variant key --------------------------------------------------------------------
+
+ @Test
+ public void scriptCanCallMethodsOnVariant() {
+ VCFHeader h = header();
+ Assert.assertTrue(accepts("variant.getStart() == 100;", h, snv("chr1", 100, "A", "C")));
+ Assert.assertFalse(accepts("variant.getStart() == 100;", h, snv("chr1", 250, "A", "C")));
+ }
+
+ @Test
+ public void scriptCanReadContig() {
+ VCFHeader h = header();
+ Assert.assertTrue(accepts("variant.getContig() == 'chr1';", h, snv("chr1", 100, "A", "C")));
+ }
+
+ @Test
+ public void scriptCanReadAlleles() {
+ VCFHeader h = header();
+ Assert.assertTrue(accepts("variant.getReference().getBaseString() == 'A';", h, snv("chr1", 100, "A", "C")));
+ }
+
+ // --- bindings: header key ---------------------------------------------------------------------
+
+ @Test
+ public void scriptCanReachHeader() {
+ VCFHeader h = header();
+ Assert.assertTrue(accepts("header.getContigLines().size() == 1;", h, snv("chr1", 100, "A", "C")));
+ }
+
+ @Test
+ public void scriptCanReadContigFromHeader() {
+ VCFHeader h = header();
+ Assert.assertTrue(accepts("header.getContigLines().get(0).getID() == 'chr1';", h, snv("chr1", 100, "A", "C")));
+ }
+
+ // --- error paths ------------------------------------------------------------------------------
+
+ @Test(expectedExceptions = RuntimeScriptException.class)
+ public void malformedScript_throwsAtConstruction() {
+ new JavascriptVariantFilter("this is not javascript ;)", header());
+ }
+
+ @Test(expectedExceptions = RuntimeException.class)
+ public void scriptThatThrowsAtEval_propagatesAsRuntimeException() {
+ VCFHeader h = header();
+ JavascriptVariantFilter f = new JavascriptVariantFilter("throw 'boom';", h);
+ f.test(snv("chr1", 100, "A", "C"));
+ }
+
+ // --- multi-record reuse -----------------------------------------------------------------------
+
+ @Test
+ public void filterRefreshesBindingsAcrossManyVariants() {
+ VCFHeader h = header();
+ JavascriptVariantFilter f = new JavascriptVariantFilter("variant.getStart() >= 200;", h);
+
+ List variants = new ArrayList<>();
+ for (int i = 1; i <= 4; i++) variants.add(snv("chr1", i * 100, "A", "C"));
+
+ int kept = 0;
+ for (VariantContext v : variants) if (f.test(v)) kept++;
+ Assert.assertEquals(kept, 3);
}
}
diff --git a/src/test/resources/htsjdk/samtools/samFilter01.js b/src/test/resources/htsjdk/samtools/samFilter01.js
deleted file mode 100644
index 3fe7e00394..0000000000
--- a/src/test/resources/htsjdk/samtools/samFilter01.js
+++ /dev/null
@@ -1,2 +0,0 @@
-/** answer to https://www.biostars.org/p/77802/#77966 */
-(record.referenceIndex==record.mateReferenceIndex && record.referenceIndex>=0 && record.readNegativeStrandFlag!=record.mateNegativeStrandFlag && ((record.mateNegativeStrandFlag && record.alignmentStart < record.mateAlignmentStart ) || (record.readNegativeStrandFlag && record.mateAlignmentStart < record.alignmentStart ) ))
diff --git a/src/test/resources/htsjdk/samtools/samFilter02.js b/src/test/resources/htsjdk/samtools/samFilter02.js
deleted file mode 100644
index 046e7ecae4..0000000000
--- a/src/test/resources/htsjdk/samtools/samFilter02.js
+++ /dev/null
@@ -1,9 +0,0 @@
-/** accept record if second base of DNA is a A */
-function accept(r)
- {
- /* using substring instead of charAt because http://developer.actuate.com/community/forum/index.php?/topic/25434-javascript-stringcharati-wont-return-a-character/ */
- return r.getReadString().length()>2 &&
- r.getReadString().substring(1,2)=="A";
- }
-
-accept(record);
diff --git a/src/test/resources/htsjdk/variant/variantFilter01.js b/src/test/resources/htsjdk/variant/variantFilter01.js
deleted file mode 100644
index 0036477a86..0000000000
--- a/src/test/resources/htsjdk/variant/variantFilter01.js
+++ /dev/null
@@ -1,2 +0,0 @@
-/** get variant having position%2==0 */
-variant.getStart()%2 == 0;
diff --git a/src/test/resources/htsjdk/variant/variantFilter02.js b/src/test/resources/htsjdk/variant/variantFilter02.js
deleted file mode 100644
index c102d25291..0000000000
--- a/src/test/resources/htsjdk/variant/variantFilter02.js
+++ /dev/null
@@ -1,20 +0,0 @@
-/** prints a VARIATION if two samples at least have a DP>100 */
-function myfilterFunction(thevariant)
- {
- var samples=header.genotypeSamples;
- var countOkDp=0;
-
-
- for(var i=0; i< samples.size();++i)
- {
- var sampleName=samples.get(i);
- if(! variant.hasGenotype(sampleName)) continue;
- var genotype = thevariant.genotypes.get(sampleName);
- if( ! genotype.hasDP()) continue;
- var dp= genotype.getDP();
- if(dp > 100 ) countOkDp++;
- }
- return (countOkDp>2)
- }
-
-myfilterFunction(variant)