ConversationTest.java

  1package eu.siacs.conversations.entities;
  2
  3import static org.mockito.ArgumentMatchers.any;
  4import static org.mockito.Mockito.doAnswer;
  5import static org.mockito.Mockito.mock;
  6import static org.mockito.Mockito.when;
  7
  8import java.util.concurrent.atomic.AtomicReference;
  9
 10import org.junit.Before;
 11import org.junit.BeforeClass;
 12import org.junit.Test;
 13import org.junit.runner.RunWith;
 14import org.robolectric.RobolectricTestRunner;
 15import org.robolectric.RuntimeEnvironment;
 16import org.robolectric.Shadows;
 17import org.robolectric.annotation.Config;
 18import org.robolectric.annotation.ConscryptMode;
 19
 20import android.os.Build;
 21import android.os.Looper;
 22import android.view.ContextThemeWrapper;
 23import android.view.LayoutInflater;
 24import android.view.View;
 25import android.widget.ListView;
 26import android.widget.RelativeLayout;
 27import androidx.viewpager.widget.ViewPager;
 28import com.google.android.material.tabs.TabLayout;
 29import eu.siacs.conversations.Conversations;
 30import eu.siacs.conversations.R;
 31import eu.siacs.conversations.databinding.CommandSliderFieldBinding;
 32import eu.siacs.conversations.services.XmppConnectionService;
 33import eu.siacs.conversations.xml.Element;
 34import eu.siacs.conversations.xml.Namespace;
 35import eu.siacs.conversations.xmpp.Jid;
 36import im.conversations.android.xmpp.model.disco.info.Feature;
 37import im.conversations.android.xmpp.model.disco.info.InfoQuery;
 38import junit.framework.Assert;
 39
 40@RunWith(RobolectricTestRunner.class)
 41@Config(sdk = Build.VERSION_CODES.TIRAMISU, application = Conversations.class)
 42@ConscryptMode(ConscryptMode.Mode.OFF)
 43public class ConversationTest {
 44    private static final InfoQuery INFO_QUERY_WITH_OCCUPANT_ID = new InfoQuery();
 45    private static final InfoQuery INFO_QUERY_WITHOUT_OCCUPANT_ID = new InfoQuery();
 46
 47    private Conversation withOccupantId;
 48    private Conversation withoutOccupantId;
 49    private Conversation nullMucOptions;
 50
 51    @BeforeClass
 52    public static void setupClass() {
 53        final var occupantIdFeature = new Feature();
 54        occupantIdFeature.setVar(Namespace.OCCUPANT_ID);
 55        INFO_QUERY_WITH_OCCUPANT_ID.addChild(occupantIdFeature);
 56    }
 57
 58    @Before
 59    public void setUp() throws Exception {
 60        final var account = mock(Account.class);
 61        when(account.getJid()).thenReturn(Jid.ofLocalAndDomain("testAccount", "example.org"));
 62
 63        withOccupantId = new Conversation(
 64            "Test MUC",
 65            account,
 66            Jid.ofLocalAndDomain("testMuc", "example.org"),
 67            Conversation.MODE_MULTI
 68        );
 69        withOccupantId.getMucOptions().updateConfiguration(INFO_QUERY_WITH_OCCUPANT_ID);
 70
 71        withoutOccupantId = new Conversation(
 72            "Test MUC",
 73            account,
 74            Jid.ofLocalAndDomain("testMuc", "example.org"),
 75            Conversation.MODE_MULTI
 76        );
 77        withoutOccupantId.getMucOptions().updateConfiguration(INFO_QUERY_WITHOUT_OCCUPANT_ID);
 78
 79        nullMucOptions = new Conversation(
 80            "Test MUC",
 81            account,
 82            Jid.ofLocalAndDomain("testMuc", "example.org"),
 83            Conversation.MODE_MULTI
 84        );
 85        final var mucOptionsField = Conversation.class.getDeclaredField("mucOptions");
 86        mucOptionsField.setAccessible(true);
 87        ((AtomicReference<?>) mucOptionsField.get(nullMucOptions)).set(null);
 88    }
 89
 90    @Test
 91    public void getMucOccupantsCacheReturnsCacheWhenMucOptionsIsNull() throws Exception {
 92        var cache = nullMucOptions.getMucOccupantCache();
 93        Assert.assertNotNull("Should return cache when mucOptions is null", cache);
 94    }
 95
 96    @Test
 97    public void getMucOccupantsCacheReturnsCacheWhenFeatureSupported() throws Exception {
 98        var cache = withOccupantId.getMucOccupantCache();
 99        Assert.assertNotNull("Should return cache when occupant-id is supported", cache);
100    }
101
102    @Test
103    public void getMucOccupantsCacheReturnsCacheWhenFeatureNotSupported() throws Exception {
104        var cache = withoutOccupantId.getMucOccupantCache();
105        Assert.assertNotNull("Should return cache even when occupant-id is not supported", cache);
106    }
107
108    @Test
109    public void setupViewPagerListenerDoesNotLeakBetweenConversations() {
110        final var context = RuntimeEnvironment.getApplication();
111        final var pager = new ViewPager(context);
112        final var tabs = mock(TabLayout.class);
113
114        final int[] selectedTabPosition = {0};
115        doAnswer(invocation -> {
116            ViewPager vp = invocation.getArgument(0);
117            vp.addOnPageChangeListener(new ViewPager.OnPageChangeListener() {
118                public void onPageScrollStateChanged(int state) {}
119                public void onPageScrolled(int p, float o, int opx) {}
120                public void onPageSelected(int position) {
121                    selectedTabPosition[0] = position;
122                }
123            });
124            return null;
125        }).when(tabs).setupWithViewPager(any(ViewPager.class));
126        when(tabs.getSelectedTabPosition()).thenAnswer(inv -> selectedTabPosition[0]);
127
128        final var page1 = new RelativeLayout(context);
129        final var page2 = new RelativeLayout(context);
130        final var commandsView = new ListView(context);
131        commandsView.setId(R.id.commands_view);
132        page2.addView(commandsView);
133        pager.addView(page1);
134        pager.addView(page2);
135
136        final var account = mock(Account.class);
137        when(account.getJid()).thenReturn(Jid.ofLocalAndDomain("testAccount", "example.org"));
138        final var roster = mock(Roster.class);
139        when(account.getRoster()).thenReturn(roster);
140
141        final var mucJid = Jid.ofLocalAndDomain("operations", "conference.soprani.ca");
142        final var mucContact = mock(Contact.class);
143        when(mucContact.isApp()).thenReturn(false);
144        when(mucContact.getJid()).thenReturn(mucJid);
145        when(roster.getContact(mucJid)).thenReturn(mucContact);
146
147        final var appJid = Jid.ofDomain("jmp.chat");
148        final var appContact = mock(Contact.class);
149        when(appContact.isApp()).thenReturn(true);
150        when(appContact.getJid()).thenReturn(appJid);
151        when(roster.getContact(appJid)).thenReturn(appContact);
152
153        final var muc = new Conversation("MUC", account, mucJid, Conversation.MODE_MULTI);
154        final var app = new Conversation("App", account, appJid, Conversation.MODE_SINGLE);
155
156        muc.setupViewPager(pager, tabs, false, null);
157        muc.showViewPager();
158
159        pager.layout(0, 0, 1024, 768);
160        Shadows.shadowOf(Looper.getMainLooper()).idle();
161
162        Assert.assertEquals("MUC should start on Conversation tab", 0, muc.getCurrentTab());
163
164        app.setupViewPager(pager, tabs, false, muc);
165        app.showViewPager();
166
167        Shadows.shadowOf(Looper.getMainLooper()).idle();
168
169        pager.setCurrentItem(1);
170
171        Assert.assertEquals(
172            "Page change on app conversation must not corrupt MUC's tab state",
173            0,
174            muc.getCurrentTab());
175
176        Assert.assertEquals(
177            "Tab indicator must stay in sync with the selected page",
178            1,
179            tabs.getSelectedTabPosition());
180    }
181
182    @Test
183    public void steppedSliderStepRejectsInRangeValueMissingFromOptionLattice() {
184        final var field = sliderField("xs:integer", "0", "68", "7", "0", "34", "68");
185
186        Assert.assertNull(
187                "A value the option-derived slider cannot represent should fall back to text input",
188                Conversation.steppedSliderStep(field));
189    }
190
191    @Test
192    public void steppedSliderStepAcceptsCompatibleOptionLattice() {
193        final var field = sliderField("xs:integer", "0", "70", "35", "0", "35", "70");
194
195        Assert.assertEquals(Float.valueOf(35f), Conversation.steppedSliderStep(field));
196    }
197
198    @Test
199    public void steppedSliderStepUsesIntegerStepWithoutOptions() {
200        final var field = sliderField("xs:integer", "0", "10", "7");
201
202        Assert.assertEquals(Float.valueOf(1f), Conversation.steppedSliderStep(field));
203    }
204
205    @Test
206    public void steppedSliderStepUsesContinuousStepWithoutOptionsForDecimal() {
207        final var field = sliderField("xs:decimal", "0", "1", "0.000001");
208
209        Assert.assertEquals(Float.valueOf(0f), Conversation.steppedSliderStep(field));
210    }
211
212    @Test
213    public void formatSliderValueDoesNotClampLargeIntegerDatatypesToInt() {
214        Assert.assertEquals("3000000000", Conversation.formatSliderValue(3_000_000_000f, "xs:long"));
215    }
216
217    @Test
218    public void formatSliderValueUsesPlainDecimalLexicalFormForDecimalDatatype() {
219        Assert.assertEquals("0.000001", Conversation.formatSliderValue(0.000001f, "xs:decimal"));
220    }
221
222    @Test
223    public void steppedSliderStepRejectsIncompatibleBounds() {
224        final var field = sliderField("xs:integer", "0", "70", "34", "0", "34", "68");
225
226        Assert.assertNull(Conversation.steppedSliderStep(field));
227    }
228
229    @Test
230    public void steppedSliderStepRejectsMalformedOptions() {
231        final var field = sliderField("xs:integer", "0", "70", "35", "0", "not-a-number", "70");
232
233        Assert.assertNull(Conversation.steppedSliderStep(field));
234    }
235
236    @Test
237    public void steppedSliderStepRejectsValuesOutsideRange() {
238        final var below = sliderField("xs:integer", "0", "70", "-5", "0", "35", "70");
239        final var above = sliderField("xs:integer", "0", "70", "999", "0", "35", "70");
240
241        Assert.assertNull(Conversation.steppedSliderStep(below));
242        Assert.assertNull(Conversation.steppedSliderStep(above));
243    }
244
245    @Test
246    public void steppedSliderStepRejectsDuplicateOptions() {
247        final var field = sliderField("xs:integer", "0", "70", "35", "0", "35", "35", "70");
248
249        Assert.assertNull(Conversation.steppedSliderStep(field));
250    }
251
252    @Test
253    public void rangedListSingleWithCustomValueFallsBackToTextInputWhenSliderCannotRepresentIt() {
254        final var session = withOccupantId.pagerAdapter.new CommandSession(
255                "test", "node", mock(XmppConnectionService.class));
256        try {
257            session.responseElement = new Element("x", Namespace.DATA);
258            session.responseElement.setAttribute("type", "form");
259            final var field = sliderField("xs:integer", "0", "68", "7", "0", "34", "68");
260            field.setAttribute("type", "list-single");
261
262            Assert.assertEquals(session.TYPE_TEXT_FIELD, session.mkField(field).viewType);
263        } finally {
264            session.loadingTimer.cancel();
265        }
266    }
267
268    @Test
269    public void sliderFieldViewHolderBindsEmptyValueWithoutCrashing() {
270        final var context = new ContextThemeWrapper(
271                RuntimeEnvironment.getApplication(),
272                com.google.android.material.R.style.Theme_MaterialComponents_DayNight_NoActionBar);
273        final var session = withOccupantId.pagerAdapter.new CommandSession(
274                "test", "node", mock(XmppConnectionService.class));
275        try {
276            final var binding = CommandSliderFieldBinding.inflate(LayoutInflater.from(context));
277            final var holder = session.new SliderFieldViewHolder(binding);
278            final var field = sliderField("xs:integer", "0", "70", "", "0", "35", "70");
279            final var item = session.new Field(
280                    eu.siacs.conversations.xmpp.forms.Field.parse(field),
281                    session.TYPE_SLIDER_FIELD);
282
283            holder.bind(item);
284
285            Assert.assertEquals(0f, binding.slider.getValue(), 0.0001f);
286        } finally {
287            session.loadingTimer.cancel();
288        }
289    }
290
291    private static Element sliderField(
292            final String datatype,
293            final String min,
294            final String max,
295            final String value,
296            final String... options) {
297        final var field = new Element("field", Namespace.DATA);
298        field.setAttribute("type", "text-single");
299        final var validate = field.addChild("validate", "http://jabber.org/protocol/xdata-validate");
300        validate.setAttribute("datatype", datatype);
301        final var range = validate.addChild("range", "http://jabber.org/protocol/xdata-validate");
302        range.setAttribute("min", min);
303        range.setAttribute("max", max);
304        if (value != null) {
305            field.addChild("value", Namespace.DATA).setContent(value);
306        }
307        for (final String option : options) {
308            field.addChild("option", Namespace.DATA)
309                    .addChild("value", Namespace.DATA)
310                    .setContent(option);
311        }
312        return field;
313    }
314}