From: gmungoc Date: Thu, 26 Oct 2017 13:32:13 +0000 (+0100) Subject: Merge branch 'develop' into features/JAL-1793VCF X-Git-Tag: Release_2_11_0~181 X-Git-Url: http://source.jalview.org/gitweb/?a=commitdiff_plain;h=06de78be50c3934158fa1d35ec92ad86b54e959f;p=jalview.git Merge branch 'develop' into features/JAL-1793VCF Conflicts: src/jalview/util/MappingUtils.java test/jalview/analysis/AlignmentUtilsTests.java test/jalview/util/MappingUtilsTest.java --- 06de78be50c3934158fa1d35ec92ad86b54e959f diff --cc src/jalview/datamodel/SequenceFeature.java index f5a9b42,9c4087e..420ade1 --- a/src/jalview/datamodel/SequenceFeature.java +++ b/src/jalview/datamodel/SequenceFeature.java @@@ -52,18 -51,6 +52,20 @@@ public class SequenceFeature implement // private key for ENA location designed not to conflict with real GFF data private static final String LOCATION = "!Location"; + private static final String ROW_DATA = "%s%s"; + + /* + * map of otherDetails special keys, and their value fields' delimiter + */ + private static final Map INFO_KEYS = new HashMap<>(); + + static + { + INFO_KEYS.put("CSQ", ","); ++ // todo capture second level metadata (CSQ FORMAT) ++ // and delimiter "|" so as to report in a table within a table? + } + /* * ATTRIBUTES is reserved for the GFF 'column 9' data, formatted as * name1=value1;name2=value2,value3;...etc @@@ -548,68 -535,4 +550,68 @@@ { return begin == 0 && end == 0; } + + /** + * Answers an html-formatted report of feature details + * + * @return + */ + public String getDetailsReport() + { + StringBuilder sb = new StringBuilder(128); + sb.append("
"); + sb.append(""); + sb.append(String.format(ROW_DATA, "Type", type)); + sb.append(String.format(ROW_DATA, "Start/end", begin == end ? begin + : begin + (isContactFeature() ? ":" : "-") + end)); + String desc = StringUtils.stripHtmlTags(description); + sb.append(String.format(ROW_DATA, "Description", desc)); + if (!Float.isNaN(score) && score != 0f) + { + sb.append(String.format(ROW_DATA, "Score", score)); + } + if (featureGroup != null) + { + sb.append(String.format(ROW_DATA, "Group", featureGroup)); + } + + if (otherDetails != null) + { + TreeMap ordered = new TreeMap<>( + String.CASE_INSENSITIVE_ORDER); + ordered.putAll(otherDetails); + + for (Entry entry : ordered.entrySet()) + { + String key = entry.getKey(); + if (ATTRIBUTES.equals(key)) + { + continue; // to avoid double reporting + } - sb.append(""); + String delimiter = INFO_KEYS.get(key); + String[] values = entry.getValue().toString().split(delimiter); + for (String value : values) + { - sb.append(""); + } + } + else - { ++ { // tried "); + } + } + } + sb.append("
").append(key).append(""); + if (INFO_KEYS.containsKey(key)) + { + /* + * split selected INFO data by delimiter over multiple lines + */ - sb.append("
 ").append(value) ++ sb.append("
").append(key).append("") ++ .append(value) + .append("
but it failed to provide a tooltip :-( ++ sb.append("
").append(key).append(""); + sb.append(entry.getValue().toString()).append("
"); + + String text = sb.toString(); + return text; + } } diff --cc src/jalview/gui/AquaInternalFrameManager.java index 0000000,ea809eb..829135b mode 000000,100644..100644 --- a/src/jalview/gui/AquaInternalFrameManager.java +++ b/src/jalview/gui/AquaInternalFrameManager.java @@@ -1,0 -1,257 +1,256 @@@ + /* + * Copyright (c) 2011, 2012, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + + package jalview.gui; + + import java.awt.Container; + import java.beans.PropertyVetoException; + import java.util.Vector; + + import javax.swing.DefaultDesktopManager; + import javax.swing.DesktopManager; + import javax.swing.JInternalFrame; + + /** + * Based on AquaInternalFrameManager + * + * DesktopManager implementation for Aqua + * + * Mac is more like Windows than it's like Motif/Basic + * + * From WindowsDesktopManager: + * + * This class implements a DesktopManager which more closely follows the MDI + * model than the DefaultDesktopManager. Unlike the DefaultDesktopManager + * policy, MDI requires that the selected and activated child frames are the + * same, and that that frame always be the top-most window. + *

+ * The maximized state is managed by the DesktopManager with MDI, instead of + * just being a property of the individual child frame. This means that if the + * currently selected window is maximized and another window is selected, that + * new window will be maximized. + * + * Downloaded from + * https://raw.githubusercontent.com/frohoff/jdk8u-jdk/master/src/macosx/classes/com/apple/laf/AquaInternalFrameManager.java + * + * Patch from Jim Procter - when the most recently opened frame is closed, + * correct behaviour is to go to the next most recent frame, rather than wrap + * around to the bottom of the window stack (as the original implementation + * does) + * - * @see com.sun.java.swing.plaf.windows.WindowsDesktopManager + */ + public class AquaInternalFrameManager extends DefaultDesktopManager + { + // Variables + + /* The frame which is currently selected/activated. + * We store this value to enforce Mac's single-selection model. + */ + JInternalFrame fCurrentFrame; + + JInternalFrame fInitialFrame; + + /* The list of frames, sorted by order of creation. + * This list is necessary because by default the order of + * child frames in the JDesktopPane changes during frame + * activation (the activated frame is moved to index 0). + * We preserve the creation order so that "next" and "previous" + * frame actions make sense. + */ + Vector fChildFrames = new Vector<>(1); + + /** + * keep a reference to the original LAF manager so we can iconise/de-iconise + * correctly + */ + private DesktopManager ourManager; + + public AquaInternalFrameManager(DesktopManager desktopManager) + { + ourManager = desktopManager; + } + + @Override + public void closeFrame(final JInternalFrame f) + { + if (f == fCurrentFrame) + { + boolean mostRecentFrame = fChildFrames + .indexOf(f) == fChildFrames.size() - 1; + if (!mostRecentFrame) + { + activateNextFrame(); + } + else + { + activatePreviousFrame(); + } + } + fChildFrames.removeElement(f); + super.closeFrame(f); + } + + @Override + public void deiconifyFrame(final JInternalFrame f) + { + JInternalFrame.JDesktopIcon desktopIcon; + + desktopIcon = f.getDesktopIcon(); + // If the icon moved, move the frame to that spot before expanding it + // reshape does delta checks for us + f.reshape(desktopIcon.getX(), desktopIcon.getY(), f.getWidth(), + f.getHeight()); + ourManager.deiconifyFrame(f); + } + + void addIcon(final Container c, + final JInternalFrame.JDesktopIcon desktopIcon) + { + c.add(desktopIcon); + } + + /** + * Removes the frame from its parent and adds its desktopIcon to the parent. + */ + @Override + public void iconifyFrame(final JInternalFrame f) + { + ourManager.iconifyFrame(f); + } + + // WindowsDesktopManager code + @Override + public void activateFrame(final JInternalFrame f) + { + try + { + if (f != null) + { + super.activateFrame(f); + } + + // If this is the first activation, add to child list. + if (fChildFrames.indexOf(f) == -1) + { + fChildFrames.addElement(f); + } + + if (fCurrentFrame != null && f != fCurrentFrame) + { + if (fCurrentFrame.isSelected()) + { + fCurrentFrame.setSelected(false); + } + } + + if (f != null && !f.isSelected()) + { + f.setSelected(true); + } + + fCurrentFrame = f; + } catch (final PropertyVetoException e) + { + } + } + + private void switchFrame(final boolean next) + { + if (fCurrentFrame == null) + { + // initialize first frame we find + if (fInitialFrame != null) + { + activateFrame(fInitialFrame); + } + return; + } + + final int count = fChildFrames.size(); + if (count <= 1) + { + // No other child frames. + return; + } + + final int currentIndex = fChildFrames.indexOf(fCurrentFrame); + if (currentIndex == -1) + { + // the "current frame" is no longer in the list + fCurrentFrame = null; + return; + } + + int nextIndex; + if (next) + { + nextIndex = currentIndex + 1; + if (nextIndex == count) + { + nextIndex = 0; + } + } + else + { + nextIndex = currentIndex - 1; + if (nextIndex == -1) + { + nextIndex = count - 1; + } + } + final JInternalFrame f = fChildFrames.elementAt(nextIndex); + activateFrame(f); + fCurrentFrame = f; + } + + /** + * Activate the next child JInternalFrame, as determined by the frames' + * Z-order. If there is only one child frame, it remains activated. If there + * are no child frames, nothing happens. + */ + public void activateNextFrame() + { + switchFrame(true); + } + + /** + * same as above but will activate a frame if none have been selected + */ + public void activateNextFrame(final JInternalFrame f) + { + fInitialFrame = f; + switchFrame(true); + } + + /** + * Activate the previous child JInternalFrame, as determined by the frames' + * Z-order. If there is only one child frame, it remains activated. If there + * are no child frames, nothing happens. + */ + public void activatePreviousFrame() + { + switchFrame(false); + } + } diff --cc src/jalview/util/MappingUtils.java index d21eac3,9c5c109..f5dd883 --- a/src/jalview/util/MappingUtils.java +++ b/src/jalview/util/MappingUtils.java @@@ -941,30 -941,53 +941,81 @@@ public final class MappingUtil } /** + * Answers true if range's start-end positions include those of queryRange, + * where either range might be in reverse direction, else false + * + * @param range + * a start-end range + * @param queryRange + * a candidate subrange of range (start2-end2) + * @return + */ + public static boolean rangeContains(int[] range, int[] queryRange) + { + if (range == null || queryRange == null || range.length != 2 + || queryRange.length != 2) + { + /* + * invalid arguments + */ + return false; + } + + int min = Math.min(range[0], range[1]); + int max = Math.max(range[0], range[1]); + + return (min <= queryRange[0] && max >= queryRange[0] + && min <= queryRange[1] && max >= queryRange[1]); + } ++ ++ /** + * Removes the specified number of positions from the given ranges. Provided + * to allow a stop codon to be stripped from a CDS sequence so that it matches + * the peptide translation length. + * + * @param positions + * @param ranges + * a list of (single) [start, end] ranges + * @return + */ + public static void removeEndPositions(int positions, + List ranges) + { + int toRemove = positions; + Iterator it = new ReverseListIterator<>(ranges); + while (toRemove > 0) + { + int[] endRange = it.next(); + if (endRange.length != 2) + { + /* + * not coded for [start1, end1, start2, end2, ...] + */ + System.err + .println("MappingUtils.removeEndPositions doesn't handle multiple ranges"); + return; + } + + int length = endRange[1] - endRange[0] + 1; + if (length <= 0) + { + /* + * not coded for a reverse strand range (end < start) + */ + System.err + .println("MappingUtils.removeEndPositions doesn't handle reverse strand"); + return; + } + if (length > toRemove) + { + endRange[1] -= toRemove; + toRemove = 0; + } + else + { + toRemove -= length; + it.remove(); + } + } + } } diff --cc test/jalview/analysis/AlignmentUtilsTests.java index d229a39,06b51e6..1bff8bf --- a/test/jalview/analysis/AlignmentUtilsTests.java +++ b/test/jalview/analysis/AlignmentUtilsTests.java @@@ -64,6 -63,6 +64,8 @@@ import org.testng.annotations.Test public class AlignmentUtilsTests { ++ private static Sequence ts = new Sequence("short", ++ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklm"); @BeforeClass(alwaysRun = true) public void setUpJvOptionPane() @@@ -72,9 -71,9 +74,6 @@@ JvOptionPane.setMockResponse(JvOptionPane.CANCEL_OPTION); } - private static Sequence ts = new Sequence("short", - "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklm"); - - public static Sequence ts = new Sequence("short", - "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklm"); - @Test(groups = { "Functional" }) public void testExpandContext() { @@@ -2570,67 -2533,71 +2569,134 @@@ assertEquals(s_as3, uas3.getSequenceAsString()); } + @Test(groups = { "Functional" }) + public void testTransferGeneLoci() + { + SequenceI from = new Sequence("transcript", + "aaacccgggTTTAAACCCGGGtttaaacccgggttt"); + SequenceI to = new Sequence("CDS", "TTTAAACCCGGG"); + MapList map = new MapList(new int[] { 1, 12 }, new int[] { 10, 21 }, 1, + 1); + + /* + * first with nothing to transfer + */ + AlignmentUtils.transferGeneLoci(from, map, to); + assertNull(to.getGeneLoci()); + + /* + * next with gene loci set on 'from' sequence + */ + int[] exons = new int[] { 100, 105, 155, 164, 210, 229 }; + MapList geneMap = new MapList(new int[] { 1, 36 }, exons, 1, 1); + from.setGeneLoci("human", "GRCh38", "7", geneMap); + AlignmentUtils.transferGeneLoci(from, map, to); + + GeneLociI toLoci = to.getGeneLoci(); + assertNotNull(toLoci); + // DBRefEntry constructor upper-cases 'source' + assertEquals("HUMAN", toLoci.getSpeciesId()); + assertEquals("GRCh38", toLoci.getAssemblyId()); + assertEquals("7", toLoci.getChromosomeId()); + + /* + * transcript 'exons' are 1-6, 7-16, 17-36 + * CDS 1:12 is transcript 10-21 + * transcript 'CDS' is 10-16, 17-21 + * which is 'gene' 158-164, 210-214 + */ + MapList toMap = toLoci.getMap(); + assertEquals(1, toMap.getFromRanges().size()); + assertEquals(2, toMap.getFromRanges().get(0).length); + assertEquals(1, toMap.getFromRanges().get(0)[0]); + assertEquals(12, toMap.getFromRanges().get(0)[1]); + assertEquals(1, toMap.getToRanges().size()); + assertEquals(4, toMap.getToRanges().get(0).length); + assertEquals(158, toMap.getToRanges().get(0)[0]); + assertEquals(164, toMap.getToRanges().get(0)[1]); + assertEquals(210, toMap.getToRanges().get(0)[2]); + assertEquals(214, toMap.getToRanges().get(0)[3]); + // or summarised as (but toString might change in future): + assertEquals("[ [1, 12] ] 1:1 to [ [158, 164, 210, 214] ]", + toMap.toString()); + + /* + * an existing value is not overridden + */ + geneMap = new MapList(new int[] { 1, 36 }, new int[] { 36, 1 }, 1, 1); + from.setGeneLoci("inhuman", "GRCh37", "6", geneMap); + AlignmentUtils.transferGeneLoci(from, map, to); + assertEquals("GRCh38", toLoci.getAssemblyId()); + assertEquals("7", toLoci.getChromosomeId()); + toMap = toLoci.getMap(); + assertEquals("[ [1, 12] ] 1:1 to [ [158, 164, 210, 214] ]", + toMap.toString()); + } ++ + /** + * Tests for the method that maps nucleotide to protein based on CDS features + */ + @Test(groups = "Functional") + public void testMapCdsToProtein() + { + SequenceI peptide = new Sequence("pep", "KLQ"); + + /* + * Case 1: CDS 3 times length of peptide + * NB method only checks lengths match, not translation + */ + SequenceI dna = new Sequence("dna", "AACGacgtCTCCT"); + dna.createDatasetSequence(); + dna.addSequenceFeature(new SequenceFeature("CDS", "", 1, 4, null)); + dna.addSequenceFeature(new SequenceFeature("CDS", "", 9, 13, null)); + MapList ml = AlignmentUtils.mapCdsToProtein(dna, peptide); + assertEquals(3, ml.getFromRatio()); + assertEquals(1, ml.getToRatio()); + assertEquals("[[1, 3]]", + Arrays.deepToString(ml.getToRanges().toArray())); + assertEquals("[[1, 4], [9, 13]]", + Arrays.deepToString(ml.getFromRanges().toArray())); + + /* + * Case 2: CDS 3 times length of peptide + stop codon + * (note code does not currently check trailing codon is a stop codon) + */ + dna = new Sequence("dna", "AACGacgtCTCCTTGA"); + dna.createDatasetSequence(); + dna.addSequenceFeature(new SequenceFeature("CDS", "", 1, 4, null)); + dna.addSequenceFeature(new SequenceFeature("CDS", "", 9, 16, null)); + ml = AlignmentUtils.mapCdsToProtein(dna, peptide); + assertEquals(3, ml.getFromRatio()); + assertEquals(1, ml.getToRatio()); + assertEquals("[[1, 3]]", + Arrays.deepToString(ml.getToRanges().toArray())); + assertEquals("[[1, 4], [9, 13]]", + Arrays.deepToString(ml.getFromRanges().toArray())); + + /* + * Case 3: CDS not 3 times length of peptide - no mapping is made + */ + dna = new Sequence("dna", "AACGacgtCTCCTTG"); + dna.createDatasetSequence(); + dna.addSequenceFeature(new SequenceFeature("CDS", "", 1, 4, null)); + dna.addSequenceFeature(new SequenceFeature("CDS", "", 9, 15, null)); + ml = AlignmentUtils.mapCdsToProtein(dna, peptide); + assertNull(ml); + + /* + * Case 4: incomplete start codon corresponding to X in peptide + */ + dna = new Sequence("dna", "ACGacgtCTCCTTGG"); + dna.createDatasetSequence(); + SequenceFeature sf = new SequenceFeature("CDS", "", 1, 3, null); + sf.setPhase("2"); // skip 2 positions (AC) to start of next codon (GCT) + dna.addSequenceFeature(sf); + dna.addSequenceFeature(new SequenceFeature("CDS", "", 8, 15, null)); + peptide = new Sequence("pep", "XLQ"); + ml = AlignmentUtils.mapCdsToProtein(dna, peptide); + assertEquals("[[2, 3]]", + Arrays.deepToString(ml.getToRanges().toArray())); + assertEquals("[[3, 3], [8, 12]]", + Arrays.deepToString(ml.getFromRanges().toArray())); + } - } diff --cc test/jalview/util/MappingUtilsTest.java index 87070d7,5226819..d4cf98a --- a/test/jalview/util/MappingUtilsTest.java +++ b/test/jalview/util/MappingUtilsTest.java @@@ -1149,93 -1149,49 +1149,138 @@@ public class MappingUtilsTes assertEquals("[12, 11, 8, 4]", Arrays.toString(ranges)); } + @Test(groups = { "Functional" }) + public void testRangeContains() + { + /* + * both forward ranges + */ + assertTrue(MappingUtils.rangeContains(new int[] { 1, 10 }, new int[] { + 1, 10 })); + assertTrue(MappingUtils.rangeContains(new int[] { 1, 10 }, new int[] { + 2, 10 })); + assertTrue(MappingUtils.rangeContains(new int[] { 1, 10 }, new int[] { + 1, 9 })); + assertTrue(MappingUtils.rangeContains(new int[] { 1, 10 }, new int[] { + 4, 5 })); + assertFalse(MappingUtils.rangeContains(new int[] { 1, 10 }, new int[] { + 0, 9 })); + assertFalse(MappingUtils.rangeContains(new int[] { 1, 10 }, new int[] { + -10, -9 })); + assertFalse(MappingUtils.rangeContains(new int[] { 1, 10 }, new int[] { + 1, 11 })); + assertFalse(MappingUtils.rangeContains(new int[] { 1, 10 }, new int[] { + 11, 12 })); + + /* + * forward range, reverse query + */ + assertTrue(MappingUtils.rangeContains(new int[] { 1, 10 }, new int[] { + 10, 1 })); + assertTrue(MappingUtils.rangeContains(new int[] { 1, 10 }, new int[] { + 9, 1 })); + assertTrue(MappingUtils.rangeContains(new int[] { 1, 10 }, new int[] { + 10, 2 })); + assertTrue(MappingUtils.rangeContains(new int[] { 1, 10 }, new int[] { + 5, 5 })); + assertFalse(MappingUtils.rangeContains(new int[] { 1, 10 }, new int[] { + 11, 1 })); + assertFalse(MappingUtils.rangeContains(new int[] { 1, 10 }, new int[] { + 10, 0 })); + + /* + * reverse range, forward query + */ + assertTrue(MappingUtils.rangeContains(new int[] { 10, 1 }, new int[] { + 1, 10 })); + assertTrue(MappingUtils.rangeContains(new int[] { 10, 1 }, new int[] { + 1, 9 })); + assertTrue(MappingUtils.rangeContains(new int[] { 10, 1 }, new int[] { + 2, 10 })); + assertTrue(MappingUtils.rangeContains(new int[] { 10, 1 }, new int[] { + 6, 6 })); + assertFalse(MappingUtils.rangeContains(new int[] { 10, 1 }, new int[] { + 6, 11 })); + assertFalse(MappingUtils.rangeContains(new int[] { 10, 1 }, new int[] { + 11, 20 })); + assertFalse(MappingUtils.rangeContains(new int[] { 10, 1 }, new int[] { + -3, -2 })); + + /* + * both reverse + */ + assertTrue(MappingUtils.rangeContains(new int[] { 10, 1 }, new int[] { + 10, 1 })); + assertTrue(MappingUtils.rangeContains(new int[] { 10, 1 }, new int[] { + 9, 1 })); + assertTrue(MappingUtils.rangeContains(new int[] { 10, 1 }, new int[] { + 10, 2 })); + assertTrue(MappingUtils.rangeContains(new int[] { 10, 1 }, new int[] { + 3, 3 })); + assertFalse(MappingUtils.rangeContains(new int[] { 10, 1 }, new int[] { + 11, 1 })); + assertFalse(MappingUtils.rangeContains(new int[] { 10, 1 }, new int[] { + 10, 0 })); + assertFalse(MappingUtils.rangeContains(new int[] { 10, 1 }, new int[] { + 12, 11 })); + assertFalse(MappingUtils.rangeContains(new int[] { 10, 1 }, new int[] { + -5, -8 })); + + /* + * bad arguments + */ + assertFalse(MappingUtils.rangeContains(new int[] { 1, 10, 12 }, + new int[] { + 1, 10 })); + assertFalse(MappingUtils.rangeContains(new int[] { 1, 10 }, + new int[] { 1 })); + assertFalse(MappingUtils.rangeContains(new int[] { 1, 10 }, null)); + assertFalse(MappingUtils.rangeContains(null, new int[] { 1, 10 })); + } + + @Test(groups = "Functional") + public void testRemoveEndPositions() + { + List ranges = new ArrayList<>(); + + /* + * case 1: truncate last range + */ + ranges.add(new int[] { 1, 10 }); + ranges.add(new int[] { 20, 30 }); + MappingUtils.removeEndPositions(5, ranges); + assertEquals(2, ranges.size()); + assertEquals(25, ranges.get(1)[1]); + + /* + * case 2: remove last range + */ + ranges.clear(); + ranges.add(new int[] { 1, 10 }); + ranges.add(new int[] { 20, 22 }); + MappingUtils.removeEndPositions(3, ranges); + assertEquals(1, ranges.size()); + assertEquals(10, ranges.get(0)[1]); + + /* + * case 3: truncate penultimate range + */ + ranges.clear(); + ranges.add(new int[] { 1, 10 }); + ranges.add(new int[] { 20, 21 }); + MappingUtils.removeEndPositions(3, ranges); + assertEquals(1, ranges.size()); + assertEquals(9, ranges.get(0)[1]); + + /* + * case 4: remove last two ranges + */ + ranges.clear(); + ranges.add(new int[] { 1, 10 }); + ranges.add(new int[] { 20, 20 }); + ranges.add(new int[] { 30, 30 }); + MappingUtils.removeEndPositions(3, ranges); + assertEquals(1, ranges.size()); + assertEquals(9, ranges.get(0)[1]); + } }