Adjust context to shrink based on match size (closes #229)

Change-Id: I5dba1f714beb07c71c69a783d801d4a40492ff5d
diff --git a/src/test/java/de/ids_mannheim/korap/index/TestMaxContext.java b/src/test/java/de/ids_mannheim/korap/index/TestMaxContext.java
index 6072688..3c29a95 100644
--- a/src/test/java/de/ids_mannheim/korap/index/TestMaxContext.java
+++ b/src/test/java/de/ids_mannheim/korap/index/TestMaxContext.java
@@ -2,10 +2,13 @@
 
 import static de.ids_mannheim.korap.TestSimple.getJsonString;
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
 
 import java.io.IOException;
+import java.util.Properties;
 
 import org.junit.BeforeClass;
+import org.junit.After;
 import org.junit.Test;
 
 import com.fasterxml.jackson.core.JsonProcessingException;
@@ -14,12 +17,15 @@
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.fasterxml.jackson.databind.node.ArrayNode;
 
+import org.apache.lucene.index.Term;
 import de.ids_mannheim.korap.Krill;
 import de.ids_mannheim.korap.KrillIndex;
 import de.ids_mannheim.korap.response.Match;
 import de.ids_mannheim.korap.response.Result;
 import de.ids_mannheim.korap.response.SearchContext;
 import de.ids_mannheim.korap.util.KrillProperties;
+import org.apache.lucene.search.spans.SpanTermQuery;
+
 
 public class TestMaxContext {
     private static KrillIndex ki;
@@ -41,6 +47,33 @@
                 .getFile());
     }
 
