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}