mirror of
https://github.com/praktimarc/kst4contest.git
synced 2026-10-26 16:15:51 +01:00
Fix Compose UI regressions in KST4Contest 1.50 (#147)
* test: make stylesheet tests platform-independent * fix: address Compose UI regressions * fix: complete Compose UI workflow corrections * feat: improve manual QTF heading control
This commit is contained in:
1 parent
4642d658a3
commit
a4e10300e7
114 files changed
+8065
-1128
No files matched your search
@@ -1,12 +1,19 @@
|
||||
package kst4contest.view;
|
||||
|
||||
import kst4contest.model.ChatCategory;
|
||||
import kst4contest.model.ChatMember;
|
||||
import kst4contest.model.ChatMessage;
|
||||
import kst4contest.observe.DirectUiDispatcher;
|
||||
import kst4contest.observe.SimpleRoster;
|
||||
import kst4contest.view.compose.SelectedMessageFilter;
|
||||
import kst4contest.view.compose.SelectedStationMessagePredicate;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
@@ -136,6 +143,39 @@ class RosterListBindingTest {
|
||||
"a newly set filter must be visible without waiting for the next roster change");
|
||||
}
|
||||
|
||||
@Test
|
||||
void selectedStationMessagesFollowNewMessagesAndSelectionChanges() {
|
||||
ChatMember first = member("DL1FIRST", 2);
|
||||
ChatMember second = member("DL2SECOND", 2);
|
||||
SimpleRoster<ChatMessage> messages = new SimpleRoster<>();
|
||||
AtomicReference<Predicate<ChatMessage>> filter = new AtomicReference<>(
|
||||
SelectedStationMessagePredicate.forSelection(
|
||||
SelectedMessageFilter.PUBLIC, first, "DL0OWN"));
|
||||
|
||||
RosterListBinding<ChatMessage> binding = RosterListBinding.derived(
|
||||
new DirectUiDispatcher(),
|
||||
() -> messages.snapshot().stream().filter(filter.get()).toList(),
|
||||
messages);
|
||||
|
||||
ChatMessage unrelated = publicMessage(second);
|
||||
messages.add(unrelated);
|
||||
assertTrue(binding.list().isEmpty());
|
||||
|
||||
ChatMessage matching = publicMessage(first);
|
||||
messages.add(matching);
|
||||
assertEquals(List.of(matching), List.copyOf(binding.list()));
|
||||
|
||||
filter.set(SelectedStationMessagePredicate.forSelection(
|
||||
SelectedMessageFilter.PUBLIC, second, "DL0OWN"));
|
||||
binding.refresh();
|
||||
assertEquals(List.of(unrelated), List.copyOf(binding.list()),
|
||||
"the former station's messages must disappear immediately");
|
||||
|
||||
ChatMessage next = publicMessage(second);
|
||||
messages.add(next);
|
||||
assertEquals(List.of(unrelated, next), List.copyOf(binding.list()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aDisposedDerivedBindingDetachesFromEverySource() {
|
||||
SimpleRoster<String> members = new SimpleRoster<>();
|
||||
@@ -150,4 +190,20 @@ class RosterListBindingTest {
|
||||
|
||||
assertTrue(binding.list().isEmpty());
|
||||
}
|
||||
|
||||
private static ChatMember member(String callSign, int category) {
|
||||
ChatMember member = new ChatMember();
|
||||
member.setCallSign(callSign);
|
||||
member.setChatCategory(new ChatCategory(category));
|
||||
return member;
|
||||
}
|
||||
|
||||
private static ChatMessage publicMessage(ChatMember sender) {
|
||||
ChatMessage message = new ChatMessage();
|
||||
message.setSender(sender);
|
||||
message.setReceiver(member("ALL", sender.getChatCategory().getCategoryNumber()));
|
||||
message.setChatCategory(sender.getChatCategory());
|
||||
message.setMessageText("test");
|
||||
return message;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package kst4contest.view;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.LinkedBlockingQueue;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.Timeout;
|
||||
|
||||
import kst4contest.ApplicationConstants;
|
||||
import kst4contest.controller.ChatController;
|
||||
import kst4contest.controller.MessageBusManagementThread;
|
||||
import kst4contest.controller.ScoreService;
|
||||
import kst4contest.controller.StationMetricsService;
|
||||
import kst4contest.controller.ThreadStatusCallback;
|
||||
import kst4contest.model.AirPlaneReflectionInfo;
|
||||
import kst4contest.model.ChatCategory;
|
||||
import kst4contest.model.ChatMember;
|
||||
import kst4contest.model.ChatMessage;
|
||||
import kst4contest.model.ChatPreferences;
|
||||
import kst4contest.observe.DirectUiDispatcher;
|
||||
import kst4contest.observe.SimpleRoster;
|
||||
import kst4contest.observe.SimpleValue;
|
||||
import kst4contest.view.compose.DataTableState;
|
||||
import kst4contest.view.compose.RowKeys;
|
||||
import kst4contest.view.compose.SelectedMessageFilter;
|
||||
import kst4contest.view.compose.SelectedStationMessagePredicate;
|
||||
import kst4contest.view.feed.SelectedStationMessagesFeed;
|
||||
|
||||
/** Exercises protocol parsing, selected-station filtering and the displayed table together. */
|
||||
class SelectedStationMessageFlowTest {
|
||||
|
||||
private static final String OWN_CALL = "DL0OWN";
|
||||
private static final String SELECTED_CALL = "DM5M";
|
||||
|
||||
private final LinkedBlockingQueue<ChatMessage> receiveQueue = new LinkedBlockingQueue<>();
|
||||
private final SimpleRoster<ChatMessage> messages = new SimpleRoster<>();
|
||||
private final AtomicReference<Predicate<ChatMessage>> filter = new AtomicReference<>();
|
||||
|
||||
private ChatController controller;
|
||||
private ChatPreferences preferences;
|
||||
private ChatMember selected;
|
||||
private RosterListBinding<ChatMessage> binding;
|
||||
private DataTableState<ChatMessage> displayed;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
preferences = mock(ChatPreferences.class);
|
||||
when(preferences.getStn_loginCallSign()).thenReturn(OWN_CALL);
|
||||
when(preferences.getStn_loginLocatorMainCat()).thenReturn("JO50LL");
|
||||
when(preferences.getMYQRGFirstCat()).thenReturn(new SimpleValue<>("144.300"));
|
||||
when(preferences.getMYQRGSecondCat()).thenReturn(new SimpleValue<>("1296.200"));
|
||||
|
||||
controller = mock(ChatController.class);
|
||||
when(controller.getChatPreferences()).thenReturn(preferences);
|
||||
when(controller.getChatCategoryMain()).thenReturn(new ChatCategory(ChatCategory.VUHF));
|
||||
when(controller.getChatCategorySecondChat()).thenReturn(new ChatCategory(ChatCategory.MICROWAVE));
|
||||
when(controller.getStationMetricsService()).thenReturn(mock(StationMetricsService.class));
|
||||
when(controller.getScoreService()).thenReturn(mock(ScoreService.class));
|
||||
when(controller.findActiveChatMember(any(ChatMember.class)))
|
||||
.thenAnswer(invocation -> activeMember(invocation.getArgument(0)));
|
||||
doAnswer(invocation -> {
|
||||
messages.add(invocation.getArgument(0));
|
||||
return null;
|
||||
}).when(controller).publishChatMessage(any(ChatMessage.class));
|
||||
|
||||
selected = member(SELECTED_CALL, ChatCategory.VUHF);
|
||||
filter.set(SelectedStationMessagePredicate.forSelection(
|
||||
SelectedMessageFilter.PM_TO_ME, selected, OWN_CALL));
|
||||
|
||||
displayed = new DataTableState<>(List.of(), RowKeys.INSTANCE.byReference(), "selected-flow-test");
|
||||
SelectedStationMessagesFeed feed =
|
||||
new SelectedStationMessagesFeed(displayed, new DirectUiDispatcher());
|
||||
binding = RosterListBinding.derived(
|
||||
new DirectUiDispatcher(),
|
||||
() -> messages.snapshot().stream().filter(filter.get()).toList(),
|
||||
messages);
|
||||
binding.onChanged(feed::push);
|
||||
feed.push(binding.list());
|
||||
}
|
||||
|
||||
@Test
|
||||
@Timeout(10)
|
||||
void realisticFramesReachTheDisplayedRowsUnderAllFourModes() throws InterruptedException {
|
||||
runThrough(
|
||||
chatFrame(2, SELECTED_CALL, "private", OWN_CALL),
|
||||
chatFrame(2, SELECTED_CALL, "(" + OWN_CALL + ") public reply", "0"),
|
||||
chatFrame(2, SELECTED_CALL, "general public message", "0"),
|
||||
chatFrame(2, SELECTED_CALL, "private to another station", "DL2OTHER"),
|
||||
chatFrame(2, "DL2OTHER", "other station", "0"),
|
||||
chatFrame(3, SELECTED_CALL, "same call, other category", "0"));
|
||||
|
||||
assertEquals(2, displayed.getRows().size(),
|
||||
"pm to me must contain the genuine PM and the explicitly addressed public line");
|
||||
|
||||
selectMode(SelectedMessageFilter.PUBLIC);
|
||||
assertEquals(2, displayed.getRows().size(),
|
||||
"the two public lines overlap with the addressed-public PM view");
|
||||
|
||||
selectMode(SelectedMessageFilter.PM_TO_OTHER);
|
||||
assertEquals(1, displayed.getRows().size(),
|
||||
"pm to other keeps private traffic sent by the selected station to another station");
|
||||
|
||||
selectMode(SelectedMessageFilter.NOTHING);
|
||||
assertEquals(4, displayed.getRows().size(),
|
||||
"nothing removes only the type/receiver restriction");
|
||||
|
||||
selectStation(member("DL2OTHER", ChatCategory.VUHF));
|
||||
assertEquals(2, displayed.getRows().size(),
|
||||
"changing the selected station must replace the displayed station scope immediately");
|
||||
selectStation(member(SELECTED_CALL, ChatCategory.VUHF));
|
||||
|
||||
runThrough(chatFrame(2, SELECTED_CALL, "new while the panel is open", OWN_CALL));
|
||||
assertEquals(5, displayed.getRows().size(),
|
||||
"a new matching line must reach the already open table");
|
||||
}
|
||||
|
||||
private void selectMode(SelectedMessageFilter mode) {
|
||||
filter.set(SelectedStationMessagePredicate.forSelection(mode, selected, OWN_CALL));
|
||||
binding.refresh();
|
||||
}
|
||||
|
||||
private void selectStation(ChatMember station) {
|
||||
selected = station;
|
||||
selectMode(SelectedMessageFilter.NOTHING);
|
||||
}
|
||||
|
||||
private void runThrough(ChatMessage... frames) throws InterruptedException {
|
||||
MessageBusManagementThread thread = new MessageBusManagementThread(
|
||||
controller,
|
||||
mock(ThreadStatusCallback.class),
|
||||
11L,
|
||||
receiveQueue,
|
||||
ignored -> true);
|
||||
for (ChatMessage frame : frames) {
|
||||
receiveQueue.add(frame);
|
||||
}
|
||||
receiveQueue.add(poisonPill());
|
||||
thread.start();
|
||||
thread.join();
|
||||
}
|
||||
|
||||
private static ChatMessage chatFrame(int category, String sender, String text, String receiver) {
|
||||
return wireMessage("CH|" + category + "|1700000000|" + sender
|
||||
+ "|Operator|0|" + text + "|" + receiver + "|");
|
||||
}
|
||||
|
||||
private static ChatMessage poisonPill() {
|
||||
ChatMessage message = wireMessage(ApplicationConstants.DISCONNECT_RDR_POISONPILL);
|
||||
message.setMessageSenderName(ApplicationConstants.DISCONNECT_RDR_POISONPILL);
|
||||
return message;
|
||||
}
|
||||
|
||||
private static ChatMessage wireMessage(String text) {
|
||||
ChatMessage message = new ChatMessage();
|
||||
message.setMessageText(text);
|
||||
return message;
|
||||
}
|
||||
|
||||
private static ChatMember activeMember(ChatMember lookup) {
|
||||
return member(lookup.getCallSign(), lookup.getChatCategory().getCategoryNumber());
|
||||
}
|
||||
|
||||
private static ChatMember member(String callSign, int category) {
|
||||
ChatMember member = new ChatMember();
|
||||
member.setCallSign(callSign);
|
||||
member.setChatCategory(new ChatCategory(category));
|
||||
member.setQra("JO31AA");
|
||||
member.setAirPlaneReflectInfo(new AirPlaneReflectionInfo());
|
||||
return member;
|
||||
}
|
||||
}
|
||||
@@ -3,19 +3,23 @@ package kst4contest.view.compose
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.assertCountEquals
|
||||
import androidx.compose.ui.test.assertIsNotEnabled
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.runComposeUiTest
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kst4contest.model.ChatCategory
|
||||
import kst4contest.model.ChatMember
|
||||
import kst4contest.model.ChatMessage
|
||||
import kst4contest.model.ChatPreferences
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
import java.nio.file.Path
|
||||
import kotlin.math.abs
|
||||
|
||||
/** The send line with SO2R: the display-only QTF2 field appears only while SO2R is on. */
|
||||
@OptIn(ExperimentalTestApi::class)
|
||||
@@ -66,4 +70,53 @@ class ChatInputRowSo2rScreenTest {
|
||||
onNodeWithText("270.0").assertExists()
|
||||
onNodeWithText("270.0").assertIsNotEnabled()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `send buttons message and station fields share height and preserve realistic values`() = runComposeUiTest {
|
||||
val prefs = prefs().apply {
|
||||
getMYQRGFirstCat().set("1296.123.000")
|
||||
getMYQRGSecondCat().set("144.310.000")
|
||||
getActualQTF().set(360.0)
|
||||
}
|
||||
val state = chatInputStateFor(prefs).apply {
|
||||
value = TextFieldValue("/cq OK1IN test test test aasdasd ggggg asd asd gggg")
|
||||
}
|
||||
setContent {
|
||||
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
|
||||
ChatInputRow(
|
||||
state = state,
|
||||
prefs = prefs,
|
||||
ownQrgMain = ObservedValue(prefs.getMYQRGFirstCat()),
|
||||
ownQrgSecond = ObservedValue(prefs.getMYQRGSecondCat()),
|
||||
antennaQtf = ObservedValue(prefs.getActualQTF()),
|
||||
antennaQtf2 = ObservedValue(prefs.getActualQTF2()),
|
||||
so2rEnabled = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val values = listOf("1296.123.000", "144.310.000", "360.0")
|
||||
val heights = listOf("TX", "clear")
|
||||
.map { onNodeWithText(it).fetchSemanticsNode().boundsInRoot.height } +
|
||||
values.map { onNodeWithText(it).fetchSemanticsNode().boundsInRoot.height }
|
||||
assertTrue(
|
||||
heights.max() - heights.min() <= 1f,
|
||||
"send-row control heights differ: $heights",
|
||||
)
|
||||
|
||||
val minimumQrgWidth = with(density) { 96.dp.toPx() }
|
||||
values.take(2).forEach { value ->
|
||||
val width = onNodeWithText(value).fetchSemanticsNode().boundsInRoot.width
|
||||
assertTrue(width >= minimumQrgWidth - 1f, "$value has only $width px")
|
||||
}
|
||||
val qtfWidth = onNodeWithText("360.0").fetchSemanticsNode().boundsInRoot.width
|
||||
assertTrue(
|
||||
abs(qtfWidth - with(density) { 56.dp.toPx() }) <= 1f,
|
||||
"360.0 field is $qtfWidth px wide",
|
||||
)
|
||||
assertTrue(
|
||||
abs(onNodeWithText("TX").fetchSemanticsNode().boundsInRoot.height -
|
||||
with(density) { 26.dp.toPx() }) <= 1f,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.runComposeUiTest
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.math.abs
|
||||
|
||||
/** Shared fields and choices use the same compact desktop height. */
|
||||
@OptIn(ExperimentalTestApi::class)
|
||||
class CompactControlsScreenTest {
|
||||
|
||||
@Test
|
||||
fun fieldsChoicesAndButtonsShareTheDesktopControlHeight() = runComposeUiTest {
|
||||
setContent {
|
||||
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
|
||||
Column {
|
||||
CompactTextField(value = "144.310.000", onValueChange = {})
|
||||
Form.inlineChoice(
|
||||
label = "Reachability",
|
||||
items = listOf("Auto"),
|
||||
selected = "Auto",
|
||||
describe = { it },
|
||||
onSelect = {},
|
||||
)
|
||||
Form.button(text = "TX", onClick = {})
|
||||
Form.button(text = "Filter", onClick = {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val heights = listOf("144.310.000", "Auto", "TX", "Filter")
|
||||
.map { onNodeWithText(it).fetchSemanticsNode().boundsInRoot.height }
|
||||
val targetHeight = with(density) { 26.dp.toPx() }
|
||||
|
||||
assertTrue(
|
||||
heights.all { abs(it - targetHeight) <= 1f },
|
||||
"controls are $heights px tall instead of $targetHeight px",
|
||||
)
|
||||
assertTrue(
|
||||
heights.max() - heights.min() <= 1f,
|
||||
"control heights differ by more than one pixel: $heights",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun accentAndToggleButtonsShareEnabledAndDisabledClickBehaviour() = runComposeUiTest {
|
||||
var accentClicks = 0
|
||||
var disabledClicks = 0
|
||||
setContent {
|
||||
Kst4ContestTheme(darkMode = true, baseFontSizeSp = 12f) {
|
||||
Column {
|
||||
Form.button(
|
||||
text = "Accent",
|
||||
accented = true,
|
||||
onClick = { accentClicks++ },
|
||||
)
|
||||
Form.button(
|
||||
text = "Selected",
|
||||
selected = true,
|
||||
onClick = {},
|
||||
)
|
||||
Form.button(
|
||||
text = "Disabled accent",
|
||||
enabled = false,
|
||||
accented = true,
|
||||
onClick = { disabledClicks++ },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onNodeWithText("Accent").performClick()
|
||||
onNodeWithText("Disabled accent").performClick()
|
||||
|
||||
assertEquals(1, accentClicks)
|
||||
assertEquals(0, disabledClicks)
|
||||
assertTrue(onNodeWithText("Selected").fetchSemanticsNode().boundsInRoot.height < 40f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun controlsStayAlignedAt125PercentDensityInEveningDesign() = runComposeUiTest {
|
||||
setContent {
|
||||
CompositionLocalProvider(
|
||||
LocalDensity provides androidx.compose.ui.unit.Density(1.25f),
|
||||
) {
|
||||
Kst4ContestTheme(darkMode = true, baseFontSizeSp = 12f) {
|
||||
Column {
|
||||
CompactTextField(value = "10368.000", onValueChange = {})
|
||||
Form.inlineChoice(
|
||||
label = "Reachability",
|
||||
items = listOf("Auto"),
|
||||
selected = "Auto",
|
||||
describe = { it },
|
||||
onSelect = {},
|
||||
)
|
||||
Form.button(text = "TX", onClick = {})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val heights = listOf("10368.000", "Auto", "TX")
|
||||
.map { onNodeWithText(it).fetchSemanticsNode().boundsInRoot.height }
|
||||
val expected = 26f * 1.25f
|
||||
assertTrue(
|
||||
heights.all { abs(it - expected) <= 1f },
|
||||
"125% controls are $heights px tall instead of about $expected px",
|
||||
)
|
||||
assertTrue(
|
||||
heights.max() - heights.min() <= 1f,
|
||||
"125% control heights differ by more than one pixel: $heights",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun controlsGrowTogetherInsteadOfClippingALargerConfiguredFont() = runComposeUiTest {
|
||||
setContent {
|
||||
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 24f) {
|
||||
Column {
|
||||
CompactTextField(value = "1296.123.000", onValueChange = {})
|
||||
Form.inlineChoice(
|
||||
label = "Reachability",
|
||||
items = listOf("Auto"),
|
||||
selected = "Auto",
|
||||
describe = { it },
|
||||
onSelect = {},
|
||||
)
|
||||
Form.button(text = "TX", onClick = {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val heights = listOf("1296.123.000", "Auto", "TX")
|
||||
.map { onNodeWithText(it).fetchSemanticsNode().boundsInRoot.height }
|
||||
assertTrue(heights.min() > with(density) { 26.dp.toPx() })
|
||||
assertTrue(
|
||||
heights.max() - heights.min() <= 1f,
|
||||
"large-font controls do not grow together: $heights",
|
||||
)
|
||||
}
|
||||
}
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.ui.ImageComposeScene
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kst4contest.model.Band
|
||||
import kst4contest.model.ChatCategory
|
||||
import kst4contest.model.ChatMember
|
||||
import kst4contest.model.ChatMessage
|
||||
import kst4contest.model.ChatPreferences
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.util.EnumSet
|
||||
|
||||
/** Renders the compact control strips for visual inspection in both supported palettes. */
|
||||
class CompactControlsVisualRenderingTest(@param:TempDir private val tempDir: Path) {
|
||||
|
||||
@Test
|
||||
fun rendersDayAndEveningControlsAt100And125Percent() {
|
||||
val reportDirectory = Path.of("build", "reports", "compact-controls")
|
||||
Files.createDirectories(reportDirectory)
|
||||
|
||||
listOf(false, true).forEach { darkMode ->
|
||||
listOf(1f, 1.25f).forEach { scale ->
|
||||
val bytes = render(darkMode, scale)
|
||||
val palette = if (darkMode) "evening" else "day"
|
||||
val scaling = if (scale == 1f) "100" else "125"
|
||||
Files.write(reportDirectory.resolve("$palette-$scaling.png"), bytes)
|
||||
assertTrue(bytes.size > 10_000, "$palette at $scaling% did not render")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun render(darkMode: Boolean, scale: Float): ByteArray {
|
||||
val preferences = ChatPreferences(tempDir.resolve("preferences-$darkMode-$scale.xml")).apply {
|
||||
getMYQRGFirstCat().set("1296.123.000")
|
||||
getMYQRGSecondCat().set("144.310.000")
|
||||
getActualQTF().set(360.0)
|
||||
}
|
||||
val chatInputState = ChatInputState(RenderActions(preferences)).apply {
|
||||
value = TextFieldValue("/cq OK1IN test test test aasdasd ggggg asd asd gggg")
|
||||
}
|
||||
val filters = StationFilterState()
|
||||
|
||||
val scene = ImageComposeScene(width = 1366, height = 380, density = Density(scale)) {
|
||||
Kst4ContestTheme(darkMode = darkMode, baseFontSizeSp = 12f) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
) {
|
||||
Column(Modifier.padding(4.dp)) {
|
||||
TimelineView(
|
||||
state = TimelineState(),
|
||||
onCandidateClicked = {},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
StationFilterBar(
|
||||
filters = filters,
|
||||
activeBands = EnumSet.of(Band.B_144, Band.B_432, Band.B_1296),
|
||||
reachability = ReachabilityState(
|
||||
EnumSet.of(Band.B_144, Band.B_432, Band.B_1296),
|
||||
),
|
||||
onChanged = {},
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
ChatInputRow(
|
||||
state = chatInputState,
|
||||
prefs = preferences,
|
||||
ownQrgMain = ObservedValue(preferences.getMYQRGFirstCat()),
|
||||
ownQrgSecond = ObservedValue(preferences.getMYQRGSecondCat()),
|
||||
antennaQtf = ObservedValue(preferences.getActualQTF()),
|
||||
antennaQtf2 = ObservedValue(preferences.getActualQTF2()),
|
||||
so2rEnabled = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return try {
|
||||
scene.render().encodeToData()!!.bytes
|
||||
} finally {
|
||||
scene.close()
|
||||
}
|
||||
}
|
||||
|
||||
private class RenderActions(private val preferences: ChatPreferences) : ChatInputActions {
|
||||
override fun panelSelection(): ChatMember? = null
|
||||
override fun tableSelection(): ChatMember? = null
|
||||
override fun scoreSelection(): ChatMember? = null
|
||||
override fun resolveVariables(template: String, member: ChatMember?): String = template
|
||||
override fun isAddressedToOwnCallsign(text: String): Boolean = false
|
||||
override fun resolveCategory(text: String, member: ChatMember?): ChatCategory? = null
|
||||
override fun mainCategory(): ChatCategory? = null
|
||||
override fun recordOutboundCq(text: String) = Unit
|
||||
override fun requestRecompute(reason: String) = Unit
|
||||
override fun queue(message: ChatMessage) = Unit
|
||||
override fun ownQrgMain(): String = preferences.getMYQRGFirstCat().get().orEmpty()
|
||||
override fun ownQrgSecond(): String = preferences.getMYQRGSecondCat().get().orEmpty()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.assertIsEnabled
|
||||
import androidx.compose.ui.test.assertIsNotEnabled
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.runComposeUiTest
|
||||
import kst4contest.controller.On4KstConnectionState
|
||||
import kst4contest.model.ChatPreferences
|
||||
import kst4contest.view.compose.tabs.showTab
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
import java.nio.file.Path
|
||||
|
||||
@OptIn(ExperimentalTestApi::class)
|
||||
class ConnectionBarScreenTest(@param:TempDir private val tempDir: Path) {
|
||||
|
||||
@Test
|
||||
fun `button availability follows every manager lifecycle state`() {
|
||||
var connectionState = On4KstConnectionState.DISCONNECTED
|
||||
val state = ConnectionBarState(preferences()) { connectionState }
|
||||
|
||||
On4KstConnectionState.entries.forEach { lifecycle ->
|
||||
connectionState = lifecycle
|
||||
assertEqualsLifecycleRules(lifecycle, state)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `async disconnect redraws the open settings connection bar`() = runComposeUiTest {
|
||||
var connectionState by mutableStateOf(On4KstConnectionState.ONLINE)
|
||||
val state = ConnectionBarState(preferences()) { connectionState }
|
||||
|
||||
showTab {
|
||||
ConnectionBar(
|
||||
state = state,
|
||||
onConnect = { },
|
||||
onSave = { },
|
||||
onApplyAndClose = { },
|
||||
onDisconnectAndCloseChat = { },
|
||||
onDisconnectOnly = { },
|
||||
)
|
||||
}
|
||||
|
||||
onNodeWithText("Connect", substring = true).assertIsNotEnabled()
|
||||
onNodeWithText("Disconnect").assertIsEnabled()
|
||||
|
||||
runOnIdle { connectionState = On4KstConnectionState.STOPPING }
|
||||
onNodeWithText("Connect", substring = true).assertIsNotEnabled()
|
||||
onNodeWithText("Disconnect").assertIsNotEnabled()
|
||||
|
||||
runOnIdle { connectionState = On4KstConnectionState.DISCONNECTED }
|
||||
onNodeWithText("Connect", substring = true).assertIsEnabled()
|
||||
onNodeWithText("Disconnect").assertIsNotEnabled()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failed attempt enables one new connection attempt`() = runComposeUiTest {
|
||||
var connectionState by mutableStateOf(On4KstConnectionState.CONNECTING)
|
||||
var attempts = 0
|
||||
val state = ConnectionBarState(preferences()) { connectionState }
|
||||
|
||||
showTab {
|
||||
ConnectionBar(
|
||||
state = state,
|
||||
onConnect = {
|
||||
attempts++
|
||||
connectionState = On4KstConnectionState.CONNECTING
|
||||
},
|
||||
onSave = { },
|
||||
onApplyAndClose = { },
|
||||
onDisconnectAndCloseChat = { },
|
||||
onDisconnectOnly = { },
|
||||
)
|
||||
}
|
||||
|
||||
runOnIdle { connectionState = On4KstConnectionState.DISCONNECTED }
|
||||
onNodeWithText("Connect", substring = true).performClick()
|
||||
|
||||
assertTrue(attempts == 1)
|
||||
onNodeWithText("Connect", substring = true).assertIsNotEnabled()
|
||||
}
|
||||
|
||||
private fun assertEqualsLifecycleRules(
|
||||
lifecycle: On4KstConnectionState,
|
||||
state: ConnectionBarState,
|
||||
) {
|
||||
assertTrue(state.canConnect == (lifecycle == On4KstConnectionState.DISCONNECTED), lifecycle.name)
|
||||
assertTrue(state.canDisconnect == lifecycle.isConnectionAttemptActive, lifecycle.name)
|
||||
if (lifecycle == On4KstConnectionState.STOPPING) {
|
||||
assertFalse(state.canConnect)
|
||||
assertFalse(state.canDisconnect)
|
||||
}
|
||||
}
|
||||
|
||||
private fun preferences() = ChatPreferences(tempDir.resolve("preferences.xml"))
|
||||
}
|
||||
@@ -34,6 +34,97 @@ class DataTableStateTest {
|
||||
rowKey = RowKeys.byValue { s: Station -> s.callSign },
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `an initial sort is applied once and keeps later rows in order`() {
|
||||
val table = DataTableState(
|
||||
columns = listOf(
|
||||
DataColumn(
|
||||
id = "callsign",
|
||||
title = "Callsign",
|
||||
value = { station: Station -> station.callSign },
|
||||
comparator = compareBy(String.CASE_INSENSITIVE_ORDER) { station: Station ->
|
||||
station.callSign.trim()
|
||||
},
|
||||
),
|
||||
),
|
||||
rowKey = RowKeys.byValue { station: Station -> station.callSign },
|
||||
initialSortColumnId = "callsign",
|
||||
)
|
||||
|
||||
table.replaceRows(listOf(Station("do5amf", 1), Station("DL1ABC", 2)))
|
||||
assertEquals(listOf("DL1ABC", "do5amf"), table.visibleRows.map { it.callSign })
|
||||
|
||||
table.replaceRows(
|
||||
listOf(Station("ZZ9ZZZ", 3), Station("do5amf", 1), Station("AA1AAA", 4), Station("DL1ABC", 2)),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf("AA1AAA", "DL1ABC", "do5amf", "ZZ9ZZZ"),
|
||||
table.visibleRows.map { it.callSign },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a user sort replaces the initial sort and survives row and filter updates`() {
|
||||
val table = DataTableState(
|
||||
columns = listOf(
|
||||
DataColumn(id = "callsign", title = "Callsign", value = { station: Station -> station.callSign }),
|
||||
DataColumn(
|
||||
id = "qrb",
|
||||
title = "QRB",
|
||||
value = { station: Station -> station.qrb.toString() },
|
||||
comparator = compareBy { station: Station -> station.qrb },
|
||||
),
|
||||
),
|
||||
rowKey = RowKeys.byValue { station: Station -> station.callSign },
|
||||
initialSortColumnId = "callsign",
|
||||
)
|
||||
table.replaceRows(listOf(Station("B", 300), Station("A", 100)))
|
||||
|
||||
table.toggleSort("qrb")
|
||||
table.rowFilter = { it.qrb >= 100 }
|
||||
table.replaceRows(listOf(Station("C", 200), Station("B", 300), Station("A", 100)))
|
||||
|
||||
assertEquals("qrb", table.sortedColumnId)
|
||||
assertEquals(listOf("A", "C", "B"), table.visibleRows.map { it.callSign })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a selected filtered row can be pinned only in the table view`() {
|
||||
val table = DataTableState(
|
||||
columns = listOf(DataColumn(id = "callsign", title = "Callsign", value = { s: Station -> s.callSign })),
|
||||
rowKey = RowKeys.byValue { station: Station -> station.callSign },
|
||||
keepSelectionVisible = true,
|
||||
)
|
||||
val hidden = Station("DL1HIDE", 100)
|
||||
val shown = Station("DL2SHOW", 500)
|
||||
table.replaceRows(listOf(hidden, shown))
|
||||
table.rowFilter = { it.qrb >= 300 }
|
||||
|
||||
table.select(hidden)
|
||||
|
||||
assertEquals(listOf("DL2SHOW"), table.filteredRows.map { it.callSign }, "the shared map filter changed")
|
||||
assertEquals(
|
||||
listOf("DL1HIDE", "DL2SHOW"),
|
||||
table.visibleRows.map { it.callSign },
|
||||
"the selected station was not made reachable in the table",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the same selected row can request scrolling more than once`() {
|
||||
val table = stationTable()
|
||||
val selected = Station("DL1ABC", 100)
|
||||
table.replaceRows(listOf(selected))
|
||||
table.select(selected)
|
||||
|
||||
table.requestScrollToSelection()
|
||||
val first = table.scrollRequestRevision
|
||||
table.requestScrollToSelection()
|
||||
|
||||
assertTrue(table.scrollRequestRevision > first)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the selection follows the row, not its position`() {
|
||||
val table = stationTable()
|
||||
@@ -388,6 +479,24 @@ class DataTableColumnWidthTest {
|
||||
assertEquals(140.0, table(store).widthOf("callsign"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a stored narrow width wins over the automatic width`() {
|
||||
val store = RecordingStore()
|
||||
store.stored["dx-cluster-monitor/callsign"] = 36.0
|
||||
val table = table(store)
|
||||
val column = table.columns.first { it.id == "callsign" }
|
||||
|
||||
assertEquals(36f, effectiveColumnWidthDp(table, column, mapOf("callsign" to 140f)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the automatic width is used only without a stored width`() {
|
||||
val table = table(RecordingStore())
|
||||
val column = table.columns.first { it.id == "callsign" }
|
||||
|
||||
assertEquals(140f, effectiveColumnWidthDp(table, column, mapOf("callsign" to 140f)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a column never sized yet has no stored width`() {
|
||||
assertNull(table(RecordingStore()).widthOf("qra"),
|
||||
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import kst4contest.model.ChatPreferences
|
||||
import kst4contest.view.compose.tabs.GuiOptionsTabState
|
||||
import kst4contest.view.i18n.LanguageStore
|
||||
import kst4contest.view.i18n.SYSTEM_LANGUAGE
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
import java.nio.file.Path
|
||||
import java.util.Locale
|
||||
|
||||
class GuiStartupDesignPersistenceTest(@param:TempDir private val tempDir: Path) {
|
||||
|
||||
@Test
|
||||
fun `root profile keeps evening startup design across save layout autosave and reload`() {
|
||||
assertStartupDesignSurvives(tempDir.resolve("preferences.xml"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `named profile keeps evening startup design across save layout autosave and reload`() {
|
||||
assertStartupDesignSurvives(tempDir.resolve("profiles/CONTEST/preferences.xml"))
|
||||
}
|
||||
|
||||
private fun assertStartupDesignSurvives(preferencesFile: Path) {
|
||||
val preferences = ChatPreferences(preferencesFile)
|
||||
val draft = SettingsDraft()
|
||||
val notices = SettingsNotices()
|
||||
val state = GuiOptionsTabState(preferences, draft, languageStore())
|
||||
|
||||
state.gUI_darkModeActiveByDefault = true
|
||||
applyAndSave(draft, notices) {
|
||||
preferences.writePreferencesToXmlFile()
|
||||
.takeIf { it }
|
||||
?.let { preferences.storeAndRestorePreferencesFileName }
|
||||
}
|
||||
|
||||
assertTrue(preferences.isGUI_darkModeActiveByDefault)
|
||||
|
||||
// The menu switch is session-only and must not alter the next startup.
|
||||
preferences.isGUI_darkModeActive = false
|
||||
preferences.getGUIsettingsStageSceneSizeHW()[0] = 777.0
|
||||
assertTrue(preferences.writeLayoutPreferencesToXmlFile())
|
||||
|
||||
val restored = ChatPreferences(preferencesFile)
|
||||
assertTrue(restored.readPreferencesFromXmlFile())
|
||||
assertTrue(restored.isGUI_darkModeActiveByDefault)
|
||||
assertFalse(restored.isGUI_darkModeActive)
|
||||
}
|
||||
|
||||
private fun languageStore() = LanguageStore(
|
||||
storedLanguage = { SYSTEM_LANGUAGE },
|
||||
storeLanguage = { },
|
||||
systemDefault = Locale.UK,
|
||||
)
|
||||
}
|
||||
@@ -120,6 +120,15 @@ class MainWindowSplitterDefaultsTest(@TempDir private val tempDir: Path) {
|
||||
val splitter = SplitterState(paneCount = 2, stored = stored) {}
|
||||
assertEquals(stored.map { it.toFloat() }, splitter.positions)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the selected station content splitter takes the one stored profile position`() {
|
||||
val stored = prefs.getGUIselectedCallSignSplitPane_dividerposition()
|
||||
assertEquals(1, stored.size)
|
||||
|
||||
val splitter = SplitterState(paneCount = MainWindowState.SELECTED_STATION_PANE_COUNT, stored = stored) {}
|
||||
assertEquals(stored.map { it.toFloat() }, splitter.positions)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import kst4contest.model.ChatPreferences
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
import java.nio.file.Path
|
||||
|
||||
class ManualQtfHeadingSynchronizerTest(@param:TempDir private val directory: Path) {
|
||||
|
||||
@Test
|
||||
fun `valid explicit QTF updates actual heading when synchronization is enabled`() {
|
||||
val prefs = preferences().apply {
|
||||
actualQTF.set(20.0)
|
||||
}
|
||||
assertTrue(applyExplicitQtfHeading(prefs, 315.5))
|
||||
assertEquals(315.5, prefs.actualQTF.get(), 0.0001)
|
||||
assertEquals(135.0, prefs.stn_qtfDefault, 0.0001)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `primary rotator prevents manual QTF synchronization`() {
|
||||
val prefs = preferences().apply {
|
||||
setStn_pstRotatorEnabled(true)
|
||||
actualQTF.set(82.0)
|
||||
}
|
||||
assertFalse(applyExplicitQtfHeading(prefs, 180.0))
|
||||
assertEquals(82.0, prefs.actualQTF.get(), 0.0001)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `disabled option and invalid angles leave actual heading unchanged`() {
|
||||
val prefs = preferences().apply {
|
||||
setStn_useQtfFilterDirectionAsAntennaHeading(false)
|
||||
actualQTF.set(82.0)
|
||||
}
|
||||
listOf(90.0, Double.NaN, Double.POSITIVE_INFINITY, -0.1, 360.1).forEach { qtf ->
|
||||
assertFalse(applyExplicitQtfHeading(prefs, qtf), "unexpectedly accepted $qtf")
|
||||
assertEquals(82.0, prefs.actualQTF.get(), 0.0001)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `zero and 360 follow the existing antenna heading convention`() {
|
||||
val prefs = preferences()
|
||||
assertTrue(applyExplicitQtfHeading(prefs, 0.0))
|
||||
assertEquals(0.0, prefs.actualQTF.get(), 0.0001)
|
||||
assertTrue(applyExplicitQtfHeading(prefs, 360.0))
|
||||
assertEquals(360.0, prefs.actualQTF.get(), 0.0001)
|
||||
}
|
||||
|
||||
private fun preferences() = ChatPreferences(directory.resolve("preferences.xml"))
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import kst4contest.model.Band
|
||||
import kst4contest.model.ChatCategory
|
||||
import kst4contest.model.ChatMember
|
||||
import kst4contest.view.map.PathAnalysisResult
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class MapReachabilityStateTest {
|
||||
|
||||
@Test
|
||||
fun `latest station and band request rejects stale results`() {
|
||||
val callbacks = mutableListOf<(PathAnalysisResult) -> Unit>()
|
||||
val state = MapReachabilityState("JO51AA") { _, _, callback -> callbacks += callback }
|
||||
val first = member("DL1ABC", 2, "JN49AA")
|
||||
val second = member("DL2XYZ", 3, "JO60AA")
|
||||
|
||||
assertTrue(state.calculate(first, Band.B_144))
|
||||
assertEquals("Loading", state.result?.analysisMode())
|
||||
assertTrue(state.calculate(second, Band.B_432))
|
||||
|
||||
callbacks[1](result("DL2XYZ", 432.0))
|
||||
callbacks[0](result("DL1ABC", 144.0))
|
||||
|
||||
assertEquals("DL2XYZ", state.result?.toCallsignRaw())
|
||||
assertEquals(432.0, state.result?.analysisFrequencyMHz())
|
||||
assertFalse(state.running)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selecting another station invalidates an outstanding explicit result`() {
|
||||
lateinit var callback: (PathAnalysisResult) -> Unit
|
||||
val state = MapReachabilityState("JO51AA") { _, _, result -> callback = result }
|
||||
val first = member("DL1ABC", 2, "JN49AA")
|
||||
|
||||
state.calculate(first, Band.B_144)
|
||||
state.selectionChanged(StationIdentity("DL2XYZ", 2))
|
||||
callback(result("DL1ABC", 144.0))
|
||||
|
||||
assertNull(state.result)
|
||||
assertFalse(state.running)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `missing station or band does not start a request`() {
|
||||
val state = MapReachabilityState("JO51AA") { _, _, _ -> error("must not run") }
|
||||
|
||||
assertFalse(state.calculate(null, Band.B_144))
|
||||
assertFalse(state.calculate(member("DL1ABC", 2, "JN49AA"), null))
|
||||
}
|
||||
|
||||
private fun member(callSign: String, category: Int, locator: String) = ChatMember().apply {
|
||||
this.callSign = callSign
|
||||
chatCategory = ChatCategory(category)
|
||||
qra = locator
|
||||
}
|
||||
|
||||
private fun result(callSign: String, frequency: Double) = PathAnalysisResult.noProfile(
|
||||
"Calculated", "JO51AA", "JN49AA", callSign,
|
||||
100.0, 180.0, 10.0, 10.0, frequency, "done",
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.assertIsNotSelected
|
||||
import androidx.compose.ui.test.assertIsSelected
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.runComposeUiTest
|
||||
import kst4contest.model.ChatCategory
|
||||
import kst4contest.model.ChatMember
|
||||
import kst4contest.model.ChatMessage
|
||||
import kst4contest.model.ClusterMessage
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertSame
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
@OptIn(ExperimentalTestApi::class)
|
||||
class MessageTabsScreenTest {
|
||||
|
||||
@Test
|
||||
fun `tab semantics distinguish the active tab and follow selection`() = runComposeUiTest {
|
||||
setContent {
|
||||
Kst4ContestTheme(darkMode = true, baseFontSizeSp = 12f) {
|
||||
MessageTabs(
|
||||
publicMessages = messageTable(),
|
||||
clusterMessages = clusterTable(),
|
||||
qsoOfTheOther = messageTable(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
onNodeWithText("Public messages").assertIsSelected()
|
||||
onNodeWithText("DXCluster messages").assertIsNotSelected().performClick()
|
||||
waitForIdle()
|
||||
onNodeWithText("Public messages").assertIsNotSelected()
|
||||
onNodeWithText("DXCluster messages").assertIsSelected()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a public message row reports its sender selection source`() = runComposeUiTest {
|
||||
val public = messageTable()
|
||||
val message = messageFrom("DL1PUBLIC")
|
||||
public.replaceRows(listOf(message))
|
||||
val clicks = mutableListOf<Pair<ChatMessage, StationSelectionSource>>()
|
||||
|
||||
setContent {
|
||||
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
|
||||
MessageTabs(
|
||||
publicMessages = public,
|
||||
clusterMessages = clusterTable(),
|
||||
qsoOfTheOther = messageTable(),
|
||||
onStationMessageClicked = { clicked, source -> clicks += clicked to source },
|
||||
)
|
||||
}
|
||||
}
|
||||
waitForIdle()
|
||||
|
||||
onNodeWithText("DL1PUBLIC").performClick()
|
||||
waitForIdle()
|
||||
|
||||
assertSame(message, clicks.single().first)
|
||||
assertEquals(StationSelectionSource.PUBLIC_MESSAGE, clicks.single().second)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a qso of the other row reports its sender selection source`() = runComposeUiTest {
|
||||
val qso = messageTable()
|
||||
val message = messageFrom("DL2QSO")
|
||||
qso.replaceRows(listOf(message))
|
||||
val clicks = mutableListOf<Pair<ChatMessage, StationSelectionSource>>()
|
||||
|
||||
setContent {
|
||||
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
|
||||
MessageTabs(
|
||||
publicMessages = messageTable(),
|
||||
clusterMessages = clusterTable(),
|
||||
qsoOfTheOther = qso,
|
||||
onStationMessageClicked = { clicked, source -> clicks += clicked to source },
|
||||
)
|
||||
}
|
||||
}
|
||||
waitForIdle()
|
||||
|
||||
onNodeWithText("QSO of the other").performClick()
|
||||
waitForIdle()
|
||||
onNodeWithText("DL2QSO").performClick()
|
||||
waitForIdle()
|
||||
|
||||
assertSame(message, clicks.single().first)
|
||||
assertEquals(StationSelectionSource.QSO_OF_THE_OTHER, clicks.single().second)
|
||||
}
|
||||
|
||||
private fun messageTable() = DataTableState(
|
||||
columns = listOf(
|
||||
DataColumn(
|
||||
id = "sender",
|
||||
title = "Sender",
|
||||
value = { message: ChatMessage -> message.sender?.callSign.orEmpty() },
|
||||
),
|
||||
),
|
||||
rowKey = RowKeys.byReference<ChatMessage>(),
|
||||
)
|
||||
|
||||
private fun clusterTable() = DataTableState(
|
||||
columns = emptyList<DataColumn<ClusterMessage>>(),
|
||||
rowKey = RowKeys.byReference<ClusterMessage>(),
|
||||
)
|
||||
|
||||
private fun messageFrom(callSign: String): ChatMessage = ChatMessage().apply {
|
||||
sender = ChatMember().apply {
|
||||
this.callSign = callSign
|
||||
chatCategory = ChatCategory(2)
|
||||
}
|
||||
receiver = ChatMember().apply { this.callSign = "ALL" }
|
||||
chatCategory = ChatCategory(2)
|
||||
messageText = "test"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.runComposeUiTest
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kst4contest.model.ChatCategory
|
||||
import kst4contest.model.ChatMember
|
||||
import kst4contest.model.RecentChatJoin
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertSame
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
@OptIn(ExperimentalTestApi::class)
|
||||
class NewJoinedBarScreenTest {
|
||||
|
||||
@Test
|
||||
fun fiveNewestStationsAreDirectAndAdditionalStationsUseTheMoreDropdown() = runComposeUiTest {
|
||||
val selection = StationSelectionState()
|
||||
val members = (1..7).map { member("DL${it}ABC") }
|
||||
val newJoined = NewJoinedState(clock = { 10_000L }) { member ->
|
||||
selection.select(member, StationSelectionSource.NEW_JOINED)
|
||||
}.apply {
|
||||
updateActiveMembers(members)
|
||||
updateEvents(
|
||||
members.mapIndexed { index, member ->
|
||||
RecentChatJoin(member.callSign, 2, 9_000L + index)
|
||||
},
|
||||
)
|
||||
}
|
||||
val priority = TopPriorityState(topStations = { emptyList() }, onSelect = {})
|
||||
|
||||
setContent {
|
||||
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
|
||||
TopPriorityBar(
|
||||
state = priority,
|
||||
newJoined = newJoined,
|
||||
onShowMore = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
onNodeWithText("New joined:").assertIsDisplayed()
|
||||
listOf("DL7ABC", "DL6ABC", "DL5ABC", "DL4ABC", "DL3ABC").forEach {
|
||||
onNodeWithText(it).assertIsDisplayed()
|
||||
}
|
||||
onNodeWithText("DL2ABC").assertDoesNotExist()
|
||||
|
||||
onNodeWithTag(NEW_JOINED_MORE_TAG).performClick()
|
||||
onNodeWithText("DL2ABC").assertIsDisplayed().performClick()
|
||||
|
||||
assertSame(members[1], selection.selected)
|
||||
assertEquals(StationSelectionSource.NEW_JOINED, selection.lastSource)
|
||||
assertTrue(selection.scrollRequestRevision > 0L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyNewJoinedStateUsesNoSpaceBesidePriority() = runComposeUiTest {
|
||||
val newJoined = NewJoinedState(onSelect = {})
|
||||
val priorityMember = member("OK1FPR")
|
||||
val priority = TopPriorityState(
|
||||
topStations = { listOf(priorityMember to 767.0) },
|
||||
onSelect = {},
|
||||
).apply { refresh() }
|
||||
|
||||
setContent {
|
||||
Kst4ContestTheme(darkMode = true, baseFontSizeSp = 12f) {
|
||||
TopPriorityBar(
|
||||
state = priority,
|
||||
newJoined = newJoined,
|
||||
onShowMore = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
onNodeWithText("New joined:").assertDoesNotExist()
|
||||
onNodeWithText("Priority:").assertIsDisplayed()
|
||||
onNodeWithText("1 OK1FPR 767").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun narrowBarMovesAdditionalDirectEntriesIntoTheSameDropdownWithoutOverflow() = runComposeUiTest {
|
||||
val members = (1..7).map { member("DL${it}ABC") }
|
||||
val newJoined = NewJoinedState(clock = { 10_000L }, onSelect = {}).apply {
|
||||
updateActiveMembers(members)
|
||||
updateEvents(members.mapIndexed { index, member ->
|
||||
RecentChatJoin(member.callSign, 2, 9_000L + index)
|
||||
})
|
||||
}
|
||||
|
||||
setContent {
|
||||
Kst4ContestTheme(darkMode = true, baseFontSizeSp = 12f) {
|
||||
Box(Modifier.width(300.dp)) {
|
||||
TopPriorityBar(
|
||||
state = TopPriorityState(topStations = { emptyList() }, onSelect = {}),
|
||||
newJoined = newJoined,
|
||||
onShowMore = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onNodeWithText("DL7ABC").assertIsDisplayed()
|
||||
onNodeWithText("DL6ABC").assertDoesNotExist()
|
||||
val limit = with(density) { 300.dp.toPx() }
|
||||
listOf(NEW_JOINED_GROUP_TAG, PRIORITY_GROUP_TAG).forEach { tag ->
|
||||
val bounds = onNodeWithTag(tag).assertIsDisplayed().fetchSemanticsNode().boundsInRoot
|
||||
assertTrue(bounds.right <= limit + 0.5f, "$tag crosses the narrow bar")
|
||||
}
|
||||
|
||||
onNodeWithTag(NEW_JOINED_MORE_TAG).performClick()
|
||||
onNodeWithText("DL6ABC").assertIsDisplayed()
|
||||
}
|
||||
|
||||
private fun member(callSign: String) = ChatMember().apply {
|
||||
this.callSign = callSign
|
||||
chatCategory = ChatCategory(2)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import kst4contest.model.ChatCategory
|
||||
import kst4contest.model.ChatMember
|
||||
import kst4contest.model.RecentChatJoin
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertSame
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
class NewJoinedStateTest {
|
||||
|
||||
private val now = AtomicLong(1_000_000L)
|
||||
private var selected: ChatMember? = null
|
||||
private val state = NewJoinedState(clock = now::get, onSelect = { selected = it })
|
||||
|
||||
@Test
|
||||
fun `an initial active roster without live events produces no entries`() {
|
||||
state.updateActiveMembers(listOf(member("HB9SJV", 2)))
|
||||
|
||||
assertTrue(state.entries.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a live join is shown once and duplicate events do not renew it`() {
|
||||
val station = member("HB9SJV", 2)
|
||||
state.updateActiveMembers(listOf(station))
|
||||
state.updateEvents(
|
||||
listOf(
|
||||
event("HB9SJV", 2, now.get() - 2_000L),
|
||||
event("HB9SJV", 2, now.get() - 1_000L),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(listOf("HB9SJV"), state.entries.map { it.label })
|
||||
assertEquals(now.get() - 2_000L, state.entries.single().joinedAtEpochMillis)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the same full callsign in two categories stays distinguishable`() {
|
||||
val main = member("DL1KDA", 2)
|
||||
val microwave = member("DL1KDA", 3)
|
||||
state.updateActiveMembers(listOf(main, microwave))
|
||||
state.updateEvents(
|
||||
listOf(
|
||||
event("DL1KDA", 3, now.get()),
|
||||
event("DL1KDA", 2, now.get() - 1L),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(listOf(3, 2), state.entries.map { it.identity.categoryNumber })
|
||||
state.entries.first().select()
|
||||
assertSame(microwave, selected)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an entry expires exactly ten minutes after the join`() {
|
||||
val station = member("OE5SOS", 2)
|
||||
state.updateActiveMembers(listOf(station))
|
||||
state.updateEvents(listOf(event("OE5SOS", 2, now.get())))
|
||||
|
||||
now.addAndGet(NewJoinedState.VALIDITY_MILLIS - 1L)
|
||||
state.refresh()
|
||||
assertTrue(state.isRecent(station))
|
||||
|
||||
now.incrementAndGet()
|
||||
state.refresh()
|
||||
assertFalse(state.isRecent(station))
|
||||
assertTrue(state.entries.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only five newest entries are direct and the rest remain in the overflow`() {
|
||||
val members = (1..7).map { member("DL${it}ABC", 2) }
|
||||
state.updateActiveMembers(members)
|
||||
state.updateEvents(
|
||||
members.mapIndexed { index, member ->
|
||||
event(member.callSign, 2, now.get() - (members.size - index))
|
||||
},
|
||||
)
|
||||
|
||||
assertEquals(listOf("DL7ABC", "DL6ABC", "DL5ABC", "DL4ABC", "DL3ABC"),
|
||||
state.visibleEntries.map { it.label })
|
||||
assertEquals(listOf("DL2ABC", "DL1ABC"), state.overflowEntries.map { it.label })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a station leaving disappears and a real rejoin gets the new event`() {
|
||||
val first = member("PA2M", 2)
|
||||
state.updateActiveMembers(listOf(first))
|
||||
state.updateEvents(listOf(event("PA2M", 2, now.get())))
|
||||
|
||||
state.updateActiveMembers(emptyList())
|
||||
assertTrue(state.entries.isEmpty())
|
||||
|
||||
val rejoined = member("PA2M", 2)
|
||||
now.addAndGet(5_000L)
|
||||
state.updateActiveMembers(listOf(rejoined))
|
||||
state.updateEvents(listOf(event("PA2M", 2, now.get())))
|
||||
|
||||
state.entries.single().select()
|
||||
assertSame(rejoined, selected)
|
||||
assertEquals(now.get(), state.entries.single().joinedAtEpochMillis)
|
||||
}
|
||||
|
||||
private fun member(callSign: String, categoryNumber: Int) = ChatMember().apply {
|
||||
this.callSign = callSign
|
||||
chatCategory = ChatCategory(categoryNumber)
|
||||
}
|
||||
|
||||
private fun event(callSign: String, categoryNumber: Int, joinedAt: Long) =
|
||||
RecentChatJoin(callSign, categoryNumber, joinedAt)
|
||||
}
|
||||
@@ -100,11 +100,11 @@ class PaletteStoreFactoryTest(@TempDir private val tempDir: Path) {
|
||||
val files = OperatorProfilePaletteFiles.of(other)
|
||||
|
||||
assertTrue(
|
||||
files.dayFile.path.endsWith("profiles/OP2/KST4ContestDefaultDay.css"),
|
||||
files.dayFile.toPath().endsWith(Path.of("profiles", "OP2", "KST4ContestDefaultDay.css")),
|
||||
"the daylight file is ${files.dayFile.path}",
|
||||
)
|
||||
assertTrue(
|
||||
files.eveningFile.path.endsWith("profiles/OP2/KST4ContestDefaultEvening.css"),
|
||||
files.eveningFile.toPath().endsWith(Path.of("profiles", "OP2", "KST4ContestDefaultEvening.css")),
|
||||
"the evening file is ${files.eveningFile.path}",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import kst4contest.model.Band
|
||||
import kst4contest.model.ChatMember
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.util.EnumSet
|
||||
|
||||
class ReachabilityStateTest {
|
||||
|
||||
@Test
|
||||
fun `auto and only locally active bands are offered`() {
|
||||
val state = ReachabilityState(EnumSet.of(Band.B_144, Band.B_1296))
|
||||
|
||||
assertEquals(listOf(null, Band.B_144, Band.B_1296), state.options.map { it.band })
|
||||
assertNull(state.bandOverride)
|
||||
assertEquals("Auto", state.selectedOption.label)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `band changes redraw but never calculate`() {
|
||||
val station = ChatMember().apply { callSign = "DL1ABC" }
|
||||
val calculated = mutableListOf<Pair<ChatMember, Band>>()
|
||||
var redraws = 0
|
||||
val state = ReachabilityState(
|
||||
activeBands = EnumSet.of(Band.B_144, Band.B_432),
|
||||
resolveAutoBand = { Band.B_144 },
|
||||
selectedStation = { station },
|
||||
calculate = { member, band -> calculated += member to band },
|
||||
refresh = { redraws++ },
|
||||
)
|
||||
|
||||
state.selectBand(Band.B_432)
|
||||
|
||||
assertEquals(Band.B_432, state.resolveBand(station))
|
||||
assertEquals(1, redraws)
|
||||
assertTrue(calculated.isEmpty())
|
||||
|
||||
assertTrue(state.calculateSelected())
|
||||
assertEquals(listOf(station to Band.B_432), calculated)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `auto uses shared resolver and unavailable selections do not calculate`() {
|
||||
var selected: ChatMember? = ChatMember()
|
||||
var calculations = 0
|
||||
val state = ReachabilityState(
|
||||
activeBands = EnumSet.of(Band.B_144),
|
||||
resolveAutoBand = { null },
|
||||
selectedStation = { selected },
|
||||
calculate = { _, _ -> calculations++ },
|
||||
)
|
||||
|
||||
assertFalse(state.canCalculate)
|
||||
assertFalse(state.calculateSelected())
|
||||
selected = null
|
||||
assertFalse(state.calculateSelected())
|
||||
assertEquals(0, calculations)
|
||||
}
|
||||
}
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import kst4contest.model.ChatCategory
|
||||
import kst4contest.model.ChatMember
|
||||
import kst4contest.model.ChatMessage
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class SelectedStationMessagePredicateTest {
|
||||
|
||||
private val ownCall = "DL0OWN"
|
||||
private val selected = member("DL1SEL", 2)
|
||||
|
||||
@Test
|
||||
fun `pm to me contains both private directions with the selected station`() {
|
||||
val predicate = SelectedStationMessagePredicate.forSelection(
|
||||
SelectedMessageFilter.PM_TO_ME,
|
||||
selected,
|
||||
ownCall,
|
||||
)
|
||||
|
||||
assertTrue(predicate.test(message(selected, member(ownCall, 2), 2)))
|
||||
assertTrue(predicate.test(message(member(ownCall, 2), selected, 2)))
|
||||
assertFalse(predicate.test(message(selected, member("DL2OTHER", 2), 2)))
|
||||
assertFalse(predicate.test(publicMessage(selected, 2)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pm to me also contains an explicitly addressed public message`() {
|
||||
val predicate = SelectedStationMessagePredicate.forSelection(
|
||||
SelectedMessageFilter.PM_TO_ME,
|
||||
selected,
|
||||
ownCall,
|
||||
)
|
||||
|
||||
assertTrue(predicate.test(publicMessage(selected, 2, addressedToMe = true)))
|
||||
assertFalse(predicate.test(publicMessage(selected, 2, addressedToMe = false)))
|
||||
|
||||
val mentionOnly = publicMessage(selected, 2).apply {
|
||||
messageText = "Please ask $ownCall whether the path is open"
|
||||
}
|
||||
assertFalse(predicate.test(mentionOnly))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pm to other contains only private messages sent by the selected station to others`() {
|
||||
val other = member("DL2OTHER", 2)
|
||||
val predicate = SelectedStationMessagePredicate.forSelection(
|
||||
SelectedMessageFilter.PM_TO_OTHER,
|
||||
selected,
|
||||
ownCall,
|
||||
)
|
||||
|
||||
assertTrue(predicate.test(message(selected, other, 2)))
|
||||
assertFalse(predicate.test(message(other, selected, 2)))
|
||||
assertFalse(predicate.test(message(selected, member(ownCall, 2), 2)))
|
||||
assertFalse(predicate.test(publicMessage(selected, 2)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `public contains only public messages sent by the selected station`() {
|
||||
val predicate = SelectedStationMessagePredicate.forSelection(
|
||||
SelectedMessageFilter.PUBLIC,
|
||||
selected,
|
||||
ownCall,
|
||||
)
|
||||
|
||||
assertTrue(predicate.test(publicMessage(selected, 2)))
|
||||
assertTrue(predicate.test(publicMessage(selected, 2, addressedToMe = true)))
|
||||
assertFalse(predicate.test(publicMessage(member("DL2OTHER", 2), 2)))
|
||||
assertFalse(predicate.test(message(selected, member("DL2OTHER", 2), 2)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nothing applies no additional message type restriction`() {
|
||||
val predicate = SelectedStationMessagePredicate.forSelection(
|
||||
SelectedMessageFilter.NOTHING,
|
||||
selected,
|
||||
ownCall,
|
||||
)
|
||||
|
||||
assertTrue(predicate.test(message(selected, member(ownCall, 2), 2)))
|
||||
assertTrue(predicate.test(message(member(ownCall, 2), selected, 2)))
|
||||
assertTrue(predicate.test(publicMessage(selected, 2)))
|
||||
assertFalse(predicate.test(publicMessage(member("DL2OTHER", 2), 2)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the same callsign in another category does not match`() {
|
||||
val predicate = SelectedStationMessagePredicate.forSelection(
|
||||
SelectedMessageFilter.PUBLIC,
|
||||
selected,
|
||||
ownCall,
|
||||
)
|
||||
|
||||
assertFalse(predicate.test(publicMessage(member("DL1SEL", 3), 3)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `message category is authoritative when a member snapshot has no category`() {
|
||||
val senderWithoutCategory = member("DL1SEL", 2).apply { chatCategory = null }
|
||||
val predicate = SelectedStationMessagePredicate.forSelection(
|
||||
SelectedMessageFilter.PM_TO_ME,
|
||||
selected,
|
||||
ownCall,
|
||||
)
|
||||
|
||||
assertTrue(predicate.test(message(senderWithoutCategory, member(ownCall, 2), 2)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `missing selection or incomplete historical data fails closed`() {
|
||||
val withoutSelection = SelectedStationMessagePredicate.forSelection(
|
||||
SelectedMessageFilter.PUBLIC,
|
||||
null,
|
||||
ownCall,
|
||||
)
|
||||
val incomplete = ChatMessage().apply { chatCategory = ChatCategory(2) }
|
||||
|
||||
assertFalse(withoutSelection.test(publicMessage(selected, 2)))
|
||||
assertFalse(
|
||||
SelectedStationMessagePredicate.forSelection(SelectedMessageFilter.PUBLIC, selected, ownCall)
|
||||
.test(incomplete),
|
||||
)
|
||||
assertFalse(
|
||||
SelectedStationMessagePredicate.forSelection(
|
||||
SelectedMessageFilter.PUBLIC,
|
||||
ChatMember().apply { callSign = "DL1SEL" },
|
||||
ownCall,
|
||||
).test(publicMessage(selected, 2)),
|
||||
)
|
||||
}
|
||||
|
||||
private fun message(sender: ChatMember, receiver: ChatMember, category: Int): ChatMessage = ChatMessage().apply {
|
||||
this.sender = sender
|
||||
this.receiver = receiver
|
||||
chatCategory = ChatCategory(category)
|
||||
messageText = "test"
|
||||
}
|
||||
|
||||
private fun publicMessage(
|
||||
sender: ChatMember,
|
||||
category: Int,
|
||||
addressedToMe: Boolean = false,
|
||||
): ChatMessage = message(sender, member("ALL", category), category).apply {
|
||||
isMessageDirectedToCommunity = true
|
||||
isMessageDirectedToMe = addressedToMe
|
||||
}
|
||||
|
||||
private fun member(callSign: String, category: Int): ChatMember = ChatMember().apply {
|
||||
this.callSign = callSign
|
||||
chatCategory = ChatCategory(category)
|
||||
}
|
||||
}
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.ui.ImageComposeScene
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kst4contest.model.Band
|
||||
import kst4contest.model.ChatCategory
|
||||
import kst4contest.model.ChatMember
|
||||
import kst4contest.model.ChatMessage
|
||||
import kst4contest.model.ClusterMessage
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.time.Instant
|
||||
import java.util.EnumSet
|
||||
import java.util.function.Consumer
|
||||
|
||||
/** Produces review images for the selected-station controls and lower message tabs. */
|
||||
class SelectedStationMessageUiVisualRenderingTest {
|
||||
|
||||
@Test
|
||||
fun rendersDayAndEveningAt100And125Percent() {
|
||||
val reportDirectory = Path.of("build", "reports", "selected-station-message-ui")
|
||||
Files.createDirectories(reportDirectory)
|
||||
|
||||
val scenarios = listOf(
|
||||
RenderScenario(prefix = "", panelWidth = 570.dp, windowHeight = 768),
|
||||
RenderScenario(prefix = "narrow-", panelWidth = 360.dp, windowHeight = 768),
|
||||
RenderScenario(prefix = "short-", panelWidth = 570.dp, windowHeight = 480),
|
||||
)
|
||||
scenarios.forEach { scenario ->
|
||||
listOf(false, true).forEach { darkMode ->
|
||||
listOf(1f, 1.25f).forEach { scale ->
|
||||
val bytes = render(darkMode, scale, scenario.panelWidth, scenario.windowHeight)
|
||||
val palette = if (darkMode) "evening" else "day"
|
||||
val scaling = if (scale == 1f) "100" else "125"
|
||||
val name = "${scenario.prefix}$palette-$scaling.png"
|
||||
Files.write(reportDirectory.resolve(name), bytes)
|
||||
assertTrue(bytes.size > 10_000, "$name did not render")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun render(darkMode: Boolean, scale: Float, panelWidth: Dp, windowHeight: Int): ByteArray {
|
||||
val selectedMember = member("DM5M", 2).apply {
|
||||
setQTFdirection(255.54)
|
||||
qrb = 1907.59
|
||||
activityTimeLastInEpoch = Instant.now().epochSecond - 240
|
||||
isQrv144 = false
|
||||
isQrv1240 = false
|
||||
}
|
||||
val selectedState = SelectedStationState(
|
||||
propagateNotQrv = {},
|
||||
createSked = { _, _, _, _ -> true },
|
||||
detectBands = { "Bands: 144/432 MHz" },
|
||||
).apply {
|
||||
select(selectedMember)
|
||||
skedBand = Band.B_144
|
||||
skedMode = "SSB"
|
||||
remindPm = true
|
||||
}
|
||||
val selectedMessages = messageTable().apply {
|
||||
replaceRows(
|
||||
listOf(
|
||||
message(selectedMember, member("DL0OWN", 2), "pse ur qrg"),
|
||||
message(selectedMember, member("ALL", 2), "(DL0OWN) qrv 144.300"),
|
||||
),
|
||||
)
|
||||
}
|
||||
val publicMessages = messageTable().apply {
|
||||
replaceRows(listOf(message(selectedMember, member("ALL", 2), "cq 144.300")))
|
||||
}
|
||||
val qsoMessages = messageTable().apply {
|
||||
replaceRows(listOf(message(member("DL1ABC", 2), member("DL2XYZ", 2), "pse sked")))
|
||||
}
|
||||
|
||||
val scene = ImageComposeScene(width = 1366, height = windowHeight, density = Density(scale)) {
|
||||
Kst4ContestTheme(darkMode = darkMode, baseFontSizeSp = 12f) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
) {
|
||||
Row(Modifier.fillMaxSize().padding(6.dp)) {
|
||||
MessageTabs(
|
||||
publicMessages = publicMessages,
|
||||
clusterMessages = clusterTable(),
|
||||
qsoOfTheOther = qsoMessages,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
SelectedStationPanel(
|
||||
state = selectedState,
|
||||
messages = selectedMessages,
|
||||
activeBands = EnumSet.of(Band.B_144, Band.B_432, Band.B_1296),
|
||||
openInBrowser = Consumer {},
|
||||
onShowOnMap = {},
|
||||
onShowPathInAirScout = {},
|
||||
modifier = Modifier.width(panelWidth).padding(start = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return try {
|
||||
scene.render().encodeToData()!!.bytes
|
||||
} finally {
|
||||
scene.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun messageTable() = DataTableState(
|
||||
columns = listOf(
|
||||
DataColumn("sender", "Sender", value = { message: ChatMessage -> message.sender.callSign }),
|
||||
DataColumn("message", "Message", value = { message: ChatMessage -> message.messageText.orEmpty() }),
|
||||
),
|
||||
rowKey = RowKeys.byReference<ChatMessage>(),
|
||||
)
|
||||
|
||||
private fun clusterTable() = DataTableState(
|
||||
columns = listOf(
|
||||
DataColumn(
|
||||
"message",
|
||||
"DXCluster",
|
||||
value = { message: ClusterMessage -> message.messageInhibited.orEmpty() },
|
||||
),
|
||||
),
|
||||
rowKey = RowKeys.byReference<ClusterMessage>(),
|
||||
)
|
||||
|
||||
private fun message(sender: ChatMember, receiver: ChatMember, text: String) = ChatMessage().apply {
|
||||
this.sender = sender
|
||||
this.receiver = receiver
|
||||
chatCategory = sender.chatCategory
|
||||
messageText = text
|
||||
}
|
||||
|
||||
private fun member(callSign: String, category: Int) = ChatMember().apply {
|
||||
this.callSign = callSign
|
||||
chatCategory = ChatCategory(category)
|
||||
qra = "JO31AA"
|
||||
}
|
||||
|
||||
private data class RenderScenario(
|
||||
val prefix: String,
|
||||
val panelWidth: Dp,
|
||||
val windowHeight: Int,
|
||||
)
|
||||
}
|
||||
+431
@@ -0,0 +1,431 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.assertIsOff
|
||||
import androidx.compose.ui.test.assertIsOn
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import androidx.compose.ui.test.runComposeUiTest
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kst4contest.model.Band
|
||||
import kst4contest.model.ChatCategory
|
||||
import kst4contest.model.ChatMember
|
||||
import kst4contest.model.ChatMessage
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.Instant
|
||||
import java.util.EnumSet
|
||||
import java.util.function.Consumer
|
||||
import kotlin.math.abs
|
||||
|
||||
/** The selected-station controls remain reachable when its splitter pane is short. */
|
||||
@OptIn(ExperimentalTestApi::class)
|
||||
class SelectedStationPanelScreenTest {
|
||||
|
||||
@Test
|
||||
fun stationHeaderKeepsAllMetadataOnOneLineWhenWideAndOmitsTheLocator() = runComposeUiTest {
|
||||
val station = member("CT1EKD").apply {
|
||||
qra = "IM59IB"
|
||||
chatCategory = ChatCategory(2)
|
||||
setQTFdirection(255.54)
|
||||
qrb = 1907.59
|
||||
activityTimeLastInEpoch = Instant.now().epochSecond - 240
|
||||
}
|
||||
val selected = selectedState(station, detectedBands = "Bands: 144/432 MHz")
|
||||
|
||||
setPanel(selected, Modifier.width(900.dp).height(420.dp))
|
||||
|
||||
val callsignTop = onNodeWithText("CT1EKD").fetchSemanticsNode().boundsInRoot.top
|
||||
listOf(
|
||||
station.chatCategory.getChatCategoryName(station.chatCategory.categoryNumber),
|
||||
"QTF: 255.54°",
|
||||
"QRB: 1907.59 km",
|
||||
"Bands: 144/432 MHz",
|
||||
).forEach { text ->
|
||||
val top = onNodeWithText(text).assertIsDisplayed().fetchSemanticsNode().boundsInRoot.top
|
||||
assertTrue(abs(top - callsignTop) <= 4f, "$text did not remain in the wide title row")
|
||||
}
|
||||
onNodeWithText("Last activity:", substring = true).assertIsDisplayed()
|
||||
onNodeWithText("Bands: Bands:", substring = true).assertDoesNotExist()
|
||||
onNodeWithText("IM59IB").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stationHeaderWrapsMetadataWithoutClippingWhenNarrow() = runComposeUiTest {
|
||||
val station = member("CT1EKD").apply {
|
||||
qra = "IM59IB"
|
||||
chatCategory = ChatCategory(2)
|
||||
setQTFdirection(255.54)
|
||||
qrb = 1907.59
|
||||
activityTimeLastInEpoch = Instant.now().epochSecond - 240
|
||||
}
|
||||
val selected = selectedState(station, detectedBands = "Bands: 144/432 MHz")
|
||||
|
||||
setPanel(selected, Modifier.width(310.dp).height(620.dp))
|
||||
|
||||
val callsign = onNodeWithText("CT1EKD").fetchSemanticsNode().boundsInRoot
|
||||
val bands = onNodeWithText("Bands: 144/432 MHz").assertIsDisplayed()
|
||||
.fetchSemanticsNode().boundsInRoot
|
||||
val panel = onNodeWithTag(SELECTED_STATION_PANEL_TAG).fetchSemanticsNode().boundsInRoot
|
||||
assertTrue(bands.top > callsign.top, "the narrow header did not wrap")
|
||||
assertTrue(bands.right <= panel.right, "wrapped metadata crossed the panel edge")
|
||||
onNodeWithText("IM59IB").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stationHeaderKeepsTheAlreadyLabelledEmptyBandsValueExactlyOnce() = runComposeUiTest {
|
||||
val selected = selectedState(member("DM5M"), detectedBands = "Bands: -")
|
||||
|
||||
setPanel(selected, Modifier.width(570.dp).height(420.dp))
|
||||
|
||||
onNodeWithText("Bands: -").assertIsDisplayed()
|
||||
onNodeWithText("Bands: Bands:", substring = true).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun messageFilterLabelSharesTheFirstFlowItemWithPmToMe() = runComposeUiTest {
|
||||
val selected = selectedState("DM5M")
|
||||
|
||||
setPanel(selected, Modifier.width(250.dp).height(520.dp))
|
||||
|
||||
val label = onNodeWithText("Show messages:").assertIsDisplayed()
|
||||
.fetchSemanticsNode().boundsInRoot
|
||||
val firstChoice = onNodeWithText("pm to me").assertIsDisplayed()
|
||||
.fetchSemanticsNode().boundsInRoot
|
||||
assertTrue(abs(label.center.y - firstChoice.center.y) <= 2f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun allMessageChoicesShareOneVerticalAxis() = runComposeUiTest {
|
||||
setPanel(selectedState("DM5M"), Modifier.width(570.dp).height(420.dp))
|
||||
|
||||
val centers = listOf("pm to me", "pm to other", "public msgs", "nothing")
|
||||
.map { onNodeWithText(it).fetchSemanticsNode().boundsInRoot.center.y }
|
||||
|
||||
assertTrue(centers.max() - centers.min() <= 1f, "radio labels are misaligned: $centers")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun selectedMessageSplitterChangesTheActualTableHeightInBothDirections() = runComposeUiTest {
|
||||
val selected = selectedState("DM5M")
|
||||
val splitter = SplitterState(2, doubleArrayOf(0.45)) {}
|
||||
|
||||
setContent {
|
||||
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
|
||||
SelectedStationPanel(
|
||||
state = selected,
|
||||
messages = messageTable(),
|
||||
activeBands = EnumSet.of(Band.B_144),
|
||||
contentSplitter = splitter,
|
||||
openInBrowser = Consumer {},
|
||||
onShowOnMap = {},
|
||||
onShowPathInAirScout = {},
|
||||
modifier = Modifier.width(570.dp).height(620.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val initial = onNodeWithTag(SELECTED_MESSAGES_TABLE_TAG)
|
||||
.fetchSemanticsNode().boundsInRoot.height
|
||||
runOnIdle { splitter.drag(0, 0.70f, 500f) }
|
||||
waitForIdle()
|
||||
val taller = onNodeWithTag(SELECTED_MESSAGES_TABLE_TAG)
|
||||
.fetchSemanticsNode().boundsInRoot.height
|
||||
assertTrue(taller > initial, "moving the divider down did not enlarge the table")
|
||||
|
||||
runOnIdle { splitter.drag(0, 0.25f, 500f) }
|
||||
waitForIdle()
|
||||
val shorter = onNodeWithTag(SELECTED_MESSAGES_TABLE_TAG)
|
||||
.fetchSemanticsNode().boundsInRoot.height
|
||||
assertTrue(shorter < taller, "moving the divider up did not shrink the table")
|
||||
|
||||
onNodeWithText("Lookup on qrz.com").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun selectedMessageSplitterSurvivesStationAndMessageUpdates() = runComposeUiTest {
|
||||
val selected = selectedState("DM5M")
|
||||
val messages = messageTable()
|
||||
val splitter = SplitterState(2, doubleArrayOf(0.62)) {}
|
||||
|
||||
setContent {
|
||||
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
|
||||
SelectedStationPanel(
|
||||
state = selected,
|
||||
messages = messages,
|
||||
activeBands = EnumSet.of(Band.B_144),
|
||||
contentSplitter = splitter,
|
||||
openInBrowser = Consumer {},
|
||||
onShowOnMap = {},
|
||||
onShowPathInAirScout = {},
|
||||
modifier = Modifier.width(570.dp).height(620.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val expectedPosition = splitter.positions.single()
|
||||
runOnIdle {
|
||||
selected.select(member("DL1ABC"))
|
||||
messages.replaceRows(listOf(ChatMessage().apply { messageText = "new message" }))
|
||||
}
|
||||
waitForIdle()
|
||||
|
||||
assertEquals(expectedPosition, splitter.positions.single())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun notQrvHeadingFollowsTheCurrentStationWithoutKeepingAStaleCallsign() = runComposeUiTest {
|
||||
val selected = selectedState("DM5M")
|
||||
|
||||
setContent {
|
||||
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
|
||||
SelectedStationPanel(
|
||||
state = selected,
|
||||
messages = messageTable(),
|
||||
activeBands = EnumSet.of(Band.B_144),
|
||||
openInBrowser = Consumer {},
|
||||
onShowOnMap = {},
|
||||
onShowPathInAirScout = {},
|
||||
modifier = Modifier.height(420.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
onNodeWithText("Tag DM5M not QRV at bands:").assertIsDisplayed()
|
||||
|
||||
runOnIdle { selected.select(member("DL1ABC")) }
|
||||
waitForIdle()
|
||||
onNodeWithText("Tag DM5M not QRV at bands:").assertDoesNotExist()
|
||||
onNodeWithText("Tag DL1ABC not QRV at bands:").assertIsDisplayed()
|
||||
|
||||
runOnIdle { selected.select(null) }
|
||||
waitForIdle()
|
||||
onNodeWithText("Tag DL1ABC not QRV at bands:").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun skedSentenceUsesTheCompactControlsWithoutTheOldOffsetsLabel() = runComposeUiTest {
|
||||
val selected = selectedState("DM5M")
|
||||
|
||||
setContent {
|
||||
Kst4ContestTheme(darkMode = true, baseFontSizeSp = 12f) {
|
||||
SelectedStationPanel(
|
||||
state = selected,
|
||||
messages = messageTable(),
|
||||
activeBands = EnumSet.of(Band.B_144),
|
||||
openInBrowser = Consumer {},
|
||||
onShowOnMap = {},
|
||||
onShowPathInAirScout = {},
|
||||
modifier = Modifier.height(420.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
onNodeWithText("Sked alert").assertIsDisplayed()
|
||||
onNodeWithText("Sked in").assertIsDisplayed()
|
||||
onNodeWithText("Remind pm in").assertIsDisplayed()
|
||||
onNodeWithText("Reminder offsets").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun skedBandChoiceUsesEnabledBandsAndPassesTheSelectionToSkedCreation() = runComposeUiTest {
|
||||
var createdMinutes: Int? = null
|
||||
var createdBand: Band? = null
|
||||
var createdMode: String? = null
|
||||
val selected = SelectedStationState(
|
||||
propagateNotQrv = {},
|
||||
createSked = { _, minutes, band, mode ->
|
||||
createdMinutes = minutes
|
||||
createdBand = band
|
||||
createdMode = mode
|
||||
true
|
||||
},
|
||||
).apply {
|
||||
select(member("DM5M"))
|
||||
skedBand = Band.B_144
|
||||
skedMode = "SSB"
|
||||
}
|
||||
|
||||
setContent {
|
||||
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
|
||||
SelectedStationPanel(
|
||||
state = selected,
|
||||
messages = messageTable(),
|
||||
activeBands = EnumSet.of(Band.B_144, Band.B_432),
|
||||
openInBrowser = Consumer {},
|
||||
onShowOnMap = {},
|
||||
onShowPathInAirScout = {},
|
||||
modifier = Modifier.width(570.dp).height(520.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
onNodeWithText("144 MHz").performClick()
|
||||
onNodeWithText("1296 MHz").assertDoesNotExist()
|
||||
onNodeWithText("432 MHz").assertIsDisplayed().performClick()
|
||||
assertEquals(Band.B_432, selected.skedBand)
|
||||
|
||||
onNodeWithText("5").performClick()
|
||||
onNodeWithText("8").assertIsDisplayed().performClick()
|
||||
onNodeWithText("SSB").performClick()
|
||||
onNodeWithText("CW").assertIsDisplayed().performClick()
|
||||
|
||||
onNodeWithText("Create sked").performScrollTo().performClick()
|
||||
assertEquals(8, createdMinutes)
|
||||
assertEquals(Band.B_432, createdBand)
|
||||
assertEquals("CW", createdMode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun notQrvToggleButtonsKeepAllSemanticsAndFollowTheSelectedStation() = runComposeUiTest {
|
||||
val first = member("DM5M").apply {
|
||||
isQrv144 = false
|
||||
isQrv432 = true
|
||||
isQrv1240 = false
|
||||
}
|
||||
val second = member("DL1ABC").apply {
|
||||
isQrv144 = true
|
||||
isQrv432 = false
|
||||
isQrv1240 = true
|
||||
}
|
||||
val propagated = mutableListOf<ChatMember>()
|
||||
val selected = SelectedStationState(
|
||||
propagateNotQrv = { propagated += it },
|
||||
createSked = { _, _, _, _ -> true },
|
||||
).apply { select(first) }
|
||||
|
||||
setPanel(
|
||||
selected,
|
||||
Modifier.width(570.dp).height(520.dp),
|
||||
EnumSet.of(Band.B_144, Band.B_432, Band.B_1296),
|
||||
)
|
||||
|
||||
onNodeWithText("144").assertIsOn()
|
||||
onNodeWithText("432").assertIsOff()
|
||||
onNodeWithText("23cm").assertIsOn()
|
||||
onNodeWithText("all").assertIsOff().performClick().assertIsOn()
|
||||
assertFalse(first.isQrv10G, "all must retain its existing all-supported-bands semantics")
|
||||
assertTrue(propagated.last() === first)
|
||||
|
||||
onNodeWithText("all").performClick().assertIsOff()
|
||||
assertTrue(first.isQrv144)
|
||||
assertTrue(first.isQrv10G)
|
||||
|
||||
runOnIdle { selected.select(second) }
|
||||
waitForIdle()
|
||||
onNodeWithText("144").assertIsOff()
|
||||
onNodeWithText("432").assertIsOn()
|
||||
onNodeWithText("23cm").assertIsOff()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun notQrvAndSkedGroupsFollowTheActualPanelWidth() = runComposeUiTest {
|
||||
val selected = selectedState("DM5M")
|
||||
var panelWidth by mutableStateOf(620.dp)
|
||||
|
||||
setContent {
|
||||
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
|
||||
Panel(selected, Modifier.width(panelWidth).height(560.dp))
|
||||
}
|
||||
}
|
||||
|
||||
var notQrv = onNodeWithTag(SELECTED_NOT_QRV_GROUP_TAG).fetchSemanticsNode().boundsInRoot
|
||||
var sked = onNodeWithTag(SELECTED_SKED_GROUP_TAG).fetchSemanticsNode().boundsInRoot
|
||||
assertTrue(abs(notQrv.top - sked.top) <= 1f, "wide groups were not arranged horizontally")
|
||||
assertTrue(sked.left >= notQrv.right, "wide groups overlap")
|
||||
|
||||
runOnIdle { panelWidth = 360.dp }
|
||||
waitForIdle()
|
||||
|
||||
notQrv = onNodeWithTag(SELECTED_NOT_QRV_GROUP_TAG).fetchSemanticsNode().boundsInRoot
|
||||
sked = onNodeWithTag(SELECTED_SKED_GROUP_TAG).fetchSemanticsNode().boundsInRoot
|
||||
val panel = onNodeWithTag(SELECTED_STATION_PANEL_TAG).fetchSemanticsNode().boundsInRoot
|
||||
assertTrue(sked.top >= notQrv.bottom, "narrow groups were not stacked vertically")
|
||||
assertTrue(notQrv.right <= panel.right && sked.right <= panel.right, "a group crossed the panel edge")
|
||||
|
||||
listOf("Create sked", "Sked fail", "Reset fail").forEach {
|
||||
onNodeWithText(it).performScrollTo().assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun lookupActionsCanBeScrolledIntoViewInAShortPane() = runComposeUiTest {
|
||||
val selected = selectedState("DL1ABC")
|
||||
val messages = messageTable()
|
||||
|
||||
setContent {
|
||||
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
|
||||
SelectedStationPanel(
|
||||
state = selected,
|
||||
messages = messages,
|
||||
activeBands = EnumSet.of(Band.B_144),
|
||||
openInBrowser = Consumer {},
|
||||
onShowOnMap = {},
|
||||
onShowPathInAirScout = {},
|
||||
modifier = Modifier.height(220.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
onNodeWithText("Lookup on qrz.com").assertIsDisplayed()
|
||||
}
|
||||
|
||||
private fun selectedState(callSign: String) = selectedState(member(callSign))
|
||||
|
||||
private fun selectedState(member: ChatMember, detectedBands: String = "") = SelectedStationState(
|
||||
propagateNotQrv = {},
|
||||
createSked = { _, _, _, _ -> true },
|
||||
detectBands = { detectedBands },
|
||||
).apply { select(member) }
|
||||
|
||||
private fun member(callSign: String) = ChatMember().apply {
|
||||
this.callSign = callSign
|
||||
qra = "JO50AA"
|
||||
}
|
||||
|
||||
private fun androidx.compose.ui.test.ComposeUiTest.setPanel(
|
||||
selected: SelectedStationState,
|
||||
modifier: Modifier,
|
||||
activeBands: Set<Band> = EnumSet.of(Band.B_144),
|
||||
) {
|
||||
setContent {
|
||||
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
|
||||
Panel(selected, modifier, activeBands)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@androidx.compose.runtime.Composable
|
||||
private fun Panel(
|
||||
selected: SelectedStationState,
|
||||
modifier: Modifier,
|
||||
activeBands: Set<Band> = EnumSet.of(Band.B_144),
|
||||
) {
|
||||
SelectedStationPanel(
|
||||
state = selected,
|
||||
messages = messageTable(),
|
||||
activeBands = activeBands,
|
||||
openInBrowser = Consumer {},
|
||||
onShowOnMap = {},
|
||||
onShowPathInAirScout = {},
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
private fun messageTable() = DataTableState(
|
||||
SelectedStationMessageColumns.all(),
|
||||
RowKeys.byReference<ChatMessage>(),
|
||||
"selected-station-panel-test",
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import kst4contest.model.ChatMember
|
||||
import kst4contest.model.ChatPreferences
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
import java.nio.file.Path
|
||||
|
||||
/**
|
||||
* The panel below the station list, for the station currently selected.
|
||||
@@ -16,6 +19,9 @@ import org.junit.jupiter.api.Assertions.assertTrue
|
||||
*/
|
||||
class SelectedStationStateTest {
|
||||
|
||||
@TempDir
|
||||
lateinit var tempDir: Path
|
||||
|
||||
private class Session {
|
||||
val propagated = mutableListOf<String>()
|
||||
var skedsCreated = 0
|
||||
@@ -178,6 +184,15 @@ class SelectedStationStateTest {
|
||||
"the JavaFX radio buttons were one toggle group; two cannot be on")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `public messages can be the configured initial radio mode`() {
|
||||
val prefs = ChatPreferences(tempDir.resolve("preferences.xml")).apply {
|
||||
isGuiOptions_defaultFilterPublicMsgs = true
|
||||
}
|
||||
|
||||
assertEquals(SelectedMessageFilter.PUBLIC, SelectedMessageFilter.fromPreferences(prefs))
|
||||
}
|
||||
|
||||
/**
|
||||
* No filter is chosen until the operator chooses one. JavaFX has the
|
||||
* setSelected(true) on "pm to me" commented out, so the group starts with nothing on,
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import kst4contest.model.Band
|
||||
import kst4contest.model.ChatCategory
|
||||
import kst4contest.model.ChatMember
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertSame
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class SkedEditorStateTest {
|
||||
|
||||
@Test
|
||||
fun `panel submission includes reminder settings in one request`() {
|
||||
val requests = mutableListOf<SkedRequest>()
|
||||
val member = member("DL1ABC", 2)
|
||||
val state = SelectedStationState(
|
||||
propagateNotQrv = { },
|
||||
createSked = { _, _, _, _ -> false },
|
||||
)
|
||||
state.skedRequestSubmitter = { request -> requests += request; true }
|
||||
state.select(member)
|
||||
state.skedMinutes = 8
|
||||
state.skedBand = Band.B_432
|
||||
state.skedMode = "CW"
|
||||
state.remindPm = true
|
||||
state.reminderOffsets = "5+2+1"
|
||||
|
||||
assertTrue(state.createSked())
|
||||
assertEquals(
|
||||
SkedRequest(member, 8, Band.B_432, "CW", true, "5+2+1"),
|
||||
requests.single(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `popup draft keeps its target when the shared selection changes`() {
|
||||
val requests = mutableListOf<SkedRequest>()
|
||||
val original = member("DL1ABC", 2)
|
||||
val later = member("DL2XYZ", 3)
|
||||
val draft = SkedEditorState(
|
||||
target = original,
|
||||
minutes = 5,
|
||||
band = Band.B_144,
|
||||
mode = "SSB",
|
||||
remindPm = false,
|
||||
reminderOffsets = "2+1",
|
||||
submitter = { request -> requests += request; true },
|
||||
)
|
||||
|
||||
draft.minutes = 10
|
||||
draft.band = Band.B_1296
|
||||
draft.remindPm = true
|
||||
@Suppress("UNUSED_VARIABLE") val unrelatedSelection = later
|
||||
|
||||
assertTrue(draft.submit())
|
||||
assertSame(original, requests.single().target)
|
||||
assertEquals(Band.B_1296, requests.single().band)
|
||||
assertTrue(requests.single().remindPm)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejected popup submission reports failure so popup can remain open`() {
|
||||
val draft = SkedEditorState(
|
||||
target = member("DL1ABC", 2),
|
||||
minutes = 5,
|
||||
band = null,
|
||||
mode = "SSB",
|
||||
remindPm = false,
|
||||
reminderOffsets = "2+1",
|
||||
submitter = { false },
|
||||
)
|
||||
|
||||
assertFalse(draft.submit())
|
||||
}
|
||||
|
||||
private fun member(callSign: String, category: Int) = ChatMember().apply {
|
||||
this.callSign = callSign
|
||||
chatCategory = ChatCategory(category)
|
||||
}
|
||||
}
|
||||
@@ -219,6 +219,57 @@ class SplitterStateTest {
|
||||
assertEquals(777f, state.paneSizes(totalPx = 777f).sum(), 0.01f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pane-specific minimums keep fixed-height controls visible without replacing stored positions`() {
|
||||
val stored = doubleArrayOf(0.62, 0.7, 0.75, 0.9)
|
||||
val state = SplitterState(paneCount = 5, stored = stored) {}
|
||||
|
||||
val sizes = state.paneSizes(
|
||||
totalPx = 800f,
|
||||
minimumPaneSizesPx = floatArrayOf(24f, 26f, 104f, 26f, 24f),
|
||||
)
|
||||
|
||||
assertTrue(sizes[0] >= 24f)
|
||||
assertTrue(sizes[1] >= 26f)
|
||||
assertTrue(sizes[2] >= 104f)
|
||||
assertTrue(sizes[3] >= 26f)
|
||||
assertTrue(sizes[4] >= 24f)
|
||||
assertEquals(800f, sizes.sum(), 0.01f)
|
||||
stored.forEachIndexed { index, position ->
|
||||
assertEquals(position.toFloat(), state.positions[index], 0.001f)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pane-specific minimums scale down together in an impossibly small splitter`() {
|
||||
val state = SplitterState(paneCount = 3, stored = doubleArrayOf(0.2, 0.8)) {}
|
||||
|
||||
val sizes = state.paneSizes(
|
||||
totalPx = 100f,
|
||||
minimumPaneSizesPx = floatArrayOf(50f, 100f, 50f),
|
||||
)
|
||||
|
||||
assertEquals(listOf(25f, 50f, 25f), sizes)
|
||||
assertEquals(100f, sizes.sum(), 0.01f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pane-specific minimum scaling tolerates float rounding`() {
|
||||
val state = SplitterState(
|
||||
paneCount = 2,
|
||||
stored = doubleArrayOf(0.48),
|
||||
minPanePx = 160f,
|
||||
) {}
|
||||
|
||||
val sizes = state.paneSizes(
|
||||
totalPx = 24.405657f,
|
||||
minimumPaneSizesPx = floatArrayOf(52f, 160f),
|
||||
)
|
||||
|
||||
assertTrue(sizes.all { it >= 0f })
|
||||
assertEquals(24.405657f, sizes.sum(), 0.0001f)
|
||||
}
|
||||
|
||||
private fun assertNull(value: Any?, message: String) {
|
||||
assertFalse(value != null, message)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntRect
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import kst4contest.model.Band
|
||||
import kst4contest.model.ChatCategory
|
||||
import kst4contest.model.ChatMember
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertSame
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class StationContextMenuStateTest {
|
||||
|
||||
@Test
|
||||
fun `single row target is selected immediately with its context source`() {
|
||||
val selections = mutableListOf<Pair<ChatMember, StationSelectionSource>>()
|
||||
val state = StationContextMenuState { member, source -> selections += member to source }
|
||||
val member = member("DL1ABC", 2)
|
||||
|
||||
state.open(listOf(member), StationSelectionSource.STATION_CONTEXT_MENU, IntOffset(10, 20))
|
||||
|
||||
assertSame(member, state.target)
|
||||
assertEquals(listOf(member to StationSelectionSource.STATION_CONTEXT_MENU), selections)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `grouped map target requires an explicit full callsign and category choice`() {
|
||||
val selections = mutableListOf<Pair<ChatMember, StationSelectionSource>>()
|
||||
val state = StationContextMenuState { member, source -> selections += member to source }
|
||||
val main = member("DN9APW-2", 2)
|
||||
val second = member("DN9APW-2", 3)
|
||||
|
||||
state.open(
|
||||
listOf(main, second, main),
|
||||
StationSelectionSource.MAP_CONTEXT_MENU,
|
||||
IntOffset(30, 40),
|
||||
)
|
||||
|
||||
assertNull(state.target)
|
||||
assertEquals(2, state.candidates.size)
|
||||
assertEquals(emptyList<Pair<ChatMember, StationSelectionSource>>(), selections)
|
||||
|
||||
state.chooseTarget(second)
|
||||
|
||||
assertSame(second, state.target)
|
||||
assertEquals(listOf(second to StationSelectionSource.MAP_CONTEXT_MENU), selections)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `target label retains the full suffix and category`() {
|
||||
val target = member("DN9APW-70", 3)
|
||||
|
||||
val label = stationContextTargetLabel(target)
|
||||
|
||||
assertTrue(label.startsWith("DN9APW-70 | "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `worked menu state follows the existing per-band flags`() {
|
||||
val target = member("DL1ABC", 2)
|
||||
target.isWorked144 = true
|
||||
|
||||
assertTrue(target.isWorkedOn(Band.B_144))
|
||||
assertFalse(target.isWorkedOn(Band.B_432))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `popup anchor is clamped at the window edge`() {
|
||||
val provider = WindowPointPopupPositionProvider(IntOffset(990, 790))
|
||||
|
||||
val position = provider.calculatePosition(
|
||||
anchorBounds = IntRect.Zero,
|
||||
windowSize = IntSize(1_000, 800),
|
||||
layoutDirection = LayoutDirection.Ltr,
|
||||
popupContentSize = IntSize(300, 250),
|
||||
)
|
||||
|
||||
assertEquals(IntOffset(700, 550), position)
|
||||
}
|
||||
|
||||
private fun member(callSign: String, category: Int) = ChatMember().apply {
|
||||
this.callSign = callSign
|
||||
chatCategory = ChatCategory(category)
|
||||
}
|
||||
}
|
||||
+190
-2
@@ -10,10 +10,13 @@ import androidx.compose.ui.test.assertIsOff
|
||||
import androidx.compose.ui.test.assertIsOn
|
||||
import androidx.compose.ui.test.assertTextEquals
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.runComposeUiTest
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kst4contest.model.Band
|
||||
import kst4contest.model.ChatPreferences
|
||||
import kst4contest.view.compose.ObservedValue
|
||||
import kst4contest.view.compose.tabs.checkBeside
|
||||
import kst4contest.view.compose.tabs.fieldBeside
|
||||
import kst4contest.view.compose.tabs.showTab
|
||||
@@ -23,7 +26,10 @@ import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
import java.nio.file.Path
|
||||
import java.util.EnumSet
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
* The QRB part of the station filter bar, drawn (#21).
|
||||
@@ -35,7 +41,7 @@ import java.util.EnumSet
|
||||
* Headless: only a composition, no window.
|
||||
*/
|
||||
@OptIn(ExperimentalTestApi::class)
|
||||
class StationFilterBarScreenTest {
|
||||
class StationFilterBarScreenTest(@param:TempDir private val directory: Path) {
|
||||
|
||||
private val maxLabel = "Show only QRB [km] <="
|
||||
private val minLabel = "Show only QRB [km] >"
|
||||
@@ -43,6 +49,7 @@ class StationFilterBarScreenTest {
|
||||
private class Bar {
|
||||
val filters = StationFilterState()
|
||||
var changes = 0
|
||||
val explicitQtfDirections = mutableListOf<Double>()
|
||||
}
|
||||
|
||||
private fun ComposeUiTest.showBar(): Bar {
|
||||
@@ -51,12 +58,127 @@ class StationFilterBarScreenTest {
|
||||
StationFilterBar(
|
||||
filters = bar.filters,
|
||||
activeBands = EnumSet.of(Band.B_144),
|
||||
reachability = ReachabilityState(EnumSet.of(Band.B_144)),
|
||||
onChanged = { bar.changes++ },
|
||||
onQtfDirectionChanged = { bar.explicitQtfDirections += it },
|
||||
)
|
||||
}
|
||||
return bar
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aValidManualQtfEntryIsReportedEvenWhileTheFilterIsOff() = runComposeUiTest {
|
||||
val bar = showBar()
|
||||
|
||||
type("Show only QTF:", "127.5")
|
||||
|
||||
assertFalse(bar.filters.qtfEnabled)
|
||||
assertEquals(listOf(127.5), bar.explicitQtfDirections)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun everyBearingButtonReportsItsExplicitDirection() = runComposeUiTest {
|
||||
val bar = showBar()
|
||||
|
||||
Bearing.entries.forEach { bearing ->
|
||||
onNodeWithText(bearing.name).performClick()
|
||||
}
|
||||
|
||||
assertEquals(Bearing.entries.map { it.degrees }, bar.explicitQtfDirections)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deselectingABearingDoesNotReportAnotherExplicitDirection() = runComposeUiTest {
|
||||
val bar = showBar()
|
||||
|
||||
onNodeWithText("NE").performClick()
|
||||
onNodeWithText("NE").performClick()
|
||||
|
||||
assertFalse(bar.filters.qtfEnabled)
|
||||
assertEquals(listOf(45.0), bar.explicitQtfDirections)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun defaultManualHeadingMakesNorthEastVisibleToTheMapObserverWhileFilterIsOff() = runComposeUiTest {
|
||||
val prefs = ChatPreferences(directory.resolve("preferences.xml"))
|
||||
val filters = StationFilterState()
|
||||
val mapHeading = ObservedValue(prefs.actualQTF)
|
||||
try {
|
||||
showTab {
|
||||
StationFilterBar(
|
||||
filters = filters,
|
||||
activeBands = EnumSet.of(Band.B_144),
|
||||
reachability = ReachabilityState(EnumSet.of(Band.B_144)),
|
||||
onChanged = {},
|
||||
onQtfDirectionChanged = { applyExplicitQtfHeading(prefs, it) },
|
||||
)
|
||||
}
|
||||
|
||||
assertFalse(filters.qtfEnabled)
|
||||
onNodeWithText("NE").performClick()
|
||||
waitForIdle()
|
||||
|
||||
assertEquals(45.0, prefs.actualQTF.get(), 0.0001)
|
||||
assertEquals(45.0, requireNotNull(mapHeading.value), 0.0001)
|
||||
assertEquals(135.0, prefs.stn_qtfDefault, 0.0001)
|
||||
} finally {
|
||||
mapHeading.release()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resetAndQtfCheckboxChangesDoNotReportAnExplicitDirection() = runComposeUiTest {
|
||||
val bar = showBar()
|
||||
toggle("Show only QTF:")
|
||||
onNodeWithText("Reset filters").performClick()
|
||||
|
||||
assertTrue(bar.explicitQtfDirections.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun invalidManualQtfEntriesAreNotReported() = runComposeUiTest {
|
||||
val bar = showBar()
|
||||
|
||||
listOf("NaN", "Infinity", "-1", "360.1").forEach { type("Show only QTF:", it) }
|
||||
|
||||
assertTrue(bar.explicitQtfDirections.isEmpty())
|
||||
assertEquals(0.0, bar.filters.qtfDegrees, 0.0001)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reachabilityBandChangesDoNotCalculateUntilTheButtonIsClicked() = runComposeUiTest {
|
||||
val filters = StationFilterState()
|
||||
val station = kst4contest.model.ChatMember()
|
||||
var calculations = 0
|
||||
val reachability = ReachabilityState(
|
||||
activeBands = EnumSet.of(Band.B_144, Band.B_432),
|
||||
resolveAutoBand = { Band.B_144 },
|
||||
selectedStation = { station },
|
||||
calculate = { member, band ->
|
||||
assertEquals(station, member)
|
||||
assertEquals(Band.B_432, band)
|
||||
calculations++
|
||||
},
|
||||
)
|
||||
|
||||
showTab {
|
||||
StationFilterBar(
|
||||
filters = filters,
|
||||
activeBands = EnumSet.of(Band.B_144, Band.B_432),
|
||||
reachability = reachability,
|
||||
onChanged = { },
|
||||
)
|
||||
}
|
||||
|
||||
onNodeWithText("Reachability").assertIsDisplayed()
|
||||
onNodeWithText("Auto").performClick()
|
||||
awaitNode(inPopup("432")).performClick()
|
||||
assertEquals(0, calculations)
|
||||
|
||||
onNodeWithText("Calc selected").performClick()
|
||||
assertEquals(1, calculations)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theMinimumQrbControlsWriteTheMinimumFilterOnly() = runComposeUiTest {
|
||||
val bar = showBar()
|
||||
@@ -133,7 +255,12 @@ class StationFilterBarScreenTest {
|
||||
setContent {
|
||||
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
|
||||
Box(Modifier.width(width)) {
|
||||
StationFilterBar(filters = filters, activeBands = EnumSet.of(Band.B_144), onChanged = {})
|
||||
StationFilterBar(
|
||||
filters = filters,
|
||||
activeBands = EnumSet.of(Band.B_144),
|
||||
reachability = ReachabilityState(EnumSet.of(Band.B_144)),
|
||||
onChanged = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -145,6 +272,10 @@ class StationFilterBarScreenTest {
|
||||
onNodeWithText(maxLabel),
|
||||
onNodeWithText(minLabel),
|
||||
onNodeWithText("Show only QTF:"),
|
||||
onNodeWithText("Inactive stations"),
|
||||
onNodeWithText("Only new grids"),
|
||||
onNodeWithText("AS next 5m"),
|
||||
onNodeWithText("Calc selected"),
|
||||
fieldBeside(maxLabel),
|
||||
fieldBeside(minLabel),
|
||||
)
|
||||
@@ -154,4 +285,61 @@ class StationFilterBarScreenTest {
|
||||
assertTrue(right <= limit + 0.5f, "a control ends at $right px, beyond the $limit px wide bar")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun filterControlsShareOneHeightAndBearingsShareOneCompactWidth() = runComposeUiTest {
|
||||
showBar()
|
||||
|
||||
val fieldHeight = fieldBeside(maxLabel).fetchSemanticsNode().boundsInRoot.height
|
||||
val checkboxHeight = checkBeside(maxLabel).fetchSemanticsNode().boundsInRoot.height
|
||||
val findFieldHeight = onNodeWithText("Find...").fetchSemanticsNode().boundsInRoot.height
|
||||
val controlHeights = listOf("Reset filters", "N", "Auto", "Calc selected")
|
||||
.map { onNodeWithText(it).fetchSemanticsNode().boundsInRoot.height } +
|
||||
listOf(fieldHeight, checkboxHeight, findFieldHeight)
|
||||
assertTrue(
|
||||
controlHeights.max() - controlHeights.min() <= 1f,
|
||||
"filter control heights differ: $controlHeights",
|
||||
)
|
||||
|
||||
val bearingWidths = Bearing.entries
|
||||
.map { onNodeWithText(it.name).fetchSemanticsNode().boundsInRoot.width }
|
||||
assertTrue(
|
||||
bearingWidths.max() - bearingWidths.min() <= 1f,
|
||||
"bearing widths differ: $bearingWidths",
|
||||
)
|
||||
val expectedWidth = with(density) { Density.DIRECTION_BUTTON_WIDTH.toPx() }
|
||||
assertTrue(
|
||||
bearingWidths.all { abs(it - expectedWidth) <= 1f },
|
||||
"bearing buttons are not $expectedWidth px wide: $bearingWidths",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun functionalFilterGroupsRemainDistinctAndInsideANarrowBar() = runComposeUiTest {
|
||||
val width = 300.dp
|
||||
setContent {
|
||||
Kst4ContestTheme(darkMode = true, baseFontSizeSp = 12f) {
|
||||
Box(Modifier.width(width)) {
|
||||
StationFilterBar(
|
||||
filters = StationFilterState(),
|
||||
activeBands = EnumSet.of(Band.B_144),
|
||||
reachability = ReachabilityState(EnumSet.of(Band.B_144)),
|
||||
onChanged = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val limit = with(density) { width.toPx() }
|
||||
listOf(
|
||||
FILTER_DISTANCE_GROUP_TAG,
|
||||
FILTER_DIRECTION_GROUP_TAG,
|
||||
FILTER_STATION_GROUP_TAG,
|
||||
FILTER_WORKED_GROUP_TAG,
|
||||
FILTER_PROPAGATION_GROUP_TAG,
|
||||
).forEach { tag ->
|
||||
val bounds = onNodeWithTag(tag).assertIsDisplayed().fetchSemanticsNode().boundsInRoot
|
||||
assertTrue(bounds.left >= 0f && bounds.right <= limit + 0.5f, "$tag crosses the filter bar")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,17 +2,23 @@ package kst4contest.view.compose
|
||||
|
||||
import androidx.compose.ui.test.ComposeUiTest
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.MouseButton
|
||||
import androidx.compose.ui.test.click
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performMouseInput
|
||||
import androidx.compose.ui.test.runComposeUiTest
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import kst4contest.model.AirPlane
|
||||
import kst4contest.model.AirPlaneReflectionInfo
|
||||
import kst4contest.model.Band
|
||||
import kst4contest.model.ChatCategory
|
||||
import kst4contest.model.ChatMember
|
||||
import kst4contest.model.RecentChatJoin
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertSame
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.util.EnumSet
|
||||
|
||||
@@ -41,6 +47,14 @@ import java.util.EnumSet
|
||||
@OptIn(ExperimentalTestApi::class)
|
||||
class StationListScreenTest {
|
||||
|
||||
@Test
|
||||
fun anEmptyTableKeepsItsStructureAndExplainsTheEmptyBody() = runComposeUiTest {
|
||||
show(stationList())
|
||||
|
||||
onNodeWithText("No entries").assertExists()
|
||||
onNodeWithText("QRB").assertExists()
|
||||
}
|
||||
|
||||
private fun stationList(): DataTableState<ChatMember> = DataTableState(
|
||||
StationColumns.all(
|
||||
tropoOf = { "" },
|
||||
@@ -77,10 +91,15 @@ class StationListScreenTest {
|
||||
private fun ComposeUiTest.show(
|
||||
state: DataTableState<ChatMember>,
|
||||
onRowClick: (ChatMember) -> Unit = {},
|
||||
onRowContextMenu: ((ChatMember, IntOffset) -> Unit)? = null,
|
||||
) {
|
||||
setContent {
|
||||
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
|
||||
DataTableView(state = state, onRowClick = onRowClick)
|
||||
DataTableView(
|
||||
state = state,
|
||||
onRowClick = onRowClick,
|
||||
onRowContextMenu = onRowContextMenu,
|
||||
)
|
||||
}
|
||||
}
|
||||
waitForIdle()
|
||||
@@ -99,7 +118,7 @@ class StationListScreenTest {
|
||||
val inSecond = station("DL1ABC", category = 3)
|
||||
val clicked = mutableListOf<ChatMember>()
|
||||
|
||||
show(state) { clicked += it }
|
||||
show(state, onRowClick = { clicked += it })
|
||||
runOnIdle { state.replaceRows(listOf(inMain, inSecond)) }
|
||||
waitForIdle()
|
||||
|
||||
@@ -140,6 +159,7 @@ class StationListScreenTest {
|
||||
onNodeWithText("QRB").performClick()
|
||||
waitForIdle()
|
||||
|
||||
assertEquals("qrb", state.sortedColumnId, "clicking the header did not select its sort")
|
||||
onNodeWithText("DL2BBB").assertDoesNotExist()
|
||||
assertEquals(listOf("DL1AAA", "DL3CCC"), drawnOrder("DL3CCC", "DL1AAA"))
|
||||
}
|
||||
@@ -162,6 +182,7 @@ class StationListScreenTest {
|
||||
onNodeWithText("AP [minutes / pot%]").performClick()
|
||||
waitForIdle()
|
||||
|
||||
assertEquals("airscout", state.sortedColumnId, "clicking the header did not select its sort")
|
||||
assertEquals(
|
||||
listOf("DL1THREE", "DL1THIRTEEN", "DL1NONE"),
|
||||
drawnOrder("DL1THIRTEEN", "DL1NONE", "DL1THREE"),
|
||||
@@ -196,6 +217,52 @@ class StationListScreenTest {
|
||||
assertEquals(null, state.selectedRow)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aSecondaryClickReportsTheExactStationAndWindowPosition() = runComposeUiTest {
|
||||
val state = stationList()
|
||||
val member = station("DL1ABC", category = 3)
|
||||
state.replaceRows(listOf(member))
|
||||
val requests = mutableListOf<Pair<ChatMember, IntOffset>>()
|
||||
|
||||
show(state, onRowContextMenu = { target, position -> requests += target to position })
|
||||
onNodeWithText("DL1ABC").performMouseInput {
|
||||
click(button = MouseButton.Secondary)
|
||||
}
|
||||
waitForIdle()
|
||||
|
||||
assertEquals(1, requests.size)
|
||||
assertSame(member, requests.single().first)
|
||||
assertTrue(requests.single().second.x >= 0 && requests.single().second.y >= 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anExplicitSelectionScrollsUsingTheCurrentSortedPosition() = runComposeUiTest {
|
||||
val selection = StationSelectionState()
|
||||
val state = DataTableState(
|
||||
StationColumns.all(
|
||||
tropoOf = { "" },
|
||||
priorityScoreOf = { 0.0 },
|
||||
formatBand = { _, _, _ -> "" },
|
||||
activeBands = EnumSet.noneOf(Band::class.java),
|
||||
),
|
||||
RowKeys.byValue(StationColumns::rowKey),
|
||||
initialSortColumnId = "callsign",
|
||||
selectionModel = selection,
|
||||
keepSelectionVisible = true,
|
||||
)
|
||||
val stations = (1..60).map { number -> station("DL${number.toString().padStart(2, '0')}TEST") }
|
||||
state.replaceRows(stations.reversed())
|
||||
|
||||
show(state)
|
||||
onNodeWithText("DL60TEST").assertDoesNotExist()
|
||||
|
||||
runOnIdle { selection.select(stations.last(), StationSelectionSource.PUBLIC_MESSAGE) }
|
||||
waitForIdle()
|
||||
|
||||
onNodeWithText("DL60TEST").assertExists()
|
||||
assertSame(stations.last(), state.selectedRow)
|
||||
}
|
||||
|
||||
/**
|
||||
* A station's fields change in place and the application calls forceRedraw: the list
|
||||
* filters again, so a station the filter now excludes leaves the screen (#21).
|
||||
@@ -221,4 +288,33 @@ class StationListScreenTest {
|
||||
onNodeWithText("DL1MOVE").assertDoesNotExist()
|
||||
onNodeWithText("DL2STAY").assertExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onlyTheCallsignCellOfARecentlyJoinedStationIsEmphasized() = runComposeUiTest {
|
||||
val state = stationList()
|
||||
val joined = station("HB9SJV", 2)
|
||||
val existing = station("DL1OLD", 2)
|
||||
val recent = NewJoinedState(clock = { 5_000L }, onSelect = {}).apply {
|
||||
updateActiveMembers(listOf(joined, existing))
|
||||
updateEvents(listOf(RecentChatJoin("HB9SJV", 2, 1_000L)))
|
||||
}
|
||||
val emphasized = mutableSetOf<Pair<String, String>>()
|
||||
state.replaceRows(listOf(joined, existing))
|
||||
|
||||
setContent {
|
||||
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
|
||||
DataTableView(
|
||||
state = state,
|
||||
emphasizeCell = { member, column ->
|
||||
val emphasize = column.id == "callsign" && recent.isRecent(member)
|
||||
if (emphasize) emphasized += member.callSign to column.id
|
||||
emphasize
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
waitForIdle()
|
||||
|
||||
assertEquals(setOf("HB9SJV" to "callsign"), emphasized)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import kst4contest.model.ChatCategory
|
||||
import kst4contest.model.ChatMember
|
||||
import kst4contest.model.ChatMessage
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNotEquals
|
||||
import org.junit.jupiter.api.Assertions.assertSame
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class StationSelectionStateTest {
|
||||
|
||||
@Test
|
||||
fun `identity includes the full callsign and category`() {
|
||||
val main = station("DN9APW-2", 2)
|
||||
val second = station("DN9APW-2", 3)
|
||||
|
||||
assertNotEquals(StationIdentity.of(main), StationIdentity.of(second))
|
||||
assertEquals(2, StationIdentity.of(main)?.categoryNumber)
|
||||
assertEquals("DN9APW-2", StationIdentity.of(main)?.callSign)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selecting the same station again creates another scroll request`() {
|
||||
val state = StationSelectionState()
|
||||
val station = station("DL1ABC", 2)
|
||||
|
||||
state.select(station, StationSelectionSource.PUBLIC_MESSAGE)
|
||||
val first = state.scrollRequestRevision
|
||||
state.select(station, StationSelectionSource.MAP)
|
||||
|
||||
assertSame(station, state.selected)
|
||||
assertTrue(state.scrollRequestRevision > first)
|
||||
assertEquals(StationSelectionSource.MAP, state.lastSource)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `direct station table selections do not request scrolling`() {
|
||||
val state = StationSelectionState()
|
||||
|
||||
state.select(station("DL1ABC", 2), StationSelectionSource.STATION_TABLE)
|
||||
|
||||
assertEquals(0L, state.scrollRequestRevision)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `station table context selection does not request scrolling`() {
|
||||
val state = StationSelectionState()
|
||||
|
||||
state.select(station("DL1ABC", 2), StationSelectionSource.STATION_CONTEXT_MENU)
|
||||
|
||||
assertEquals(0L, state.scrollRequestRevision)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `map context selection requests scrolling again for the same station`() {
|
||||
val state = StationSelectionState()
|
||||
val station = station("DL1ABC", 2)
|
||||
|
||||
state.select(station, StationSelectionSource.MAP_CONTEXT_MENU)
|
||||
val first = state.scrollRequestRevision
|
||||
state.select(station, StationSelectionSource.MAP_CONTEXT_MENU)
|
||||
|
||||
assertTrue(state.scrollRequestRevision > first)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reconciling a refreshed instance keeps the selection without requesting a scroll`() {
|
||||
val state = StationSelectionState()
|
||||
state.select(station("DL1ABC", 2), StationSelectionSource.STATION_TABLE)
|
||||
val revision = state.scrollRequestRevision
|
||||
val refreshed = station("DL1ABC", 2)
|
||||
|
||||
state.reconcile(refreshed)
|
||||
|
||||
assertSame(refreshed, state.selected)
|
||||
assertEquals(revision, state.scrollRequestRevision)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `incoming directed message targets its sender`() {
|
||||
val message = directedMessage(sender = "DL1ABC", receiver = "OWN", category = 2)
|
||||
|
||||
assertEquals(
|
||||
StationIdentity("DL1ABC", 2),
|
||||
DirectedMessageStationTarget.forMessage(message, "OWN"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `own directed message targets its receiver`() {
|
||||
val message = directedMessage(sender = "own", receiver = "DL2XYZ", category = 3)
|
||||
|
||||
assertEquals(
|
||||
StationIdentity("DL2XYZ", 3),
|
||||
DirectedMessageStationTarget.forMessage(message, "OWN"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `historical own message can obtain its receiver from the payload`() {
|
||||
val message = directedMessage(sender = "OWN", receiver = null, category = 2).apply {
|
||||
messageText = "(DL3OLD) test"
|
||||
}
|
||||
|
||||
assertEquals(
|
||||
StationIdentity("DL3OLD", 2),
|
||||
DirectedMessageStationTarget.forMessage(message, "OWN"),
|
||||
)
|
||||
}
|
||||
|
||||
private fun station(callSign: String, category: Int): ChatMember = ChatMember().apply {
|
||||
this.callSign = callSign
|
||||
chatCategory = ChatCategory(category)
|
||||
}
|
||||
|
||||
private fun directedMessage(sender: String, receiver: String?, category: Int) = ChatMessage().apply {
|
||||
this.sender = station(sender, category)
|
||||
this.receiver = receiver?.let { station(it, category) }
|
||||
chatCategory = ChatCategory(category)
|
||||
messageText = "test"
|
||||
}
|
||||
}
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.ui.ImageComposeScene
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kst4contest.model.Band
|
||||
import kst4contest.model.ChatCategory
|
||||
import kst4contest.model.ChatMember
|
||||
import kst4contest.model.ChatMessage
|
||||
import kst4contest.model.RecentChatJoin
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.util.EnumSet
|
||||
import java.util.function.Consumer
|
||||
|
||||
/** Produces review images for the station filters, join bar and selected-station splitter. */
|
||||
class StationWorkspaceVisualRenderingTest {
|
||||
|
||||
@Test
|
||||
fun rendersDayAndEveningAt100And125Percent() {
|
||||
val reportDirectory = Path.of("build", "reports", "station-workspace-ui")
|
||||
Files.createDirectories(reportDirectory)
|
||||
|
||||
listOf(
|
||||
Scenario("wide", 700, 768),
|
||||
Scenario("narrow", 360, 768),
|
||||
Scenario("short", 570, 480),
|
||||
).forEach { scenario ->
|
||||
listOf(false, true).forEach { darkMode ->
|
||||
listOf(1f, 1.25f).forEach { scale ->
|
||||
val bytes = render(scenario, darkMode, scale)
|
||||
val palette = if (darkMode) "evening" else "day"
|
||||
val scaling = if (scale == 1f) "100" else "125"
|
||||
val name = "${scenario.name}-$palette-$scaling.png"
|
||||
Files.write(reportDirectory.resolve(name), bytes)
|
||||
assertTrue(bytes.size > 10_000, "$name did not render")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun render(scenario: Scenario, darkMode: Boolean, scale: Float): ByteArray {
|
||||
val members = (1..7).map { index -> member("DL${index}ABC", if (index == 7) 3 else 2) }
|
||||
val selectedMember = members.last().apply {
|
||||
qrb = 438.2
|
||||
setQTFdirection(76.0)
|
||||
isQrv432 = false
|
||||
}
|
||||
val newJoined = NewJoinedState(clock = { 20_000L }, onSelect = {}).apply {
|
||||
updateActiveMembers(members)
|
||||
updateEvents(members.mapIndexed { index, member ->
|
||||
RecentChatJoin(member.callSign, member.chatCategory.categoryNumber, 10_000L + index)
|
||||
})
|
||||
}
|
||||
val stations = DataTableState(
|
||||
StationColumns.all(
|
||||
tropoOf = { "12 dB" },
|
||||
priorityScoreOf = { 500.0 },
|
||||
formatBand = { _, _, _ -> "" },
|
||||
activeBands = EnumSet.of(Band.B_144, Band.B_432),
|
||||
),
|
||||
RowKeys.byValue(StationColumns::rowKey),
|
||||
).apply { replaceRows(members) }
|
||||
val priority = TopPriorityState(
|
||||
topStations = { listOf(members[4] to 767.0, members[3] to 740.0) },
|
||||
onSelect = {},
|
||||
).apply { refresh() }
|
||||
val selected = SelectedStationState(
|
||||
propagateNotQrv = {},
|
||||
createSked = { _, _, _, _ -> true },
|
||||
detectBands = { "Bands: 144/432 MHz" },
|
||||
).apply { select(selectedMember) }
|
||||
val selectedMessages = DataTableState(
|
||||
SelectedStationMessageColumns.all(),
|
||||
RowKeys.byReference<ChatMessage>(),
|
||||
)
|
||||
|
||||
val scene = ImageComposeScene(
|
||||
width = 1366,
|
||||
height = scenario.height,
|
||||
density = Density(scale),
|
||||
) {
|
||||
Kst4ContestTheme(darkMode = darkMode, baseFontSizeSp = 12f) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
) {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Column(
|
||||
Modifier
|
||||
.width(scenario.width.dp)
|
||||
.fillMaxHeight()
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(4.dp),
|
||||
) {
|
||||
StationFilterBar(
|
||||
filters = StationFilterState(),
|
||||
activeBands = EnumSet.of(Band.B_144, Band.B_432),
|
||||
reachability = ReachabilityState(EnumSet.of(Band.B_144, Band.B_432)),
|
||||
onChanged = {},
|
||||
)
|
||||
DataTableView(
|
||||
state = stations,
|
||||
modifier = Modifier.weight(0.45f),
|
||||
emphasizeCell = { member, column ->
|
||||
column.id == "callsign" && newJoined.isRecent(member)
|
||||
},
|
||||
)
|
||||
TopPriorityBar(
|
||||
state = priority,
|
||||
newJoined = newJoined,
|
||||
onShowMore = {},
|
||||
)
|
||||
SelectedStationPanel(
|
||||
state = selected,
|
||||
messages = selectedMessages,
|
||||
activeBands = EnumSet.of(Band.B_144, Band.B_432),
|
||||
contentSplitter = SplitterState(2, doubleArrayOf(0.48)) {},
|
||||
openInBrowser = Consumer {},
|
||||
onShowOnMap = {},
|
||||
onShowPathInAirScout = {},
|
||||
modifier = Modifier.weight(0.55f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return try {
|
||||
scene.render().encodeToData()!!.bytes
|
||||
} finally {
|
||||
scene.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun member(callSign: String, category: Int) = ChatMember().apply {
|
||||
this.callSign = callSign
|
||||
chatCategory = ChatCategory(category)
|
||||
qra = "JO31AA"
|
||||
name = "Contest station"
|
||||
}
|
||||
|
||||
private data class Scenario(val name: String, val width: Int, val height: Int)
|
||||
}
|
||||
@@ -124,7 +124,9 @@ class TimelineStateTest {
|
||||
@Test
|
||||
fun `the lanes stack downwards in even steps`() {
|
||||
assertEquals(2f, TimelineGeometry.laneY(0))
|
||||
assertEquals(22f, TimelineGeometry.laneY(1))
|
||||
assertEquals(18f, TimelineGeometry.laneY(1))
|
||||
assertEquals(34f, TimelineGeometry.laneY(2))
|
||||
assertEquals(50f, TimelineGeometry.laneY(3))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -151,14 +153,45 @@ class TimelineStateTest {
|
||||
)
|
||||
}
|
||||
|
||||
/** A third lane would land on the axis, so it shares the last one instead. */
|
||||
/** Unknown producer values stay visible in the nearest defined lane. */
|
||||
@Test
|
||||
fun `a lane beyond the available ones folds onto the last`() {
|
||||
assertEquals(TimelineGeometry.laneY(1), TimelineGeometry.laneY(2))
|
||||
assertEquals(TimelineGeometry.laneY(1), TimelineGeometry.laneY(7))
|
||||
assertEquals(TimelineGeometry.laneY(3), TimelineGeometry.laneY(7))
|
||||
assertEquals(TimelineGeometry.laneY(0), TimelineGeometry.laneY(-1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `lane definitions expose the producer semantics in display order`() {
|
||||
assertEquals(
|
||||
listOf(
|
||||
TimelineLane.IN_BEAM_1,
|
||||
TimelineLane.IN_BEAM_2,
|
||||
TimelineLane.PRIORITY_1,
|
||||
TimelineLane.PRIORITY_2,
|
||||
),
|
||||
TimelineLane.entries,
|
||||
)
|
||||
assertEquals(TimelineLane.PRIORITY_1, TimelineLane.forIndex(2))
|
||||
assertEquals(TimelineLane.PRIORITY_2, TimelineLane.forIndex(99))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a wide timeline gets five minute ticks over the full preview`() {
|
||||
val ticks = TimelineGeometry.minuteTicks(axisWidth = 600f, minimumLabelSpacing = 30f)
|
||||
|
||||
assertEquals(listOf(0, 5, 10, 15, 20, 25, 30), ticks.map { it.minutes })
|
||||
assertEquals(0f, ticks.first().x, 0.01f)
|
||||
assertEquals(600f, ticks.last().x, 0.01f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tick interval grows when labels would overlap`() {
|
||||
val ticks = TimelineGeometry.minuteTicks(axisWidth = 100f, minimumLabelSpacing = 30f)
|
||||
|
||||
assertEquals(listOf(0, 10, 20, 30), ticks.map { it.minutes })
|
||||
assertTrue(ticks.zipWithNext().all { (left, right) -> right.x - left.x >= 30f })
|
||||
}
|
||||
|
||||
// ---- the labels --------------------------------------------------------
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.runComposeUiTest
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
@OptIn(ExperimentalTestApi::class)
|
||||
class TimelineViewScreenTest {
|
||||
|
||||
@Test
|
||||
fun `timeline shows all producer lanes and relative minute ticks`() = runComposeUiTest {
|
||||
setContent {
|
||||
MaterialTheme {
|
||||
TimelineView(
|
||||
state = TimelineState(),
|
||||
onCandidateClicked = {},
|
||||
modifier = Modifier.width(700.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
listOf("In beam 1", "In beam 2", "Priority 1", "Priority 2", "Skeds").forEach {
|
||||
onNodeWithText(it).assertExists()
|
||||
}
|
||||
listOf("0 min", "+5 min", "+10 min", "+15 min", "+20 min", "+25 min", "+30 min").forEach {
|
||||
onNodeWithText(it).assertExists()
|
||||
}
|
||||
}
|
||||
}
|
||||
+51
-1
@@ -5,6 +5,7 @@ import androidx.compose.ui.ImageComposeScene
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.Density
|
||||
import kst4contest.view.map.MapCallsignRawSnapshot
|
||||
import kst4contest.view.map.MapViewport
|
||||
import kst4contest.view.map.TileFetcher
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
@@ -15,6 +16,7 @@ import java.io.IOException
|
||||
import java.nio.file.Files
|
||||
import javax.imageio.ImageIO
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* What the map actually puts on the canvas.
|
||||
@@ -44,6 +46,8 @@ class ComposeStationMapDrawingTest {
|
||||
|
||||
private val darkMapBackground = 0x23282D
|
||||
|
||||
private val workedGridBorder = 0x000000
|
||||
|
||||
/*
|
||||
* An empty cache and a source that always fails, so that "tiles never arrive" holds by
|
||||
* construction. The default fetcher read the user's disk cache and the real tile
|
||||
@@ -64,6 +68,8 @@ class ComposeStationMapDrawingTest {
|
||||
zoom: Float,
|
||||
markers: List<MapCallsignRawSnapshot> = listOf(station("DN9APW", "JO50JP", 51.0, 10.0)),
|
||||
ownLocator6: String = "",
|
||||
workedGrossFields: Set<String> = emptySet(),
|
||||
darkMode: Boolean = true,
|
||||
): BufferedImage {
|
||||
val scene = ImageComposeScene(canvasWidth, canvasHeight, Density(1f)) {
|
||||
ComposeStationMap(
|
||||
@@ -77,7 +83,8 @@ class ComposeStationMapDrawingTest {
|
||||
antennaBeamWidthDeg = 0.0,
|
||||
maxQrbKm = 0.0,
|
||||
onMarkerClicked = null,
|
||||
darkMode = true,
|
||||
workedGrossFields = workedGrossFields,
|
||||
darkMode = darkMode,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
@@ -229,6 +236,49 @@ class ComposeStationMapDrawingTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `worked gross fields use a visible black border in both themes`() {
|
||||
val dark = render(6f, markers = emptyList(), workedGrossFields = setOf("JO51"))
|
||||
val light = render(6f, markers = emptyList(), workedGrossFields = setOf("JO51"), darkMode = false)
|
||||
|
||||
assertTrue(countColour(dark, workedGridBorder) > 0) {
|
||||
"the Evening map has no black Worked-grid border"
|
||||
}
|
||||
assertTrue(countColour(light, workedGridBorder) > 0) {
|
||||
"the Day map has no black Worked-grid border"
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `worked gross field interior is covered by thirty percent black`() {
|
||||
val viewport = MapViewport(10.0, 51.0, 6.0, canvasWidth.toDouble(), canvasHeight.toDouble())
|
||||
val cell = visibleWorkedGrossFieldCells(viewport, setOf("JO51")).single()
|
||||
val x = viewport.screenX(cell.westLon() + (cell.eastLon() - cell.westLon()) * 0.25).toInt()
|
||||
val y = viewport.screenY(cell.northLat() + (cell.southLat() - cell.northLat()) * 0.25).toInt()
|
||||
|
||||
for (darkMode in listOf(false, true)) {
|
||||
val withoutWorked = render(6f, markers = emptyList(), darkMode = darkMode)
|
||||
val withWorked = render(
|
||||
6f,
|
||||
markers = emptyList(),
|
||||
workedGrossFields = setOf("JO51"),
|
||||
darkMode = darkMode,
|
||||
)
|
||||
val before = withoutWorked.getRGB(x, y)
|
||||
val after = withWorked.getRGB(x, y)
|
||||
|
||||
for (shift in listOf(16, 8, 0)) {
|
||||
val beforeChannel = (before shr shift) and 0xFF
|
||||
val afterChannel = (after shr shift) and 0xFF
|
||||
val expected = (beforeChannel * 0.70).roundToInt()
|
||||
assertTrue(abs(afterChannel - expected) <= 2) {
|
||||
"Worked fill in darkMode=$darkMode changed channel $shift from " +
|
||||
"$beforeChannel to $afterChannel; expected $expected for 30% black"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every grid line has to be drawn once.
|
||||
*
|
||||
|
||||
+126
-11
@@ -1,15 +1,18 @@
|
||||
package kst4contest.view.compose.map
|
||||
package kst4contest.view.compose.map
|
||||
|
||||
import androidx.compose.ui.test.ComposeUiTest
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.assertIsOff
|
||||
import androidx.compose.ui.test.assertIsOn
|
||||
import androidx.compose.ui.test.assertCountEquals
|
||||
import androidx.compose.ui.test.click
|
||||
import androidx.compose.ui.test.isToggleable
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performMouseInput
|
||||
import androidx.compose.ui.test.MouseButton
|
||||
import androidx.compose.ui.test.runComposeUiTest
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import kst4contest.locatorUtils.Location
|
||||
@@ -32,6 +35,10 @@ import kst4contest.view.compose.SelectedStationState
|
||||
import kst4contest.view.compose.SplitterState
|
||||
import kst4contest.view.compose.StationColumns
|
||||
import kst4contest.view.compose.StationFilterState
|
||||
import kst4contest.view.compose.StationSelectionSource
|
||||
import kst4contest.view.compose.StationSelectionState
|
||||
import kst4contest.view.compose.STATION_CONTEXT_MENU_TAG
|
||||
import kst4contest.view.compose.STATION_CONTEXT_TARGET_CHOOSER_TAG
|
||||
import kst4contest.view.compose.ThreadStatusButtons
|
||||
import kst4contest.view.compose.TimelineState
|
||||
import kst4contest.view.compose.TopPriorityState
|
||||
@@ -41,6 +48,7 @@ import kst4contest.view.map.PathAnalysisResult
|
||||
import kst4contest.view.map.TileFetcher
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertSame
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
@@ -136,6 +144,7 @@ class StationMapWindowScreenTest {
|
||||
}
|
||||
|
||||
private fun windowState(prefs: ChatPreferences): MainWindowState {
|
||||
val stationSelection = StationSelectionState()
|
||||
val stations = DataTableState(
|
||||
StationColumns.all(
|
||||
tropoOf = { "" },
|
||||
@@ -143,6 +152,8 @@ class StationMapWindowScreenTest {
|
||||
formatBand = { _, _, _ -> "" },
|
||||
),
|
||||
RowKeys.byValue(StationColumns::rowKey),
|
||||
selectionModel = stationSelection,
|
||||
keepSelectionVisible = true,
|
||||
)
|
||||
val menu = MainMenuState()
|
||||
return MainWindowState(
|
||||
@@ -158,6 +169,7 @@ class StationMapWindowScreenTest {
|
||||
selectedStation = SelectedStationState(
|
||||
propagateNotQrv = {},
|
||||
createSked = { _, _, _, _ -> false },
|
||||
stationSelection = stationSelection,
|
||||
),
|
||||
topPriority = TopPriorityState(topStations = { emptyList() }, onSelect = {}),
|
||||
timeline = TimelineState(),
|
||||
@@ -185,6 +197,7 @@ class StationMapWindowScreenTest {
|
||||
mapState: MapState,
|
||||
pathAnalysis: MapPathAnalysis? = null,
|
||||
onPathAnalysisAsked: () -> Unit = {},
|
||||
onAlwaysOnTopChanged: (Boolean) -> Unit = {},
|
||||
) {
|
||||
state.stations.replaceRows(members)
|
||||
setContent {
|
||||
@@ -195,6 +208,7 @@ class StationMapWindowScreenTest {
|
||||
chatMembers = { members },
|
||||
mapState = mapState,
|
||||
pathAnalysis = { onPathAnalysisAsked(); pathAnalysis },
|
||||
onAlwaysOnTopChanged = onAlwaysOnTopChanged,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -217,7 +231,7 @@ class StationMapWindowScreenTest {
|
||||
val dl1abc = station("DL1ABC", 2)
|
||||
state.selectedStation.select(dl1abc)
|
||||
val selectorCalls = mutableListOf<ChatMember?>()
|
||||
state.stationSelector = { selectorCalls += it }
|
||||
state.stationSelector = { member, _ -> selectorCalls += member }
|
||||
val mapState = mapLookingAt("JO50JP", zoom = 15f)
|
||||
|
||||
show(state, listOf(dl1abc), mapState)
|
||||
@@ -266,7 +280,7 @@ class StationMapWindowScreenTest {
|
||||
state.selectedStation.select(inSecond)
|
||||
state.chatInput.value = TextFieldValue("half typed")
|
||||
val selectorCalls = mutableListOf<ChatMember?>()
|
||||
state.stationSelector = { selectorCalls += it }
|
||||
state.stationSelector = { member, _ -> selectorCalls += member }
|
||||
val mapState = mapLookingAt("JO50JP", zoom = 12f)
|
||||
|
||||
show(state, listOf(inMain, inSecond), mapState)
|
||||
@@ -281,19 +295,78 @@ class StationMapWindowScreenTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aMarkerClickWithNothingSelectedTakesTheFirstVariant() = runComposeUiTest {
|
||||
fun aMarkerClickWithNothingSelectedTakesTheMainCategory() = runComposeUiTest {
|
||||
val state = windowState(prefs())
|
||||
val inMain = station("DL1ABC", 2)
|
||||
val inSecond = station("DL1ABC", 3)
|
||||
val selectorCalls = mutableListOf<ChatMember?>()
|
||||
state.stationSelector = { selectorCalls += it }
|
||||
val scrollRevision = state.stations.scrollRequestRevision
|
||||
|
||||
show(state, listOf(inMain, inSecond), mapLookingAt("JO50JP", zoom = 12f))
|
||||
|
||||
onNodeWithTag(StationMapWindow.MAP_CANVAS_TAG).performClick()
|
||||
waitForIdle()
|
||||
|
||||
assertSame(inMain, selectorCalls.single())
|
||||
assertSame(inMain, state.stations.selectedRow)
|
||||
assertEquals(StationSelectionSource.MAP, state.stationSelection.lastSource)
|
||||
assertTrue(state.stations.scrollRequestRevision > scrollRevision)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aMarkerContextClickSelectsItsOnlyTargetAndOpensTheSharedMenu() = runComposeUiTest {
|
||||
val state = windowState(prefs())
|
||||
val member = station("DL1ABC", 2)
|
||||
val scrollRevision = state.stations.scrollRequestRevision
|
||||
|
||||
show(state, listOf(member), mapLookingAt("JO50JP", zoom = 12f))
|
||||
|
||||
onNodeWithTag(StationMapWindow.MAP_CANVAS_TAG).performMouseInput {
|
||||
click(button = MouseButton.Secondary)
|
||||
}
|
||||
waitForIdle()
|
||||
|
||||
onNodeWithTag(STATION_CONTEXT_MENU_TAG).assertExists()
|
||||
assertSame(member, state.stations.selectedRow)
|
||||
assertEquals(StationSelectionSource.MAP_CONTEXT_MENU, state.stationSelection.lastSource)
|
||||
assertTrue(state.stations.scrollRequestRevision > scrollRevision)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aMarkerContextClickRequiresCategoryChoiceForAmbiguousTargets() = runComposeUiTest {
|
||||
val state = windowState(prefs())
|
||||
val inMain = station("DL1ABC", 2)
|
||||
val inSecond = station("DL1ABC", 3)
|
||||
|
||||
show(state, listOf(inMain, inSecond), mapLookingAt("JO50JP", zoom = 12f))
|
||||
|
||||
onNodeWithTag(StationMapWindow.MAP_CANVAS_TAG).performMouseInput {
|
||||
click(button = MouseButton.Secondary)
|
||||
}
|
||||
waitForIdle()
|
||||
|
||||
onNodeWithTag(STATION_CONTEXT_TARGET_CHOOSER_TAG).assertExists()
|
||||
assertEquals(null, state.stations.selectedRow)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun workedGrossFieldsAreReadOnlyWhileGridColouringIsEnabled() = runComposeUiTest {
|
||||
val state = windowState(prefs())
|
||||
var reads = 0
|
||||
state.workedGrossFieldsProvider = {
|
||||
reads++
|
||||
setOf("JO50")
|
||||
}
|
||||
|
||||
show(state, emptyList(), mapLookingAt("JO50JP", zoom = 6f))
|
||||
assertEquals(0, reads)
|
||||
|
||||
runOnIdle { state.stationFilter.gridColouring = true }
|
||||
waitForIdle()
|
||||
assertTrue(reads > 0, "enabling Grid color did not request the persistent worked fields")
|
||||
|
||||
val readsWhileEnabled = reads
|
||||
runOnIdle { state.stationFilter.gridColouring = false }
|
||||
waitForIdle()
|
||||
assertEquals(readsWhileEnabled, reads, "disabled Grid color still read worked fields")
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -377,13 +450,55 @@ class StationMapWindowScreenTest {
|
||||
assertEquals(true, prefs.isGUIstationMapPathAnalysisVisible)
|
||||
|
||||
// The grouping checkbox sits beside its label; the label itself is not clickable.
|
||||
onNodeWithText("Group nearby stations").assertExists()
|
||||
onNode(isToggleable()).assertIsOff().performClick()
|
||||
onNodeWithText("Grouping").assertExists()
|
||||
onNodeWithTag(StationMapWindow.GROUPING_TAG).assertIsOff().performClick()
|
||||
waitForIdle()
|
||||
onNode(isToggleable()).assertIsOn()
|
||||
onNodeWithTag(StationMapWindow.GROUPING_TAG).assertIsOn()
|
||||
assertEquals(true, prefs.isGUIstationMapClusteringEnabled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun alwaysOnTopWritesThePreferenceAndAppliesBothStatesImmediately() = runComposeUiTest {
|
||||
val prefs = prefs()
|
||||
val state = windowState(prefs)
|
||||
val applied = mutableListOf<Boolean>()
|
||||
|
||||
show(state, emptyList(), MapState(), onAlwaysOnTopChanged = applied::add)
|
||||
|
||||
onNodeWithText("Always on top").assertExists()
|
||||
onNodeWithTag(StationMapWindow.ALWAYS_ON_TOP_TAG).assertIsOff().performClick()
|
||||
waitForIdle()
|
||||
assertEquals(true, prefs.isGUIstationMapAlwaysOnTop)
|
||||
assertEquals(listOf(true), applied)
|
||||
|
||||
onNodeWithTag(StationMapWindow.ALWAYS_ON_TOP_TAG).assertIsOn().performClick()
|
||||
waitForIdle()
|
||||
assertEquals(false, prefs.isGUIstationMapAlwaysOnTop)
|
||||
assertEquals(listOf(true, false), applied)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resetFiltersUsesTheSharedActionAndDoesNotResetTheView() = runComposeUiTest {
|
||||
val state = windowState(prefs())
|
||||
state.stationFilter.minQrbKm = 300.0
|
||||
state.stationFilter.minQrbEnabled = true
|
||||
var resets = 0
|
||||
state.stationFiltersChanged = { resets++ }
|
||||
val mapState = mapLookingAt("JO50JP", zoom = 14f)
|
||||
val beforeLon = mapState.centerLon
|
||||
val beforeLat = mapState.centerLat
|
||||
|
||||
show(state, emptyList(), mapState)
|
||||
onNodeWithText("Reset filters").performClick()
|
||||
waitForIdle()
|
||||
|
||||
assertEquals(false, state.stationFilter.minQrbEnabled)
|
||||
assertEquals(1, resets)
|
||||
assertEquals(beforeLon, mapState.centerLon)
|
||||
assertEquals(beforeLat, mapState.centerLat)
|
||||
assertEquals(14f, mapState.zoom)
|
||||
}
|
||||
|
||||
/**
|
||||
* The map shows the stations the list shows (#21). The Compose map drew every station
|
||||
* whatever the filter bar said, although its status line reported a filtered view; the
|
||||
@@ -396,7 +511,7 @@ class StationMapWindowScreenTest {
|
||||
val near = station("DL1NEAR", 2, locator = "JO50JP").apply { qrb = 150.0 }
|
||||
val far = station("DL2FAR", 2, locator = "JO62QM").apply { qrb = 600.0 }
|
||||
val selectorCalls = mutableListOf<ChatMember?>()
|
||||
state.stationSelector = { selectorCalls += it }
|
||||
state.stationSelector = { member, _ -> selectorCalls += member }
|
||||
|
||||
show(state, listOf(near, far), mapLookingAt("JO50JP", zoom = 12f))
|
||||
onNodeWithText("Showing 2 visible stations").assertExists()
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package kst4contest.view.compose.map
|
||||
|
||||
import kst4contest.view.map.MapViewport
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class WorkedGrossFieldOverlayTest {
|
||||
|
||||
@Test
|
||||
fun `worked cells are planned from persistent gross fields without station markers`() {
|
||||
val viewport = MapViewport(10.0, 51.0, 6.0, 900.0, 600.0)
|
||||
|
||||
val cells = visibleWorkedGrossFieldCells(viewport, setOf("JO51", "AA00"))
|
||||
|
||||
assertEquals(listOf("JO51"), cells.map { it.locatorLabel() })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `disabled grid colour produces no worked overlay cells`() {
|
||||
val viewport = MapViewport(10.0, 51.0, 6.0, 900.0, 600.0)
|
||||
|
||||
assertTrue(visibleWorkedGrossFieldCells(viewport, emptySet()).isEmpty())
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,9 @@ class DraftedSettingsTableTest {
|
||||
f.station.stn_pathAnalysisDxHighestPointInSquare = v
|
||||
},
|
||||
flag("station rotator", { it.station.stn_pstRotatorEnabled }) { f, v -> f.station.stn_pstRotatorEnabled = v },
|
||||
flag("station QTF filter heading sync", { it.station.stn_useQtfFilterDirectionAsAntennaHeading }) { f, v ->
|
||||
f.station.stn_useQtfFilterDirectionAsAntennaHeading = v
|
||||
},
|
||||
Case("station rotator host", { it.station.stn_pstRotatorHost += "x" }, { it.prefs.getStn_pstRotatorHost() }),
|
||||
Case("station rotator port", { it.station.stn_pstRotatorPort += 1 }, { it.prefs.getStn_pstRotatorPort() }),
|
||||
Case("station QTF", { it.station.stn_qtfDefault += 1.5 }, { it.prefs.getStn_qtfDefault() }),
|
||||
|
||||
@@ -36,9 +36,12 @@ import kst4contest.view.compose.Kst4ContestTheme
|
||||
* control further down can be scrolled to and clicked.
|
||||
*/
|
||||
@OptIn(ExperimentalTestApi::class)
|
||||
internal fun ComposeUiTest.showTab(content: @Composable () -> Unit) {
|
||||
internal fun ComposeUiTest.showTab(
|
||||
darkMode: Boolean = false,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
setContent {
|
||||
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
|
||||
Kst4ContestTheme(darkMode = darkMode, baseFontSizeSp = 12f) {
|
||||
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) {
|
||||
content()
|
||||
}
|
||||
|
||||
@@ -77,7 +77,9 @@ class StationTabScreenTest {
|
||||
showTab { StationTab(s.state) }
|
||||
|
||||
listOf("Own station", "Chat category", "ON4KST server", "Active bands", "Defaults",
|
||||
"PSTRotator", "Path analysis defaults").forEach { onNodeWithText(it).assertExists() }
|
||||
"Manual antenna heading", "PSTRotator", "Path analysis defaults").forEach {
|
||||
onNodeWithText(it).assertExists()
|
||||
}
|
||||
|
||||
fieldBeside("Callsign").assertTextEquals("DN9APW")
|
||||
fieldBeside("Server port").assertTextEquals("23001")
|
||||
@@ -85,6 +87,36 @@ class StationTabScreenTest {
|
||||
checkBeside("10 GHz").assertIsOff()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun qtfFilterHeadingOptionFollowsTheDraftedPrimaryRotatorState() = runComposeUiTest {
|
||||
val s = Session(preferences())
|
||||
showTab { StationTab(s.state) }
|
||||
|
||||
val label = "Use QTF filter direction as antenna heading"
|
||||
checkBeside(label).assertIsEnabled().assertIsOn()
|
||||
|
||||
toggle("Rotator sync enabled")
|
||||
|
||||
checkBeside(label).assertIsNotEnabled().assertIsOn()
|
||||
toggle("Rotator sync enabled")
|
||||
checkBeside(label).assertIsEnabled().assertIsOn()
|
||||
runOnIdle { s.draft.apply() }
|
||||
assertFalse(s.prefs.isStn_pstRotatorEnabled())
|
||||
assertTrue(s.prefs.isStn_useQtfFilterDirectionAsAntennaHeading())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun manualAntennaHeadingPanelRendersInEveningTheme() = runComposeUiTest {
|
||||
val s = Session(preferences())
|
||||
|
||||
showTab(darkMode = true) { StationTab(s.state) }
|
||||
|
||||
onNodeWithText("Manual antenna heading").assertExists()
|
||||
checkBeside("Use QTF filter direction as antenna heading")
|
||||
.assertIsEnabled()
|
||||
.assertIsOn()
|
||||
}
|
||||
|
||||
/* The credentials write through; every other field waits for Apply. */
|
||||
@Test
|
||||
fun editingTheTextFieldsStagesAllButTheCredentials() = runComposeUiTest {
|
||||
@@ -125,6 +157,18 @@ class StationTabScreenTest {
|
||||
assertEquals(135.5, s.prefs.stn_qtfDefault, 1e-9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun invalidQtfDefaultsAreNotStaged() = runComposeUiTest {
|
||||
val s = Session(preferences())
|
||||
showTab { StationTab(s.state) }
|
||||
|
||||
listOf("360,1", "-0,1", "NaN", "Infinity").forEach { invalid ->
|
||||
type("QTF", invalid)
|
||||
runOnIdle { s.draft.apply() }
|
||||
assertEquals(135.0, s.prefs.stn_qtfDefault, 1e-9, "accepted $invalid")
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* The draft is Compose state, so a staged number comes straight back to its field. The
|
||||
* field must keep the operator's text rather than redraw the number: "1" shown as "1.0"
|
||||
|
||||
@@ -12,7 +12,7 @@ import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
import java.nio.file.Path
|
||||
|
||||
class StationTabStateTest(@TempDir private val tempDir: Path) {
|
||||
class StationTabStateTest(@param:TempDir private val tempDir: Path) {
|
||||
|
||||
private inner class Session {
|
||||
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
|
||||
@@ -149,6 +149,76 @@ class StationTabStateTest(@TempDir private val tempDir: Path) {
|
||||
assertEquals(23002, s.prefs.getStn_on4kstServersPort())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `QTF filter heading synchronization waits in the draft until apply`() {
|
||||
val s = Session()
|
||||
|
||||
s.state.stn_useQtfFilterDirectionAsAntennaHeading = false
|
||||
|
||||
assertFalse(s.state.stn_useQtfFilterDirectionAsAntennaHeading)
|
||||
assertTrue(s.prefs.isStn_useQtfFilterDirectionAsAntennaHeading())
|
||||
|
||||
s.draft.apply()
|
||||
|
||||
assertFalse(s.prefs.isStn_useQtfFilterDirectionAsAntennaHeading())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `enabling primary rotator preserves QTF filter heading preference in the draft`() {
|
||||
val s = Session()
|
||||
|
||||
s.state.stn_pstRotatorEnabled = true
|
||||
|
||||
assertTrue(s.state.stn_useQtfFilterDirectionAsAntennaHeading)
|
||||
assertTrue(s.prefs.isStn_useQtfFilterDirectionAsAntennaHeading(), "an edit took effect before Apply")
|
||||
|
||||
s.draft.apply()
|
||||
|
||||
assertTrue(s.prefs.isStn_pstRotatorEnabled())
|
||||
assertTrue(s.prefs.isStn_useQtfFilterDirectionAsAntennaHeading())
|
||||
assertFalse(s.prefs.isQtfFilterDirectionAsAntennaHeadingEnabled())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `disabling primary rotator restores QTF filter heading synchronization`() {
|
||||
val s = Session()
|
||||
|
||||
s.state.stn_pstRotatorEnabled = true
|
||||
s.state.stn_pstRotatorEnabled = false
|
||||
|
||||
assertTrue(s.state.stn_useQtfFilterDirectionAsAntennaHeading)
|
||||
|
||||
s.draft.apply()
|
||||
|
||||
assertTrue(s.prefs.isQtfFilterDirectionAsAntennaHeadingEnabled())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `explicit QTF heading opt out survives primary rotator changes`() {
|
||||
val s = Session()
|
||||
s.state.stn_useQtfFilterDirectionAsAntennaHeading = false
|
||||
s.state.stn_pstRotatorEnabled = true
|
||||
s.state.stn_pstRotatorEnabled = false
|
||||
|
||||
assertFalse(s.state.stn_useQtfFilterDirectionAsAntennaHeading)
|
||||
|
||||
s.draft.apply()
|
||||
|
||||
assertFalse(s.prefs.isQtfFilterDirectionAsAntennaHeadingEnabled())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rotator 2 does not disable QTF filter heading synchronization`() {
|
||||
val s = Session()
|
||||
|
||||
s.state.stn_useQtfFilterDirectionAsAntennaHeading = true
|
||||
s.state.stn_pstRotator2Enabled = true
|
||||
s.draft.apply()
|
||||
|
||||
assertTrue(s.prefs.isStn_useQtfFilterDirectionAsAntennaHeading())
|
||||
assertTrue(s.prefs.isQtfFilterDirectionAsAntennaHeadingEnabled())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a discarded station setting leaves the preferences alone`() {
|
||||
val s = Session()
|
||||
|
||||
@@ -93,12 +93,16 @@ class StylesheetExportTest {
|
||||
|
||||
@Test
|
||||
fun anUnwritablePlaceIsReportedRatherThanThrown() {
|
||||
val target = File("/proc/kst4contest-cannot-write-here/day.css")
|
||||
val blockingFile = Files.createTempFile("kst4contest-export-blocked-parent", ".tmp").toFile()
|
||||
try {
|
||||
val target = File(blockingFile, "day.css")
|
||||
val outcome = exportShippedStylesheet(target, darkMode = false)
|
||||
|
||||
val outcome = exportShippedStylesheet(target, darkMode = false)
|
||||
|
||||
assertTrue(outcome is StylesheetExportOutcome.Failed, "outcome was $outcome")
|
||||
assertNotNull((outcome as StylesheetExportOutcome.Failed).reason)
|
||||
assertTrue(outcome is StylesheetExportOutcome.Failed, "outcome was $outcome")
|
||||
assertNotNull((outcome as StylesheetExportOutcome.Failed).reason)
|
||||
} finally {
|
||||
blockingFile.delete()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
Reference in new issue
Block a user