+    private int savedMaxTokenMatchSize;
+    private int savedMaxTokenContextSize;
+    private int savedMaxCharContextSize;
+    private int savedDefaultSearchContextLength;
+    private boolean savedMatchExpansionIncludeContextSize;
+
+    @org.junit.Before
+    public void saveGlobals() {
+        savedMaxTokenMatchSize = KrillProperties.maxTokenMatchSize;
+        savedMaxTokenContextSize = KrillProperties.maxTokenContextSize;
+        savedMaxCharContextSize = KrillProperties.maxCharContextSize;
+        savedDefaultSearchContextLength = KrillProperties.defaultSearchContextLength;
+        savedMatchExpansionIncludeContextSize = KrillProperties.matchExpansionIncludeContextSize;
+    };
+
+    @After
+    public void resetGlobals() {
+        KrillProperties.leftContextMaxShrink = 0;
+        KrillProperties.rightContextMaxShrink = 0;
+        KrillProperties.kwicMaxToken = -1;
+        KrillProperties.maxTokenMatchSize = savedMaxTokenMatchSize;
+        KrillProperties.maxTokenContextSize = savedMaxTokenContextSize;
+        KrillProperties.maxCharContextSize = savedMaxCharContextSize;
+        KrillProperties.defaultSearchContextLength = savedDefaultSearchContextLength;
+        KrillProperties.matchExpansionIncludeContextSize = savedMatchExpansionIncludeContextSize;
+    };
+
     @Test
     public void testSmallerTokenContextSize () throws IOException {
         
@@ -74,7 +107,6 @@
 
         Krill ks = new Krill(jsonNode);
         Result kr = ks.apply(ki);
-        kr = ks.apply(ki);
 
         SearchContext context = kr.getContext();
         assertEquals(KrillProperties.maxTokenContextSize,
@@ -96,7 +128,7 @@
 
     @Test
     public void searchWithLargerContextCharSize ()
-            throws JsonMappingException, JsonProcessingException {
+        throws JsonMappingException, JsonProcessingException {
         JsonNode jsonNode = mapper.readTree(jsonQuery);
         ArrayNode leftNode = (ArrayNode) jsonNode.at("/meta/context/left");
         ArrayNode rightNode = (ArrayNode) jsonNode.at("/meta/context/right");
@@ -138,4 +170,496 @@
         assertEquals(6089, km.getSnippetBrackets().length());
         KrillProperties.defaultSearchContextLength = 6;
     };
-}
+
+
+    @Test
+    public void testTokenSnippetMatchLength1 () throws IOException {       
+        SpanTermQuery stq = new SpanTermQuery(new Term("tokens", "s:des"));
+        Result kr = ki.search(stq, (short) 10);
+        
+        Match km = kr.getMatch(0);
+        assertEquals(7, km.getStartPos());
+        assertEquals(8, km.getEndPos());
+        assertEquals(6, km.getContext().left.getLength());
+        assertEquals(6, km.getContext().right.getLength());
+        
+
+        assertEquals("{\"left\":[\"bzw.\",\"a\",\"ist\",\"der\",\"erste\",\"Buchstabe\"]," +
+                     "\"match\":[\"des\"]," +
+                     "\"right\":[\"lateinischen\",\"Alphabets\",\"und\",\"ein\",\"Vokal\",\"Der\"]}",
+                     kr.getMatch(0).getSnippetTokens().toString());
+
+        KrillProperties.leftContextMaxShrink = 1;
+        KrillProperties.rightContextMaxShrink = 1;
+
+        // Shrinks the left context by 1 - as that is the match length - although it could be 2
+        assertEquals("{\"left\":[\"gibt\",\"es\",\"zwei\",\"verschiedene\",\"Phoneme\"],"+
+                     "\"match\":[\"des\"],"+
+                     "\"right\":[\"Vokals\",\"den\",\"Kurzvokal\",\"a,\",\"wie\",\"z\"]}",
+                     kr.getMatch(1).getSnippetTokens().toString());
+
+        KrillProperties.leftContextMaxShrink = 5;
+        KrillProperties.rightContextMaxShrink = 5;
+
+        // Shrinks the left context by 1 - as that is the match length - although it could be 10
+        assertEquals("{\"left\":[\"B.\",\"in\",\"Rat\",\"Die\",\"Länge\"],"+
+                     "\"match\":[\"des\"],"+
+                     "\"right\":[\"Vokals\",\"ist\",\"unterschiedlich\",\"gekennzeichnet\",\"Langer\",\"Vokal\"]}",
+                     kr.getMatch(2).getSnippetTokens().toString());
+    };
+
+    @Test
+    public void testTokenSnippetMatchLengthLong () throws JsonMappingException, JsonProcessingException {       
+        JsonNode jsonNode = mapper.readTree(jsonQuery);
+        Krill ks = new Krill(jsonNode);
+        Result kr = ks.apply(ki);
+
+        Match km = kr.getMatch(0);
+        assertEquals(34, km.getStartPos());
+        assertEquals(60, km.getEndPos());
+        assertEquals(5, km.getContext().left.getLength());
+        assertEquals(5, km.getContext().right.getLength());
+
+        String snippetToken = kr.getMatch(0).getSnippetTokens().toString();
+        assertTrue(snippetToken.contains(
+                       "\"left\":[\"sechsthäufigste\",\"Buchstabe\",\"in\",\"deutschen\",\"Texten\"]"
+                       )
+            );
+        assertTrue(snippetToken.contains(
+                       "\"right\":[\"1.\",\"Aussprache\",\"Im\",\"Deutschen\",\"und\"]"
+                       )
+            );
+
+        String snippetHTML = kr.getMatch(0).getSnippetHTML();
+        assertTrue(snippetHTML.contains("<span class=\"context-left\"><span class=\"more\"></span>sechsthäufigste Buchstabe in deutschen Texten. </span>"));
+        assertTrue(snippetHTML.contains("<span class=\"context-right\">. 1. Aussprache Im Deutschen und<span class=\"more\"></span></span>"));
+    };
+
+    @Test
+    public void testTokenSnippetMatchLengthLong2 () throws JsonMappingException, JsonProcessingException {       
+        JsonNode jsonNode = mapper.readTree(jsonQuery);
+        Krill ks = new Krill(jsonNode);
+        Result kr = ks.apply(ki);
+
+        Match km = kr.getMatch(0);
+        assertEquals(34, km.getStartPos());
+        assertEquals(60, km.getEndPos());
+        assertEquals(5, km.getContext().left.getLength());
+        assertEquals(5, km.getContext().right.getLength());
+
+        KrillProperties.leftContextMaxShrink = 1;
+        KrillProperties.rightContextMaxShrink = 1;
+
+        String snippetToken = kr.getMatch(0).getSnippetTokens().toString();
+
+        assertTrue(snippetToken.contains(
+                       "\"left\":[\"Buchstabe\",\"in\",\"deutschen\",\"Texten\"]"
+                       )
+            );
+
+        assertTrue(snippetToken.contains(
+                       "\"right\":[\"1.\",\"Aussprache\",\"Im\",\"Deutschen\"]"
+                       )
+            );
+
+        String snippetHTML = kr.getMatch(0).getSnippetHTML();
+        assertTrue(snippetHTML.contains("<span class=\"context-left\"><span class=\"more\"></span>Buchstabe in deutschen Texten. </span>"));
+        assertTrue(snippetHTML.contains("<span class=\"context-right\">. 1. Aussprache Im Deutschen<span class=\"more\"></span></span>"));
+
+    };
+
+    @Test
+    public void testTokenSnippetMatchLengthLong3 () throws JsonMappingException, JsonProcessingException {       
+        JsonNode jsonNode = mapper.readTree(jsonQuery);
+        Krill ks = new Krill(jsonNode);
+        Result kr = ks.apply(ki);
+
+        Match km = kr.getMatch(0);
+        assertEquals(34, km.getStartPos());
+        assertEquals(60, km.getEndPos());
+        assertEquals(5, km.getContext().left.getLength());
+        assertEquals(5, km.getContext().right.getLength());
+
+        KrillProperties.leftContextMaxShrink = 4;
+        KrillProperties.rightContextMaxShrink = 2;
+
+        String snippetToken = kr.getMatch(0).getSnippetTokens().toString();
+
+        assertTrue(snippetToken.contains(
+                       "\"left\":[\"Texten\"]"
+                       )
+            );
+
+        assertTrue(snippetToken.contains(
+                       "\"right\":[\"1.\",\"Aussprache\",\"Im\"]"
+                       )
+            );
+
+        String snippetHTML = kr.getMatch(0).getSnippetHTML();
+        assertTrue(snippetHTML.contains("<span class=\"context-left\"><span class=\"more\"></span>Texten. </span>"));
+        assertTrue(snippetHTML.contains("<span class=\"context-right\">. 1. Aussprache Im<span class=\"more\"></span></span>"));
+
+    };
+
+    @Test
+    public void testTokenSnippetMatchLengthLong4 () throws JsonMappingException, JsonProcessingException {       
+        int before = KrillProperties.maxTokenMatchSize;
+        KrillProperties.maxTokenMatchSize = 5;
+        JsonNode jsonNode = mapper.readTree(jsonQuery);
+        Krill ks = new Krill(jsonNode);
+        Result kr = ks.apply(ki);
+
+        Match km = kr.getMatch(0);
+        assertEquals(34, km.getStartPos());
+        assertEquals(39, km.getEndPos());
+        assertEquals(5, km.getContext().left.getLength());
+        assertEquals(5, km.getContext().right.getLength());
+
+        KrillProperties.leftContextMaxShrink = 5;
+        KrillProperties.rightContextMaxShrink = 0;
+
+        String snippetToken = kr.getMatch(0).getSnippetTokens().toString();
+        KrillProperties.maxTokenMatchSize = before;
+
+        assertTrue(!snippetToken.contains("\"left\""));
+        assertTrue(snippetToken.contains(
+                       "\"match\":[\"Mit\",\"Ausnahme\",\"von\",\"Fremdwörtern\",\"und\"]"
+                       )
+            );
+
+        assertTrue(snippetToken.contains(
+                       "\"right\":[\"Namen\",\"ist\",\"das\",\"A\",\"der\"]"
+                       )
+            );
+
+        String snippetHTML = kr.getMatch(0).getSnippetHTML();
+        assertTrue(snippetHTML.contains("<span class=\"context-left\"><span class=\"more\"></span></span>"));
+        assertTrue(snippetHTML.contains("<span class=\"match\"><mark>Mit Ausnahme von Fremdwörtern und</mark><span class=\"cutted\"></span></span>"));
+        assertTrue(snippetHTML.contains("<span class=\"context-right\"> Namen ist das A der<span class=\"more\"></span></span>"));
+    };
+
+
+    @Test
+    public void testTokenSnippetMatchLengthLong5 () throws JsonMappingException, JsonProcessingException {       
+        int before = KrillProperties.maxTokenMatchSize;
+        KrillProperties.maxTokenMatchSize = 5;
+        JsonNode jsonNode = mapper.readTree(jsonQuery);
+        Krill ks = new Krill(jsonNode);
+        Result kr = ks.apply(ki);
+
+        Match km = kr.getMatch(0);
+        assertEquals(34, km.getStartPos());
+        assertEquals(39, km.getEndPos());
+        assertEquals(5, km.getContext().left.getLength());
+        assertEquals(5, km.getContext().right.getLength());
+
+        // Adjust all context for the matchsize
+        KrillProperties.leftContextMaxShrink = 5;
+        KrillProperties.rightContextMaxShrink = 5;
+
+        String snippetToken = kr.getMatch(0).getSnippetTokens().toString();
+        KrillProperties.maxTokenMatchSize = before;
+
+        assertEquals("{\"left\":[\"deutschen\",\"Texten\"],\"match\":[\"Mit\",\"Ausnahme\",\"von\",\"Fremdwörtern\",\"und\"],\"right\":[\"Namen\",\"ist\",\"das\"]}", snippetToken);
+
+        String snippetHTML = kr.getMatch(0).getSnippetHTML();
+        assertTrue(snippetHTML.contains("<span class=\"context-left\"><span class=\"more\"></span>deutschen Texten. </span>"));
+        assertTrue(snippetHTML.contains("<span class=\"match\"><mark>Mit Ausnahme von Fremdwörtern und</mark><span class=\"cutted\"></span></span>"));
+        assertTrue(snippetHTML.contains("<span class=\"context-right\"> Namen ist das<span class=\"more\"></span></span>"));
+    };
+
+    @Test
+    public void testTokenSnippetMatchLengthLongAllKwic () throws JsonMappingException, JsonProcessingException {       
+        int before = KrillProperties.maxTokenMatchSize;
+        KrillProperties.maxTokenMatchSize = 10;
+        JsonNode jsonNode = mapper.readTree(jsonQuery);
+        Krill ks = new Krill(jsonNode);
+        Result kr = ks.apply(ki);
+
+        Match km = kr.getMatch(0);
+        assertEquals(34, km.getStartPos());
+        assertEquals(44, km.getEndPos());
+        assertEquals(5, km.getContext().left.getLength());
+        assertEquals(5, km.getContext().right.getLength());
+
+        // Adjust all context for the matchsize
+        KrillProperties.leftContextMaxShrink = 5;
+        KrillProperties.rightContextMaxShrink = 5;
+        String snippetToken = kr.getMatch(0).getSnippetTokens().toString();
+        KrillProperties.maxTokenMatchSize = before;
+
+        assertEquals("{\"match\":[\"Mit\",\"Ausnahme\",\"von\",\"Fremdwörtern\",\"und\",\"Namen\",\"ist\",\"das\",\"A\",\"der\"]}", snippetToken);
+
+        String snippetHTML = kr.getMatch(0).getSnippetHTML();
+        assertTrue(snippetHTML.contains("<span class=\"context-left\"><span class=\"more\"></span></span>"));
+        assertTrue(snippetHTML.contains("<span class=\"match\"><mark>Mit Ausnahme von Fremdwörtern und Namen ist das A der</mark><span class=\"cutted\"></span></span>"));
+        assertTrue(snippetHTML.contains("<span class=\"context-right\"><span class=\"more\"></span></span>"));
+
+    };
+
+    @Test
+    public void testUpdateConfigurationsMax () {
+        Properties props = new Properties();
+        props.setProperty("krill.context.left.maxShrink", "max");
+        props.setProperty("krill.context.right.maxShrink", "max");
+        KrillProperties.updateConfigurations(props);
+        assertEquals(KrillProperties.maxTokenContextSize,
+                     KrillProperties.leftContextMaxShrink);
+        assertEquals(KrillProperties.maxTokenContextSize,
+                     KrillProperties.rightContextMaxShrink);
+    };
+
+    @Test
+    public void testUpdateConfigurationsEdgeCases () {
+        Properties props = new Properties();
+
+        // Negative values should be clamped to 0
+        props.setProperty("krill.context.left.maxShrink", "-5");
+        props.setProperty("krill.context.right.maxShrink", "-10");
+        KrillProperties.updateConfigurations(props);
+        assertEquals(0, KrillProperties.leftContextMaxShrink);
+        assertEquals(0, KrillProperties.rightContextMaxShrink);
+
+        // Values exceeding maxTokenContextSize should be clamped
+        props.setProperty("krill.context.left.maxShrink", "9999");
+        props.setProperty("krill.context.right.maxShrink", "9999");
+        KrillProperties.updateConfigurations(props);
+        assertEquals(KrillProperties.maxTokenContextSize,
+                     KrillProperties.leftContextMaxShrink);
+        assertEquals(KrillProperties.maxTokenContextSize,
+                     KrillProperties.rightContextMaxShrink);
+
+        // Normal value
+        props.setProperty("krill.context.left.maxShrink", "3");
+        props.setProperty("krill.context.right.maxShrink", "7");
+        KrillProperties.updateConfigurations(props);
+        assertEquals(3, KrillProperties.leftContextMaxShrink);
+        assertEquals(7, KrillProperties.rightContextMaxShrink);
+    };
+
+    @Test
+    public void testTokenSnippetMatchLengthLongRightOnly ()
+        throws JsonMappingException, JsonProcessingException {
+        int before = KrillProperties.maxTokenMatchSize;
+        KrillProperties.maxTokenMatchSize = 5;
+        JsonNode jsonNode = mapper.readTree(jsonQuery);
+        Krill ks = new Krill(jsonNode);
+        Result kr = ks.apply(ki);
+
+        Match km = kr.getMatch(0);
+        assertEquals(34, km.getStartPos());
+        assertEquals(39, km.getEndPos());
+        assertEquals(5, km.getContext().left.getLength());
+        assertEquals(5, km.getContext().right.getLength());
+
+        KrillProperties.leftContextMaxShrink = 0;
+        KrillProperties.rightContextMaxShrink = 5;
+
+        String snippetToken = kr.getMatch(0).getSnippetTokens().toString();
+        KrillProperties.maxTokenMatchSize = before;
+
+        assertTrue(snippetToken.contains(
+                       "\"left\":[\"sechsthäufigste\",\"Buchstabe\",\"in\",\"deutschen\",\"Texten\"]"
+                       ));
+        assertTrue(snippetToken.contains(
+                       "\"match\":[\"Mit\",\"Ausnahme\",\"von\",\"Fremdwörtern\",\"und\"]"
+                       ));
+        assertTrue(!snippetToken.contains("\"right\""));
+    };
+
+    @Test
+    public void testSnippetBracketsWithAdjustment ()
+        throws JsonMappingException, JsonProcessingException {
+        JsonNode jsonNode = mapper.readTree(jsonQuery);
+        Krill ks = new Krill(jsonNode);
+        Result kr = ks.apply(ki);
+
+        Match km = kr.getMatch(0);
+        assertEquals(34, km.getStartPos());
+        assertEquals(60, km.getEndPos());
+
+        KrillProperties.leftContextMaxShrink = 4;
+        KrillProperties.rightContextMaxShrink = 2;
+
+        String brackets = kr.getMatch(0).getSnippetBrackets();
+
+        assertTrue(brackets.contains("Texten."));
+        assertTrue(!brackets.contains("sechsthäufigste"));
+        assertTrue(brackets.contains("[["));
+        assertTrue(brackets.contains("]]"));
+        assertTrue(brackets.contains("Aussprache Im"));
+        assertTrue(!brackets.contains("Deutschen und"));
+    };
+
+    @Test
+    public void testSmallClientContextWithLargeAdjustment ()
+        throws IOException {
+        SpanTermQuery stq = new SpanTermQuery(new Term("tokens", "s:des"));
+        Result kr = ki.search(stq, (short) 10);
+
+        KrillProperties.leftContextMaxShrink = 25;
+        KrillProperties.rightContextMaxShrink = 25;
+
+        Match km = kr.getMatch(0);
+        String snippetToken = km.getSnippetTokens().toString();
+
+        assertTrue(snippetToken.contains("\"match\":[\"des\"]"));
+
+        String snippetHTML = km.getSnippetHTML();
+        assertTrue(snippetHTML.contains("<span class=\"match\">"));
+
+        km = kr.getMatch(1);
+        snippetToken = km.getSnippetTokens().toString();
+        assertTrue(snippetToken.contains("\"match\":[\"des\"]"));
+    };
+
+    @Test
+    public void testGuardClampsOverShrinkWithLongMatch ()
+        throws JsonMappingException, JsonProcessingException {
+        // Use a small context (2 tokens) with the sentence query (match is 26 tokens)
+        // and a large adjustment. The guard must clamp shrink to available context.
+        JsonNode jsonNode = mapper.readTree(jsonQuery);
+        ArrayNode leftNode = (ArrayNode) jsonNode.at("/meta/context/left");
+        ArrayNode rightNode = (ArrayNode) jsonNode.at("/meta/context/right");
+        leftNode.set(1, "2");
+        rightNode.set(1, "2");
+
+        Krill ks = new Krill(jsonNode);
+        Result kr = ks.apply(ki);
+
+        Match km = kr.getMatch(0);
+        assertEquals(34, km.getStartPos());
+        assertEquals(60, km.getEndPos());
+        assertEquals(2, km.getContext().left.getLength());
+        assertEquals(2, km.getContext().right.getLength());
+
+        // Set adjustment much larger than available context
+        KrillProperties.leftContextMaxShrink = 25;
+        KrillProperties.rightContextMaxShrink = 25;
+
+        // Without the guard, shrinkLeft/shrinkRight would be 13 each (half of 26),
+        // but available context is only 2 on each side.
+        // The guard clamps to 2, so all context is consumed.
+        String snippetToken = km.getSnippetTokens().toString();
+        assertTrue(!snippetToken.contains("\"left\""));
+        assertTrue(!snippetToken.contains("\"right\""));
+        assertTrue(snippetToken.contains("\"match\""));
+
+        String snippetHTML = km.getSnippetHTML();
+        assertTrue(snippetHTML.contains("<span class=\"context-left\"><span class=\"more\"></span></span>"));
+        assertTrue(snippetHTML.contains("<span class=\"context-right\"><span class=\"more\"></span></span>"));
+    };
+
+    @Test
+    public void testKwicMaxTokenBasic () {
+        // kwic.max.token = matchMax + 2*contextMax - totalShrink
+        // Setting kwicMaxToken=60 with matchMax=50 and contextMax=25:
+        // maxShrink = contextMax*2 + matchMax - kwicMaxToken = 25*2 + 50 - 60 = 40
+        // split evenly: leftMaxShrink=20, rightMaxShrink=20
+        Properties props = new Properties();
+        props.setProperty("krill.kwic.max.token", "60");
+        KrillProperties.updateConfigurations(props);
+        assertEquals(60, KrillProperties.kwicMaxToken);
+        assertEquals(20, KrillProperties.leftContextMaxShrink);
+        assertEquals(20, KrillProperties.rightContextMaxShrink);
+    };
+
+    @Test
+    public void testKwicMaxTokenOverridesIndividual () {
+        // When kwic.max.token is set, individual maxShrink values are ignored
+        Properties props = new Properties();
+        props.setProperty("krill.context.left.maxShrink", "3");
+        props.setProperty("krill.context.right.maxShrink", "7");
+        props.setProperty("krill.kwic.max.token", "60");
+        KrillProperties.updateConfigurations(props);
+        assertEquals(60, KrillProperties.kwicMaxToken);
+        // Derived from kwic.max.token, not from the individual values
+        assertEquals(20, KrillProperties.leftContextMaxShrink);
+        assertEquals(20, KrillProperties.rightContextMaxShrink);
+    };
+
+    @Test
+    public void testKwicMaxTokenEqualsTotalAllowance () {
+        // kwicMaxToken = matchMax + 2*contextMax means no shrink needed
+        // 50 + 2*25 = 100
+        Properties props = new Properties();
+        props.setProperty("krill.kwic.max.token", "100");
+        KrillProperties.updateConfigurations(props);
+        assertEquals(100, KrillProperties.kwicMaxToken);
+        assertEquals(0, KrillProperties.leftContextMaxShrink);
+        assertEquals(0, KrillProperties.rightContextMaxShrink);
+    };
+
+    @Test
+    public void testKwicMaxTokenSmall () {
+        // kwicMaxToken = matchMax means context fully shrinks
+        // maxShrink = 25*2 + 50 - 50 = 50, clamped to 2*contextMax = 50
+        // split evenly: 25/25
+        Properties props = new Properties();
+        props.setProperty("krill.kwic.max.token", "50");
+        KrillProperties.updateConfigurations(props);
+        assertEquals(50, KrillProperties.kwicMaxToken);
+        assertEquals(25, KrillProperties.leftContextMaxShrink);
+        assertEquals(25, KrillProperties.rightContextMaxShrink);
+    };
+
+    @Test
+    public void testKwicMaxTokenTooSmall () {
+        // kwicMaxToken below matchMax is clamped: shrink can't exceed 2*contextMax
+        Properties props = new Properties();
+        props.setProperty("krill.kwic.max.token", "10");
+        KrillProperties.updateConfigurations(props);
+        assertEquals(10, KrillProperties.kwicMaxToken);
+        assertEquals(25, KrillProperties.leftContextMaxShrink);
+        assertEquals(25, KrillProperties.rightContextMaxShrink);
+    };
+
+    @Test
+    public void testKwicMaxTokenWithSearch ()
+        throws JsonMappingException, JsonProcessingException {
+        // End-to-end test: kwic.max.token=15, matchMax=5, contextMax=25
+        // maxShrink = 25*2+5-15 = 40, clamped to 50 = 20/20
+        // But context is only 5 on each side (query requests 5).
+        // With a 5-token match, requiredShrink = min(5, 40) = 5, split 2/3
+        int before = KrillProperties.maxTokenMatchSize;
+        KrillProperties.maxTokenMatchSize = 5;
+
+        Properties props = new Properties();
+        props.setProperty("krill.kwic.max.token", "15");
+        KrillProperties.updateConfigurations(props);
+
+        JsonNode jsonNode = mapper.readTree(jsonQuery);
+        Krill ks = new Krill(jsonNode);
+        Result kr = ks.apply(ki);
+
+        Match km = kr.getMatch(0);
+        assertEquals(34, km.getStartPos());
+        assertEquals(39, km.getEndPos());
+        assertEquals(5, km.getContext().left.getLength());
+        assertEquals(5, km.getContext().right.getLength());
+
+        String snippetToken = km.getSnippetTokens().toString();
+        KrillProperties.maxTokenMatchSize = before;
+
+        // maxShrink=40 = left=20,right=20; match=5; requiredShrink=min(5,40)=5
+        // shrinkLeft = round(5*(20/40))=round(2.5)=3, shrinkRight=2
+        // left context: 5-3=2, right context: 5-2=3
+        assertTrue(snippetToken.contains("\"left\":[\"deutschen\",\"Texten\"]"));
+        assertTrue(snippetToken.contains(
+                       "\"match\":[\"Mit\",\"Ausnahme\",\"von\",\"Fremdwörtern\",\"und\"]"
+                       ));
+        assertTrue(snippetToken.contains("\"right\":[\"Namen\",\"ist\",\"das\"]"));
+    };
+
+    @Test
+    public void testKwicMaxTokenNotSet () {
+        // When kwic.max.token is not set, individual maxShrink values work normally
+        Properties props = new Properties();
+        props.setProperty("krill.context.left.maxShrink", "3");
+        props.setProperty("krill.context.right.maxShrink", "7");
+        KrillProperties.updateConfigurations(props);
+        assertEquals(-1, KrillProperties.kwicMaxToken);
+        assertEquals(3, KrillProperties.leftContextMaxShrink);
+        assertEquals(7, KrillProperties.rightContextMaxShrink);
+    };
+};
diff --git a/src/test/java/de/ids_mannheim/korap/response/TestMatch.java b/src/test/java/de/ids_mannheim/korap/response/TestMatch.java
index fcc1db3..6e67b9d 100644
--- a/src/test/java/de/ids_mannheim/korap/response/TestMatch.java
+++ b/src/test/java/de/ids_mannheim/korap/response/TestMatch.java
@@ -53,6 +53,5 @@
                 false);
         assertEquals(326, m.getStartPos());
         assertEquals(376, m.getEndPos());
-    };
-
+    };   
 };