diff --git a/.github/workflows/pr-compile-check.yml b/.github/workflows/pr-compile-check.yml index d3ab9b94..3d0f7289 100644 --- a/.github/workflows/pr-compile-check.yml +++ b/.github/workflows/pr-compile-check.yml @@ -27,5 +27,96 @@ jobs: - name: Ensure gradlew is executable run: chmod +x gradlew + # classes + testClasses rather than compileJava + compileTestJava. The old pair did + # cover Kotlin -- checked with a deliberately broken Kotlin test file, and also in + # --dry-run: the Kotlin plugin makes compileTestJava depend on compileTestKotlin, + # because Java has to see the Kotlin output. But that is the plugin's wiring and not + # this job's statement of intent, and it covered no resources. These two aggregates + # say "compile everything in both modules" and do not depend on knowing that. - name: Compile - run: ./gradlew -S compileJava compileTestJava + run: ./gradlew -S classes testClasses + + test: + name: Test (Java 21, headless) + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4.1.7 + + - name: Set up Java 21 + uses: actions/setup-java@v4.1.0 + with: + distribution: temurin + java-version: "21" + + - name: Ensure gradlew is executable + run: chmod +x gradlew + + # Skiko's native library links against libGL, so it has to be loadable even though + # nothing here renders through it: the drawing tests use Skia's software backend and + # runComposeUiTest never opens a window. Without this the whole Compose half of the + # suite dies with "libskiko-linux-x64.so: libGL.so.1: cannot open shared object + # file" -- which reads like a missing display and is not one. Found by running this + # job under act, where the container has no libGL; GitHub's own runner image happens + # to ship it, so the step is what makes the job reproducible off GitHub. + - name: Install the OpenGL client library skiko links against + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends libgl1 + + # No Xvfb, deliberately. Everything that pins behaviour runs without a display: + # runComposeUiTest reaches the semantics tree headless (measured before this job was + # written), and the ImageComposeScene drawing tests render in software. The handful + # of tests that open a real window guard themselves with assumeFalse(isHeadless) and + # skip here; the summary below prints the skip count, so that is visible rather than + # assumed. + # + # Software GL under Xvfb was considered and rejected: a missing Skia GL context is + # the hardest failure in this codebase to read, and importing that class of flake + # into CI would cost more than the few window tests are worth here. + - name: Test + run: ./gradlew -S test + + - name: Summarise the test results + if: always() + run: | + set -euo pipefail + shopt -s nullglob + files=(*/build/test-results/test/TEST-*.xml) + if [ ${#files[@]} -eq 0 ]; then + echo "No test result XML was produced -- the test task did not run." >&2 + exit 1 + fi + # Counted from the XML and not from Gradle's console output: this project's own + # rule is never to infer "all tests passed" from an exit code. + summary=$(grep -h "> "$GITHUB_STEP_SUMMARY" + if [ "$failures" != "0" ] || [ "$errors" != "0" ]; then + echo "The result XML reports failures or errors." >&2 + exit 1 + fi + + - name: Keep the test reports on a failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: test-results + path: | + */build/test-results/test/ + */build/reports/tests/test/ + if-no-files-found: ignore + retention-days: 7 diff --git a/app-desktop/build.gradle.kts b/app-desktop/build.gradle.kts index efec74aa..6a6054da 100644 --- a/app-desktop/build.gradle.kts +++ b/app-desktop/build.gradle.kts @@ -20,6 +20,21 @@ dependencies { implementation(compose.material3) implementation(libs.sqlite.jdbc) implementation(libs.jlayer) + /* + * The Compose UI test harness, used through runComposeUiTest { } from ordinary Jupiter + * tests. Deliberately NOT ui-test-junit4: that one brings the vintage engine, and two + * engines on one classpath is how half a suite silently stops being run. Checked after + * adding this: the only engine resolved is junit-jupiter-engine. JUnit 4 classes are on + * the test classpath, but they were before this too — something else pulls them + * transitively — and with no vintage engine nothing executes them. + * + * The accessor is marked experimental by the Compose Gradle plugin, so the opt-in sits + * here — one place, for one dependency. What it buys is the only mechanism that can + * answer "did the value reach the screen": the state-level tests and the two settings + * coverage nets check that a setter is called, which is a different question. + */ + @OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class) + testImplementation(compose.uiTest) testImplementation(libs.junit.jupiter) testImplementation(libs.mockito.core) testImplementation(libs.mockito.junit.jupiter) diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/MainMenuBar.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/MainMenuBar.kt index a2235431..1ff91711 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/MainMenuBar.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/MainMenuBar.kt @@ -1,10 +1,30 @@ package kst4contest.view.compose +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import kst4contest.controller.On4KstConnectionState -import kst4contest.utils.PlatformUtils +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.window.FrameWindowScope import androidx.compose.ui.window.MenuBar +import kst4contest.controller.On4KstConnectionState +import kst4contest.utils.PlatformUtils /** * What the menu bar does when something is picked. @@ -37,16 +57,15 @@ interface MainMenuActions { } /** - * The menu bar, declared once and called by every window. + * The menu bar in the system's own bar — macOS only. * - * Compose has no shared bar across windows: `MenuBar` lives in the scope of a `Window` - * and macOS shows it in the system menu bar while that window is active. The JavaFX - * side arranged the same thing the other way round — `installSharedSystemMenuBar` - * mirrored the main window's menus into an invisible bar in every other window — and - * this is the Compose shape of it: one function each window can call. + * Compose's `MenuBar` is a `javax.swing.JMenuBar` set on the window, so Swing draws it and + * no Compose theme reaches it. On macOS that is what one wants: the bar belongs at the top + * of the screen, drawn by the system. Everywhere else it meant an unthemed Metal menu + * sitting above a themed window, which is why [Kst4ContestMenuRow] exists. * - * The labels of the two window toggles are asked for at the moment the bar is built, - * because a Compose window can be closed from its own frame without telling the menu. + * Both renderers read [mainMenuModel]. Declaring the menus twice is how one of them quietly + * loses an item the other gained. */ @Composable fun FrameWindowScope.Kst4ContestMenuBar( @@ -56,73 +75,29 @@ fun FrameWindowScope.Kst4ContestMenuBar( monitorWindowOpen: Boolean, /** * The connection state, for the read-only macOS menu. On macOS the status bar hides its - * badge because JavaFX moved the indicator into the system menu bar, so without this - * menu a macOS operator would have no connection indicator at all. + * badge because the indicator lives in the system menu bar, so without this menu a + * macOS operator would have no connection indicator at all. */ connectionState: On4KstConnectionState? = null, connectionDetail: String? = null, ) { MenuBar { - Menu("File") { - Item(state.connectLabel, enabled = state.canConnect, onClick = actions::connect) - Item("Disconnect", enabled = state.canDisconnect, onClick = actions::disconnect) - Item("Switch operator profile...", onClick = actions::switchOperatorProfile) - Item("Exit + disconnect", onClick = actions::exitApplication) - } - - Menu("Options") { - Item( - "Set QRG as name in Chat (main category)", - enabled = state.canUseChatActions, - onClick = actions::setQrgAsNameInChat, - ) - /* - * This label is the only place the AFK state is visible anywhere in the - * application, so it follows the state rather than being flipped inside the - * click handler as JavaFX did. - */ - Item( - state.awayMenuLabel, - enabled = state.canUseChatActions, - onClick = actions::toggleAwayState, - ) - Item( - if (settingsWindowOpen) "hide options" else "Show options", - onClick = actions::toggleSettingsWindow, - ) - } - - Menu("Windows") { - Item( - if (monitorWindowOpen) "Hide cluster / stranger QSOs" - else "Show cluster / stranger QSOs", - onClick = actions::toggleMonitorWindow, - ) - Item( - if (settingsWindowOpen) "hide options" else "show options", - onClick = actions::toggleSettingsWindow, - ) - Separator() - Item("Show / hide station map", onClick = actions::toggleStationMap) - Separator() - Item("Use dark mode design", onClick = actions::useDarkDesign) - Item("Use default mode design", onClick = actions::useDefaultDesign) - } - - Menu("Info") { - Item("Donate for kst4Contest development via PayPal", onClick = actions::openDonationPage) - Item("Donate for OV3T´s plane feed service", onClick = actions::openOv3tDonationPage) - Item("Visit DARC X08-Homepage", onClick = actions::openHomepage) - Item("Join kst4Contest newsgroup", onClick = actions::openNewsgroup) - Item("Contact the author using default mail app", onClick = actions::contactAuthor) - Separator() - Item("About...", onClick = actions::showAbout) + mainMenuModel(state, actions, settingsWindowOpen, monitorWindowOpen).forEach { menu -> + Menu(menu.title) { + menu.entries.forEach { entry -> + when (entry) { + is MenuEntry.Item -> + Item(entry.label, enabled = entry.enabled, onClick = entry.onClick) + MenuEntry.Separator -> Separator() + } + } + } } /* * The connection indicator, for macOS only: the system menu bar is where it lives * there, because the status bar's badge is hidden on that platform. Read-only — the - * single item is disabled, exactly as menuItemConnectionStateDetailMacOs is. + * single item is disabled, exactly as menuItemConnectionStateDetailMacOs was. */ if (PlatformUtils.isMacOs()) { Menu(ConnectionIndicator.macOsMenuTitle(connectionState)) { @@ -135,3 +110,103 @@ fun FrameWindowScope.Kst4ContestMenuBar( } } } + +/** + * The menu bar drawn inside the window, by Compose. + * + * Used everywhere except macOS. It exists because Compose's `MenuBar` hands the job to + * Swing, which knows nothing of the stylesheet palette: under Linux that produced the + * default cross-platform Metal menu above a themed window. This row reads the same palette + * as every other control, so a design switch reaches it. + * + * A menu title is shaped like a tab — flat, highlighted while active — so it takes the tab + * measurements rather than the button ones, and it highlights with the accent colour, which + * is what a JavaFX menu did. + * + * @param rowState which menu is down. Owned by the caller because `MainWindow` has to ask + * [MainMenuRowState.anyOpen] before letting the window-wide keys run. + */ +@Composable +fun Kst4ContestMenuRow( + state: MainMenuState, + actions: MainMenuActions, + settingsWindowOpen: Boolean, + monitorWindowOpen: Boolean, + rowState: MainMenuRowState, + modifier: Modifier = Modifier, +) { + val palette = LocalJavaFxPalette.current + val menus = mainMenuModel(state, actions, settingsWindowOpen, monitorWindowOpen) + + Row( + modifier = modifier + .fillMaxWidth() + .background(palette.base) + .heightIn(min = Density.TAB_MIN_HEIGHT), + verticalAlignment = Alignment.CenterVertically, + ) { + menus.forEach { menu -> + MenuTitle(menu = menu, rowState = rowState) + } + } +} + +@Composable +private fun MenuTitle(menu: MenuSpec, rowState: MainMenuRowState) { + val palette = LocalJavaFxPalette.current + val interactions = remember { MutableInteractionSource() } + val hovered by interactions.collectIsHoveredAsState() + val open = rowState.openMenu == menu.title + + Box { + Text( + text = menu.title, + style = MaterialTheme.typography.bodyMedium, + color = if (open) palette.textAccent else palette.labelTextFill, + modifier = Modifier + .background( + when { + open -> palette.accent + /* + * Hover is a hint, not a selection: the accent at full strength on + * mere pointer travel makes the bar flicker as the operator crosses + * it on the way to the chat field. + */ + hovered -> palette.accent.copy(alpha = HOVER_ALPHA) + else -> Color.Transparent + } + ) + .hoverable(interactions) + .clickable { rowState.toggle(menu.title) } + .padding( + horizontal = Density.TAB_HORIZONTAL_PADDING, + vertical = MENU_TITLE_VERTICAL_PADDING, + ), + ) + + DropdownMenu(expanded = open, onDismissRequest = rowState::close) { + menu.entries.forEach { entry -> + when (entry) { + is MenuEntry.Item -> DropdownMenuItem( + text = { + Text(entry.label, style = MaterialTheme.typography.bodyMedium) + }, + enabled = entry.enabled, + onClick = { rowState.pick(entry.onClick) }, + ) + + MenuEntry.Separator -> + HorizontalDivider( + thickness = Density.HAIRLINE, + color = palette.separatorLine, + ) + } + } + } + } +} + +/** Enough tint to show the pointer is over a title, not enough to read as a selection. */ +private const val HOVER_ALPHA = 0.35f + +private val MENU_TITLE_VERTICAL_PADDING = androidx.compose.ui.unit.Dp(4f) diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/MainMenuModel.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/MainMenuModel.kt new file mode 100644 index 00000000..a69fedfb --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/MainMenuModel.kt @@ -0,0 +1,130 @@ +package kst4contest.view.compose + +/** + * One line of a menu. + * + * Toolkit-free on purpose: the bar is drawn two different ways — Compose's `MenuBar`, + * which is a `javax.swing.JMenuBar` under the covers and only used on macOS, and the + * in-window Compose row everywhere else. Declaring the menus twice is how one renderer + * quietly loses an item that the other gained, so they both read this instead. + */ +sealed interface MenuEntry { + + /** + * @param enabled whether it can be pressed; a menu item that merely looks usable is + * worse than a disabled one, because the operator presses it mid-contest and + * either nothing happens or the wrong thing does + */ + data class Item( + val label: String, + val enabled: Boolean, + val onClick: () -> Unit, + ) : MenuEntry + + data object Separator : MenuEntry +} + +/** One menu of the bar: its title and what drops down from it. */ +data class MenuSpec(val title: String, val entries: List) + +/** + * The whole bar, as data. + * + * A plain function of the state rather than a composable, which is what finally makes the + * structure, the dynamic labels and the enabled states testable: while this lived inside + * Compose's `MenuBar` it took a running window to look at, and none of it was covered. + * + * @param settingsWindowOpen asked for at build time, not held in [MainMenuState]: a Compose + * window can be closed from its own frame without telling the menu + * @param monitorWindowOpen the same for the cluster / stranger-QSO window + */ +fun mainMenuModel( + state: MainMenuState, + actions: MainMenuActions, + settingsWindowOpen: Boolean, + monitorWindowOpen: Boolean, +): List = listOf( + MenuSpec( + "File", + listOf( + MenuEntry.Item(state.connectLabel, state.canConnect, actions::connect), + MenuEntry.Item("Disconnect", state.canDisconnect, actions::disconnect), + MenuEntry.Item( + "Switch operator profile...", + state.canSwitchProfile, + actions::switchOperatorProfile, + ), + MenuEntry.Item("Exit + disconnect", state.canExit, actions::exitApplication), + ), + ), + MenuSpec( + "Options", + listOf( + MenuEntry.Item( + "Set QRG as name in Chat (main category)", + state.canUseChatActions, + actions::setQrgAsNameInChat, + ), + /* + * This label is the only place the AFK state is visible anywhere in the + * application, so it follows the state rather than being flipped inside the + * click handler as JavaFX did. + */ + MenuEntry.Item( + state.awayMenuLabel, + state.canUseChatActions, + actions::toggleAwayState, + ), + MenuEntry.Item( + if (settingsWindowOpen) "hide options" else "Show options", + enabled = true, + actions::toggleSettingsWindow, + ), + ), + ), + MenuSpec( + "Windows", + listOf( + MenuEntry.Item( + if (monitorWindowOpen) "Hide cluster / stranger QSOs" + else "Show cluster / stranger QSOs", + enabled = true, + actions::toggleMonitorWindow, + ), + MenuEntry.Item( + if (settingsWindowOpen) "hide options" else "show options", + enabled = true, + actions::toggleSettingsWindow, + ), + MenuEntry.Separator, + MenuEntry.Item("Show / hide station map", enabled = true, actions::toggleStationMap), + MenuEntry.Separator, + MenuEntry.Item("Use dark mode design", enabled = true, actions::useDarkDesign), + MenuEntry.Item("Use default mode design", enabled = true, actions::useDefaultDesign), + ), + ), + MenuSpec( + "Info", + listOf( + MenuEntry.Item( + "Donate for kst4Contest development via PayPal", + enabled = true, + actions::openDonationPage, + ), + MenuEntry.Item( + "Donate for OV3T´s plane feed service", + enabled = true, + actions::openOv3tDonationPage, + ), + MenuEntry.Item("Visit DARC X08-Homepage", enabled = true, actions::openHomepage), + MenuEntry.Item("Join kst4Contest newsgroup", enabled = true, actions::openNewsgroup), + MenuEntry.Item( + "Contact the author using default mail app", + enabled = true, + actions::contactAuthor, + ), + MenuEntry.Separator, + MenuEntry.Item("About...", enabled = true, actions::showAbout), + ), + ), +) diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/MainMenuRowState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/MainMenuRowState.kt new file mode 100644 index 00000000..55152178 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/MainMenuRowState.kt @@ -0,0 +1,48 @@ +package kst4contest.view.compose + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue + +/** + * Which menu of the in-window menu row is currently down. + * + * Compose state, because the row redraws when it changes. One menu at a time: two dropped + * at once is a thing no menu bar does. + * + * [anyOpen] is the part that is not cosmetic. `MainWindow` hangs `WindowShortcuts.handle` + * on the window's `onPreviewKeyEvent`, and a preview runs *before* its children — so + * without asking this first, pressing Escape to dismiss a menu would also clear the + * operator's half-typed message. + */ +class MainMenuRowState { + + /** The title of the open menu, or null when none is. */ + var openMenu: String? by mutableStateOf(null) + private set + + /** Whether a menu is down, and the window keys should therefore stay out of the way. */ + val anyOpen: Boolean + get() = openMenu != null + + /** Opens the named menu, or closes it when it is the one already open. */ + fun toggle(title: String) { + openMenu = if (openMenu == title) null else title + } + + /** Closes whatever is open. Idempotent; a dismissal from outside lands here too. */ + fun close() { + openMenu = null + } + + /** + * Runs a picked item's action and closes the menu it came from. + * + * Closing first would be wrong in the one case that matters: the quit item puts up a + * modal dialog, and the row must not still be drawing a dropdown underneath it. + */ + fun pick(action: () -> Unit) { + openMenu = null + action() + } +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindow.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindow.kt index 97b5ce1e..6e8566f9 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindow.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindow.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Surface import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.window.FrameWindowScope @@ -49,14 +50,24 @@ fun FrameWindowScope.MainWindow( onShowMorePriorities: () -> Unit, onCandidateClicked: (TimelineCandidate) -> Unit, ) { - Kst4ContestMenuBar( - state = state.menu, - actions = actions, - settingsWindowOpen = state.surroundings.settingsWindowOpen, - monitorWindowOpen = state.surroundings.monitorWindowOpen, - connectionState = state.surroundings.connectionState, - connectionDetail = state.surroundings.connectionDetail, - ) + /* + * macOS gets the system menu bar at the top of the screen, which is where that platform + * expects it and which Swing draws natively. Everywhere else Compose's MenuBar meant an + * unthemed Metal menu above a themed window, so the bar is drawn in the window instead — + * see Kst4ContestMenuRow. + */ + if (PlatformUtils.isMacOs()) { + Kst4ContestMenuBar( + state = state.menu, + actions = actions, + settingsWindowOpen = state.surroundings.settingsWindowOpen, + monitorWindowOpen = state.surroundings.monitorWindowOpen, + connectionState = state.surroundings.connectionState, + connectionDetail = state.surroundings.connectionDetail, + ) + } + + val menuRow = remember { MainMenuRowState() } /* * The window-wide keys, where JavaFX had them: on the scene. Enter sends and Escape @@ -68,17 +79,38 @@ fun FrameWindowScope.MainWindow( Modifier .fillMaxSize() .onPreviewKeyEvent { event -> - WindowShortcuts.handle( - event = event, - send = state.chatInput::send, - clear = state.chatInput::clear, - insertSnippet = { index -> - state.chatInput.insertSnippet(state.snippets.entries, index) - }, - ) + /* + * Not while a menu is down. This is a PREVIEW handler, so it runs before the + * dropdown sees the key — without the guard, Escape to dismiss a menu would + * first clear the operator's half-typed message. + */ + if (menuRow.anyOpen) { + false + } else { + WindowShortcuts.handle( + event = event, + send = state.chatInput::send, + clear = state.chatInput::clear, + insertSnippet = { index -> + state.chatInput.insertSnippet(state.snippets.entries, index) + }, + ) + } }, ) { Column(Modifier.fillMaxSize()) { + if (!PlatformUtils.isMacOs()) { + Kst4ContestMenuRow( + state = state.menu, + actions = actions, + settingsWindowOpen = state.surroundings.settingsWindowOpen, + monitorWindowOpen = state.surroundings.monitorWindowOpen, + rowState = menuRow, + ) + + HorizontalDivider(thickness = Density.HAIRLINE) + } + StatusBar( connectionState = state.surroundings.connectionState, connectionDetail = state.surroundings.connectionDetail, diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/SettingsWindow.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/SettingsWindow.kt index de7bcc8b..6d6b7d4c 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/SettingsWindow.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/SettingsWindow.kt @@ -154,7 +154,7 @@ object SettingsWindow { } @Composable -private fun SettingsContent( +internal fun SettingsContent( tabs: List, notices: SettingsNotices, buttons: @Composable (close: () -> Unit) -> Unit, @@ -241,7 +241,7 @@ private fun SettingsContent( */ @OptIn(ExperimentalLayoutApi::class) @Composable -private fun SettingsTabStrip( +internal fun SettingsTabStrip( tabs: List, selectedIndex: Int, onSelect: (Int) -> Unit, diff --git a/app-desktop/src/main/resources/leaflet-1.9.4.css b/app-desktop/src/main/resources/leaflet-1.9.4.css deleted file mode 100644 index 2961b761..00000000 --- a/app-desktop/src/main/resources/leaflet-1.9.4.css +++ /dev/null @@ -1,661 +0,0 @@ -/* required styles */ - -.leaflet-pane, -.leaflet-tile, -.leaflet-marker-icon, -.leaflet-marker-shadow, -.leaflet-tile-container, -.leaflet-pane > svg, -.leaflet-pane > canvas, -.leaflet-zoom-box, -.leaflet-image-layer, -.leaflet-layer { - position: absolute; - left: 0; - top: 0; - } -.leaflet-container { - overflow: hidden; - } -.leaflet-tile, -.leaflet-marker-icon, -.leaflet-marker-shadow { - -webkit-user-select: none; - -moz-user-select: none; - user-select: none; - -webkit-user-drag: none; - } -/* Prevents IE11 from highlighting tiles in blue */ -.leaflet-tile::selection { - background: transparent; -} -/* Safari renders non-retina tile on retina better with this, but Chrome is worse */ -.leaflet-safari .leaflet-tile { - image-rendering: -webkit-optimize-contrast; - } -/* hack that prevents hw layers "stretching" when loading new tiles */ -.leaflet-safari .leaflet-tile-container { - width: 1600px; - height: 1600px; - -webkit-transform-origin: 0 0; - } -.leaflet-marker-icon, -.leaflet-marker-shadow { - display: block; - } -/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */ -/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */ -.leaflet-container .leaflet-overlay-pane svg { - max-width: none !important; - max-height: none !important; - } -.leaflet-container .leaflet-marker-pane img, -.leaflet-container .leaflet-shadow-pane img, -.leaflet-container .leaflet-tile-pane img, -.leaflet-container img.leaflet-image-layer, -.leaflet-container .leaflet-tile { - max-width: none !important; - max-height: none !important; - width: auto; - padding: 0; - } - -.leaflet-container img.leaflet-tile { - /* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */ - mix-blend-mode: plus-lighter; -} - -.leaflet-container.leaflet-touch-zoom { - -ms-touch-action: pan-x pan-y; - touch-action: pan-x pan-y; - } -.leaflet-container.leaflet-touch-drag { - -ms-touch-action: pinch-zoom; - /* Fallback for FF which doesn't support pinch-zoom */ - touch-action: none; - touch-action: pinch-zoom; -} -.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom { - -ms-touch-action: none; - touch-action: none; -} -.leaflet-container { - -webkit-tap-highlight-color: transparent; -} -.leaflet-container a { - -webkit-tap-highlight-color: rgba(51, 181, 229, 0.4); -} -.leaflet-tile { - filter: inherit; - visibility: hidden; - } -.leaflet-tile-loaded { - visibility: inherit; - } -.leaflet-zoom-box { - width: 0; - height: 0; - -moz-box-sizing: border-box; - box-sizing: border-box; - z-index: 800; - } -/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */ -.leaflet-overlay-pane svg { - -moz-user-select: none; - } - -.leaflet-pane { z-index: 400; } - -.leaflet-tile-pane { z-index: 200; } -.leaflet-overlay-pane { z-index: 400; } -.leaflet-shadow-pane { z-index: 500; } -.leaflet-marker-pane { z-index: 600; } -.leaflet-tooltip-pane { z-index: 650; } -.leaflet-popup-pane { z-index: 700; } - -.leaflet-map-pane canvas { z-index: 100; } -.leaflet-map-pane svg { z-index: 200; } - -.leaflet-vml-shape { - width: 1px; - height: 1px; - } -.lvml { - behavior: url(#default#VML); - display: inline-block; - position: absolute; - } - - -/* control positioning */ - -.leaflet-control { - position: relative; - z-index: 800; - pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */ - pointer-events: auto; - } -.leaflet-top, -.leaflet-bottom { - position: absolute; - z-index: 1000; - pointer-events: none; - } -.leaflet-top { - top: 0; - } -.leaflet-right { - right: 0; - } -.leaflet-bottom { - bottom: 0; - } -.leaflet-left { - left: 0; - } -.leaflet-control { - float: left; - clear: both; - } -.leaflet-right .leaflet-control { - float: right; - } -.leaflet-top .leaflet-control { - margin-top: 10px; - } -.leaflet-bottom .leaflet-control { - margin-bottom: 10px; - } -.leaflet-left .leaflet-control { - margin-left: 10px; - } -.leaflet-right .leaflet-control { - margin-right: 10px; - } - - -/* zoom and fade animations */ - -.leaflet-fade-anim .leaflet-popup { - opacity: 0; - -webkit-transition: opacity 0.2s linear; - -moz-transition: opacity 0.2s linear; - transition: opacity 0.2s linear; - } -.leaflet-fade-anim .leaflet-map-pane .leaflet-popup { - opacity: 1; - } -.leaflet-zoom-animated { - -webkit-transform-origin: 0 0; - -ms-transform-origin: 0 0; - transform-origin: 0 0; - } -svg.leaflet-zoom-animated { - will-change: transform; -} - -.leaflet-zoom-anim .leaflet-zoom-animated { - -webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1); - -moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1); - transition: transform 0.25s cubic-bezier(0,0,0.25,1); - } -.leaflet-zoom-anim .leaflet-tile, -.leaflet-pan-anim .leaflet-tile { - -webkit-transition: none; - -moz-transition: none; - transition: none; - } - -.leaflet-zoom-anim .leaflet-zoom-hide { - visibility: hidden; - } - - -/* cursors */ - -.leaflet-interactive { - cursor: pointer; - } -.leaflet-grab { - cursor: -webkit-grab; - cursor: -moz-grab; - cursor: grab; - } -.leaflet-crosshair, -.leaflet-crosshair .leaflet-interactive { - cursor: crosshair; - } -.leaflet-popup-pane, -.leaflet-control { - cursor: auto; - } -.leaflet-dragging .leaflet-grab, -.leaflet-dragging .leaflet-grab .leaflet-interactive, -.leaflet-dragging .leaflet-marker-draggable { - cursor: move; - cursor: -webkit-grabbing; - cursor: -moz-grabbing; - cursor: grabbing; - } - -/* marker & overlays interactivity */ -.leaflet-marker-icon, -.leaflet-marker-shadow, -.leaflet-image-layer, -.leaflet-pane > svg path, -.leaflet-tile-container { - pointer-events: none; - } - -.leaflet-marker-icon.leaflet-interactive, -.leaflet-image-layer.leaflet-interactive, -.leaflet-pane > svg path.leaflet-interactive, -svg.leaflet-image-layer.leaflet-interactive path { - pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */ - pointer-events: auto; - } - -/* visual tweaks */ - -.leaflet-container { - background: #ddd; - outline-offset: 1px; - } -.leaflet-container a { - color: #0078A8; - } -.leaflet-zoom-box { - border: 2px dotted #38f; - background: rgba(255,255,255,0.5); - } - - -/* general typography */ -.leaflet-container { - font-family: "Helvetica Neue", Arial, Helvetica, sans-serif; - font-size: 12px; - font-size: 0.75rem; - line-height: 1.5; - } - - -/* general toolbar styles */ - -.leaflet-bar { - box-shadow: 0 1px 5px rgba(0,0,0,0.65); - border-radius: 4px; - } -.leaflet-bar a { - background-color: #fff; - border-bottom: 1px solid #ccc; - width: 26px; - height: 26px; - line-height: 26px; - display: block; - text-align: center; - text-decoration: none; - color: black; - } -.leaflet-bar a, -.leaflet-control-layers-toggle { - background-position: 50% 50%; - background-repeat: no-repeat; - display: block; - } -.leaflet-bar a:hover, -.leaflet-bar a:focus { - background-color: #f4f4f4; - } -.leaflet-bar a:first-child { - border-top-left-radius: 4px; - border-top-right-radius: 4px; - } -.leaflet-bar a:last-child { - border-bottom-left-radius: 4px; - border-bottom-right-radius: 4px; - border-bottom: none; - } -.leaflet-bar a.leaflet-disabled { - cursor: default; - background-color: #f4f4f4; - color: #bbb; - } - -.leaflet-touch .leaflet-bar a { - width: 30px; - height: 30px; - line-height: 30px; - } -.leaflet-touch .leaflet-bar a:first-child { - border-top-left-radius: 2px; - border-top-right-radius: 2px; - } -.leaflet-touch .leaflet-bar a:last-child { - border-bottom-left-radius: 2px; - border-bottom-right-radius: 2px; - } - -/* zoom control */ - -.leaflet-control-zoom-in, -.leaflet-control-zoom-out { - font: bold 18px 'Lucida Console', Monaco, monospace; - text-indent: 1px; - } - -.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out { - font-size: 22px; - } - - -/* layers control */ - -.leaflet-control-layers { - box-shadow: 0 1px 5px rgba(0,0,0,0.4); - background: #fff; - border-radius: 5px; - } -.leaflet-control-layers-toggle { - background-image: url(images/layers.png); - width: 36px; - height: 36px; - } -.leaflet-retina .leaflet-control-layers-toggle { - background-image: url(images/layers-2x.png); - background-size: 26px 26px; - } -.leaflet-touch .leaflet-control-layers-toggle { - width: 44px; - height: 44px; - } -.leaflet-control-layers .leaflet-control-layers-list, -.leaflet-control-layers-expanded .leaflet-control-layers-toggle { - display: none; - } -.leaflet-control-layers-expanded .leaflet-control-layers-list { - display: block; - position: relative; - } -.leaflet-control-layers-expanded { - padding: 6px 10px 6px 6px; - color: #333; - background: #fff; - } -.leaflet-control-layers-scrollbar { - overflow-y: scroll; - overflow-x: hidden; - padding-right: 5px; - } -.leaflet-control-layers-selector { - margin-top: 2px; - position: relative; - top: 1px; - } -.leaflet-control-layers label { - display: block; - font-size: 13px; - font-size: 1.08333em; - } -.leaflet-control-layers-separator { - height: 0; - border-top: 1px solid #ddd; - margin: 5px -10px 5px -6px; - } - -/* Default icon URLs */ -.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */ - background-image: url(images/marker-icon.png); - } - - -/* attribution and scale controls */ - -.leaflet-container .leaflet-control-attribution { - background: #fff; - background: rgba(255, 255, 255, 0.8); - margin: 0; - } -.leaflet-control-attribution, -.leaflet-control-scale-line { - padding: 0 5px; - color: #333; - line-height: 1.4; - } -.leaflet-control-attribution a { - text-decoration: none; - } -.leaflet-control-attribution a:hover, -.leaflet-control-attribution a:focus { - text-decoration: underline; - } -.leaflet-attribution-flag { - display: inline !important; - vertical-align: baseline !important; - width: 1em; - height: 0.6669em; - } -.leaflet-left .leaflet-control-scale { - margin-left: 5px; - } -.leaflet-bottom .leaflet-control-scale { - margin-bottom: 5px; - } -.leaflet-control-scale-line { - border: 2px solid #777; - border-top: none; - line-height: 1.1; - padding: 2px 5px 1px; - white-space: nowrap; - -moz-box-sizing: border-box; - box-sizing: border-box; - background: rgba(255, 255, 255, 0.8); - text-shadow: 1px 1px #fff; - } -.leaflet-control-scale-line:not(:first-child) { - border-top: 2px solid #777; - border-bottom: none; - margin-top: -2px; - } -.leaflet-control-scale-line:not(:first-child):not(:last-child) { - border-bottom: 2px solid #777; - } - -.leaflet-touch .leaflet-control-attribution, -.leaflet-touch .leaflet-control-layers, -.leaflet-touch .leaflet-bar { - box-shadow: none; - } -.leaflet-touch .leaflet-control-layers, -.leaflet-touch .leaflet-bar { - border: 2px solid rgba(0,0,0,0.2); - background-clip: padding-box; - } - - -/* popup */ - -.leaflet-popup { - position: absolute; - text-align: center; - margin-bottom: 20px; - } -.leaflet-popup-content-wrapper { - padding: 1px; - text-align: left; - border-radius: 12px; - } -.leaflet-popup-content { - margin: 13px 24px 13px 20px; - line-height: 1.3; - font-size: 13px; - font-size: 1.08333em; - min-height: 1px; - } -.leaflet-popup-content p { - margin: 17px 0; - margin: 1.3em 0; - } -.leaflet-popup-tip-container { - width: 40px; - height: 20px; - position: absolute; - left: 50%; - margin-top: -1px; - margin-left: -20px; - overflow: hidden; - pointer-events: none; - } -.leaflet-popup-tip { - width: 17px; - height: 17px; - padding: 1px; - - margin: -10px auto 0; - pointer-events: auto; - - -webkit-transform: rotate(45deg); - -moz-transform: rotate(45deg); - -ms-transform: rotate(45deg); - transform: rotate(45deg); - } -.leaflet-popup-content-wrapper, -.leaflet-popup-tip { - background: white; - color: #333; - box-shadow: 0 3px 14px rgba(0,0,0,0.4); - } -.leaflet-container a.leaflet-popup-close-button { - position: absolute; - top: 0; - right: 0; - border: none; - text-align: center; - width: 24px; - height: 24px; - font: 16px/24px Tahoma, Verdana, sans-serif; - color: #757575; - text-decoration: none; - background: transparent; - } -.leaflet-container a.leaflet-popup-close-button:hover, -.leaflet-container a.leaflet-popup-close-button:focus { - color: #585858; - } -.leaflet-popup-scrolled { - overflow: auto; - } - -.leaflet-oldie .leaflet-popup-content-wrapper { - -ms-zoom: 1; - } -.leaflet-oldie .leaflet-popup-tip { - width: 24px; - margin: 0 auto; - - -ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)"; - filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678); - } - -.leaflet-oldie .leaflet-control-zoom, -.leaflet-oldie .leaflet-control-layers, -.leaflet-oldie .leaflet-popup-content-wrapper, -.leaflet-oldie .leaflet-popup-tip { - border: 1px solid #999; - } - - -/* div icon */ - -.leaflet-div-icon { - background: #fff; - border: 1px solid #666; - } - - -/* Tooltip */ -/* Base styles for the element that has a tooltip */ -.leaflet-tooltip { - position: absolute; - padding: 6px; - background-color: #fff; - border: 1px solid #fff; - border-radius: 3px; - color: #222; - white-space: nowrap; - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; - pointer-events: none; - box-shadow: 0 1px 3px rgba(0,0,0,0.4); - } -.leaflet-tooltip.leaflet-interactive { - cursor: pointer; - pointer-events: auto; - } -.leaflet-tooltip-top:before, -.leaflet-tooltip-bottom:before, -.leaflet-tooltip-left:before, -.leaflet-tooltip-right:before { - position: absolute; - pointer-events: none; - border: 6px solid transparent; - background: transparent; - content: ""; - } - -/* Directions */ - -.leaflet-tooltip-bottom { - margin-top: 6px; -} -.leaflet-tooltip-top { - margin-top: -6px; -} -.leaflet-tooltip-bottom:before, -.leaflet-tooltip-top:before { - left: 50%; - margin-left: -6px; - } -.leaflet-tooltip-top:before { - bottom: 0; - margin-bottom: -12px; - border-top-color: #fff; - } -.leaflet-tooltip-bottom:before { - top: 0; - margin-top: -12px; - margin-left: -6px; - border-bottom-color: #fff; - } -.leaflet-tooltip-left { - margin-left: -6px; -} -.leaflet-tooltip-right { - margin-left: 6px; -} -.leaflet-tooltip-left:before, -.leaflet-tooltip-right:before { - top: 50%; - margin-top: -6px; - } -.leaflet-tooltip-left:before { - right: 0; - margin-right: -12px; - border-left-color: #fff; - } -.leaflet-tooltip-right:before { - left: 0; - margin-left: -12px; - border-right-color: #fff; - } - -/* Printing */ - -@media print { - /* Prevent printers from removing background-images of controls. */ - .leaflet-control { - -webkit-print-color-adjust: exact; - print-color-adjust: exact; - } - } diff --git a/app-desktop/src/main/resources/leaflet-1.9.4.js b/app-desktop/src/main/resources/leaflet-1.9.4.js deleted file mode 100644 index a3bf693d..00000000 --- a/app-desktop/src/main/resources/leaflet-1.9.4.js +++ /dev/null @@ -1,6 +0,0 @@ -/* @preserve - * Leaflet 1.9.4, a JS library for interactive maps. https://leafletjs.com - * (c) 2010-2023 Vladimir Agafonkin, (c) 2010-2011 CloudMade - */ -!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).leaflet={})}(this,function(t){"use strict";function l(t){for(var e,i,n=1,o=arguments.length;n=this.min.x&&i.x<=this.max.x&&e.y>=this.min.y&&i.y<=this.max.y},intersects:function(t){t=_(t);var e=this.min,i=this.max,n=t.min,t=t.max,o=t.x>=e.x&&n.x<=i.x,t=t.y>=e.y&&n.y<=i.y;return o&&t},overlaps:function(t){t=_(t);var e=this.min,i=this.max,n=t.min,t=t.max,o=t.x>e.x&&n.xe.y&&n.y=n.lat&&i.lat<=o.lat&&e.lng>=n.lng&&i.lng<=o.lng},intersects:function(t){t=g(t);var e=this._southWest,i=this._northEast,n=t.getSouthWest(),t=t.getNorthEast(),o=t.lat>=e.lat&&n.lat<=i.lat,t=t.lng>=e.lng&&n.lng<=i.lng;return o&&t},overlaps:function(t){t=g(t);var e=this._southWest,i=this._northEast,n=t.getSouthWest(),t=t.getNorthEast(),o=t.lat>e.lat&&n.late.lng&&n.lng","http://www.w3.org/2000/svg"===(Wt.firstChild&&Wt.firstChild.namespaceURI));function y(t){return 0<=navigator.userAgent.toLowerCase().indexOf(t)}var b={ie:pt,ielt9:mt,edge:n,webkit:ft,android:gt,android23:vt,androidStock:yt,opera:xt,chrome:wt,gecko:bt,safari:Pt,phantom:Lt,opera12:o,win:Tt,ie3d:Mt,webkit3d:zt,gecko3d:_t,any3d:Ct,mobile:Zt,mobileWebkit:St,mobileWebkit3d:Et,msPointer:kt,pointer:Ot,touch:Bt,touchNative:At,mobileOpera:It,mobileGecko:Rt,retina:Nt,passiveEvents:Dt,canvas:jt,svg:Ht,vml:!Ht&&function(){try{var t=document.createElement("div"),e=(t.innerHTML='',t.firstChild);return e.style.behavior="url(#default#VML)",e&&"object"==typeof e.adj}catch(t){return!1}}(),inlineSvg:Wt,mac:0===navigator.platform.indexOf("Mac"),linux:0===navigator.platform.indexOf("Linux")},Ft=b.msPointer?"MSPointerDown":"pointerdown",Ut=b.msPointer?"MSPointerMove":"pointermove",Vt=b.msPointer?"MSPointerUp":"pointerup",qt=b.msPointer?"MSPointerCancel":"pointercancel",Gt={touchstart:Ft,touchmove:Ut,touchend:Vt,touchcancel:qt},Kt={touchstart:function(t,e){e.MSPOINTER_TYPE_TOUCH&&e.pointerType===e.MSPOINTER_TYPE_TOUCH&&O(e);ee(t,e)},touchmove:ee,touchend:ee,touchcancel:ee},Yt={},Xt=!1;function Jt(t,e,i){return"touchstart"!==e||Xt||(document.addEventListener(Ft,$t,!0),document.addEventListener(Ut,Qt,!0),document.addEventListener(Vt,te,!0),document.addEventListener(qt,te,!0),Xt=!0),Kt[e]?(i=Kt[e].bind(this,i),t.addEventListener(Gt[e],i,!1),i):(console.warn("wrong event specified:",e),u)}function $t(t){Yt[t.pointerId]=t}function Qt(t){Yt[t.pointerId]&&(Yt[t.pointerId]=t)}function te(t){delete Yt[t.pointerId]}function ee(t,e){if(e.pointerType!==(e.MSPOINTER_TYPE_MOUSE||"mouse")){for(var i in e.touches=[],Yt)e.touches.push(Yt[i]);e.changedTouches=[e],t(e)}}var ie=200;function ne(t,i){t.addEventListener("dblclick",i);var n,o=0;function e(t){var e;1!==t.detail?n=t.detail:"mouse"===t.pointerType||t.sourceCapabilities&&!t.sourceCapabilities.firesTouchEvents||((e=Ne(t)).some(function(t){return t instanceof HTMLLabelElement&&t.attributes.for})&&!e.some(function(t){return t instanceof HTMLInputElement||t instanceof HTMLSelectElement})||((e=Date.now())-o<=ie?2===++n&&i(function(t){var e,i,n={};for(i in t)e=t[i],n[i]=e&&e.bind?e.bind(t):e;return(t=n).type="dblclick",n.detail=2,n.isTrusted=!1,n._simulated=!0,n}(t)):n=1,o=e))}return t.addEventListener("click",e),{dblclick:i,simDblclick:e}}var oe,se,re,ae,he,le,ue=we(["transform","webkitTransform","OTransform","MozTransform","msTransform"]),ce=we(["webkitTransition","transition","OTransition","MozTransition","msTransition"]),de="webkitTransition"===ce||"OTransition"===ce?ce+"End":"transitionend";function _e(t){return"string"==typeof t?document.getElementById(t):t}function pe(t,e){var i=t.style[e]||t.currentStyle&&t.currentStyle[e];return"auto"===(i=i&&"auto"!==i||!document.defaultView?i:(t=document.defaultView.getComputedStyle(t,null))?t[e]:null)?null:i}function P(t,e,i){t=document.createElement(t);return t.className=e||"",i&&i.appendChild(t),t}function T(t){var e=t.parentNode;e&&e.removeChild(t)}function me(t){for(;t.firstChild;)t.removeChild(t.firstChild)}function fe(t){var e=t.parentNode;e&&e.lastChild!==t&&e.appendChild(t)}function ge(t){var e=t.parentNode;e&&e.firstChild!==t&&e.insertBefore(t,e.firstChild)}function ve(t,e){return void 0!==t.classList?t.classList.contains(e):0<(t=xe(t)).length&&new RegExp("(^|\\s)"+e+"(\\s|$)").test(t)}function M(t,e){var i;if(void 0!==t.classList)for(var n=F(e),o=0,s=n.length;othis.options.maxZoom)?this.setZoom(t):this},panInsideBounds:function(t,e){this._enforcingBounds=!0;var i=this.getCenter(),t=this._limitCenter(i,this._zoom,g(t));return i.equals(t)||this.panTo(t,e),this._enforcingBounds=!1,this},panInside:function(t,e){var i=m((e=e||{}).paddingTopLeft||e.padding||[0,0]),n=m(e.paddingBottomRight||e.padding||[0,0]),o=this.project(this.getCenter()),t=this.project(t),s=this.getPixelBounds(),i=_([s.min.add(i),s.max.subtract(n)]),s=i.getSize();return i.contains(t)||(this._enforcingBounds=!0,n=t.subtract(i.getCenter()),i=i.extend(t).getSize().subtract(s),o.x+=n.x<0?-i.x:i.x,o.y+=n.y<0?-i.y:i.y,this.panTo(this.unproject(o),e),this._enforcingBounds=!1),this},invalidateSize:function(t){if(!this._loaded)return this;t=l({animate:!1,pan:!0},!0===t?{animate:!0}:t);var e=this.getSize(),i=(this._sizeChanged=!0,this._lastCenter=null,this.getSize()),n=e.divideBy(2).round(),o=i.divideBy(2).round(),n=n.subtract(o);return n.x||n.y?(t.animate&&t.pan?this.panBy(n):(t.pan&&this._rawPanBy(n),this.fire("move"),t.debounceMoveend?(clearTimeout(this._sizeTimer),this._sizeTimer=setTimeout(a(this.fire,this,"moveend"),200)):this.fire("moveend")),this.fire("resize",{oldSize:e,newSize:i})):this},stop:function(){return this.setZoom(this._limitZoom(this._zoom)),this.options.zoomSnap||this.fire("viewreset"),this._stop()},locate:function(t){var e,i;return t=this._locateOptions=l({timeout:1e4,watch:!1},t),"geolocation"in navigator?(e=a(this._handleGeolocationResponse,this),i=a(this._handleGeolocationError,this),t.watch?this._locationWatchId=navigator.geolocation.watchPosition(e,i,t):navigator.geolocation.getCurrentPosition(e,i,t)):this._handleGeolocationError({code:0,message:"Geolocation not supported."}),this},stopLocate:function(){return navigator.geolocation&&navigator.geolocation.clearWatch&&navigator.geolocation.clearWatch(this._locationWatchId),this._locateOptions&&(this._locateOptions.setView=!1),this},_handleGeolocationError:function(t){var e;this._container._leaflet_id&&(e=t.code,t=t.message||(1===e?"permission denied":2===e?"position unavailable":"timeout"),this._locateOptions.setView&&!this._loaded&&this.fitWorld(),this.fire("locationerror",{code:e,message:"Geolocation error: "+t+"."}))},_handleGeolocationResponse:function(t){if(this._container._leaflet_id){var e,i,n=new v(t.coords.latitude,t.coords.longitude),o=n.toBounds(2*t.coords.accuracy),s=this._locateOptions,r=(s.setView&&(e=this.getBoundsZoom(o),this.setView(n,s.maxZoom?Math.min(e,s.maxZoom):e)),{latlng:n,bounds:o,timestamp:t.timestamp});for(i in t.coords)"number"==typeof t.coords[i]&&(r[i]=t.coords[i]);this.fire("locationfound",r)}},addHandler:function(t,e){return e&&(e=this[t]=new e(this),this._handlers.push(e),this.options[t]&&e.enable()),this},remove:function(){if(this._initEvents(!0),this.options.maxBounds&&this.off("moveend",this._panInsideMaxBounds),this._containerId!==this._container._leaflet_id)throw new Error("Map container is being reused by another instance");try{delete this._container._leaflet_id,delete this._containerId}catch(t){this._container._leaflet_id=void 0,this._containerId=void 0}for(var t in void 0!==this._locationWatchId&&this.stopLocate(),this._stop(),T(this._mapPane),this._clearControlPos&&this._clearControlPos(),this._resizeRequest&&(r(this._resizeRequest),this._resizeRequest=null),this._clearHandlers(),this._loaded&&this.fire("unload"),this._layers)this._layers[t].remove();for(t in this._panes)T(this._panes[t]);return this._layers=[],this._panes=[],delete this._mapPane,delete this._renderer,this},createPane:function(t,e){e=P("div","leaflet-pane"+(t?" leaflet-"+t.replace("Pane","")+"-pane":""),e||this._mapPane);return t&&(this._panes[t]=e),e},getCenter:function(){return this._checkIfLoaded(),this._lastCenter&&!this._moved()?this._lastCenter.clone():this.layerPointToLatLng(this._getCenterLayerPoint())},getZoom:function(){return this._zoom},getBounds:function(){var t=this.getPixelBounds();return new s(this.unproject(t.getBottomLeft()),this.unproject(t.getTopRight()))},getMinZoom:function(){return void 0===this.options.minZoom?this._layersMinZoom||0:this.options.minZoom},getMaxZoom:function(){return void 0===this.options.maxZoom?void 0===this._layersMaxZoom?1/0:this._layersMaxZoom:this.options.maxZoom},getBoundsZoom:function(t,e,i){t=g(t),i=m(i||[0,0]);var n=this.getZoom()||0,o=this.getMinZoom(),s=this.getMaxZoom(),r=t.getNorthWest(),t=t.getSouthEast(),i=this.getSize().subtract(i),t=_(this.project(t,n),this.project(r,n)).getSize(),r=b.any3d?this.options.zoomSnap:1,a=i.x/t.x,i=i.y/t.y,t=e?Math.max(a,i):Math.min(a,i),n=this.getScaleZoom(t,n);return r&&(n=Math.round(n/(r/100))*(r/100),n=e?Math.ceil(n/r)*r:Math.floor(n/r)*r),Math.max(o,Math.min(s,n))},getSize:function(){return this._size&&!this._sizeChanged||(this._size=new p(this._container.clientWidth||0,this._container.clientHeight||0),this._sizeChanged=!1),this._size.clone()},getPixelBounds:function(t,e){t=this._getTopLeftPoint(t,e);return new f(t,t.add(this.getSize()))},getPixelOrigin:function(){return this._checkIfLoaded(),this._pixelOrigin},getPixelWorldBounds:function(t){return this.options.crs.getProjectedBounds(void 0===t?this.getZoom():t)},getPane:function(t){return"string"==typeof t?this._panes[t]:t},getPanes:function(){return this._panes},getContainer:function(){return this._container},getZoomScale:function(t,e){var i=this.options.crs;return e=void 0===e?this._zoom:e,i.scale(t)/i.scale(e)},getScaleZoom:function(t,e){var i=this.options.crs,t=(e=void 0===e?this._zoom:e,i.zoom(t*i.scale(e)));return isNaN(t)?1/0:t},project:function(t,e){return e=void 0===e?this._zoom:e,this.options.crs.latLngToPoint(w(t),e)},unproject:function(t,e){return e=void 0===e?this._zoom:e,this.options.crs.pointToLatLng(m(t),e)},layerPointToLatLng:function(t){t=m(t).add(this.getPixelOrigin());return this.unproject(t)},latLngToLayerPoint:function(t){return this.project(w(t))._round()._subtract(this.getPixelOrigin())},wrapLatLng:function(t){return this.options.crs.wrapLatLng(w(t))},wrapLatLngBounds:function(t){return this.options.crs.wrapLatLngBounds(g(t))},distance:function(t,e){return this.options.crs.distance(w(t),w(e))},containerPointToLayerPoint:function(t){return m(t).subtract(this._getMapPanePos())},layerPointToContainerPoint:function(t){return m(t).add(this._getMapPanePos())},containerPointToLatLng:function(t){t=this.containerPointToLayerPoint(m(t));return this.layerPointToLatLng(t)},latLngToContainerPoint:function(t){return this.layerPointToContainerPoint(this.latLngToLayerPoint(w(t)))},mouseEventToContainerPoint:function(t){return De(t,this._container)},mouseEventToLayerPoint:function(t){return this.containerPointToLayerPoint(this.mouseEventToContainerPoint(t))},mouseEventToLatLng:function(t){return this.layerPointToLatLng(this.mouseEventToLayerPoint(t))},_initContainer:function(t){t=this._container=_e(t);if(!t)throw new Error("Map container not found.");if(t._leaflet_id)throw new Error("Map container is already initialized.");S(t,"scroll",this._onScroll,this),this._containerId=h(t)},_initLayout:function(){var t=this._container,e=(this._fadeAnimated=this.options.fadeAnimation&&b.any3d,M(t,"leaflet-container"+(b.touch?" leaflet-touch":"")+(b.retina?" leaflet-retina":"")+(b.ielt9?" leaflet-oldie":"")+(b.safari?" leaflet-safari":"")+(this._fadeAnimated?" leaflet-fade-anim":"")),pe(t,"position"));"absolute"!==e&&"relative"!==e&&"fixed"!==e&&"sticky"!==e&&(t.style.position="relative"),this._initPanes(),this._initControlPos&&this._initControlPos()},_initPanes:function(){var t=this._panes={};this._paneRenderers={},this._mapPane=this.createPane("mapPane",this._container),Z(this._mapPane,new p(0,0)),this.createPane("tilePane"),this.createPane("overlayPane"),this.createPane("shadowPane"),this.createPane("markerPane"),this.createPane("tooltipPane"),this.createPane("popupPane"),this.options.markerZoomAnimation||(M(t.markerPane,"leaflet-zoom-hide"),M(t.shadowPane,"leaflet-zoom-hide"))},_resetView:function(t,e,i){Z(this._mapPane,new p(0,0));var n=!this._loaded,o=(this._loaded=!0,e=this._limitZoom(e),this.fire("viewprereset"),this._zoom!==e);this._moveStart(o,i)._move(t,e)._moveEnd(o),this.fire("viewreset"),n&&this.fire("load")},_moveStart:function(t,e){return t&&this.fire("zoomstart"),e||this.fire("movestart"),this},_move:function(t,e,i,n){void 0===e&&(e=this._zoom);var o=this._zoom!==e;return this._zoom=e,this._lastCenter=t,this._pixelOrigin=this._getNewPixelOrigin(t),n?i&&i.pinch&&this.fire("zoom",i):((o||i&&i.pinch)&&this.fire("zoom",i),this.fire("move",i)),this},_moveEnd:function(t){return t&&this.fire("zoomend"),this.fire("moveend")},_stop:function(){return r(this._flyToFrame),this._panAnim&&this._panAnim.stop(),this},_rawPanBy:function(t){Z(this._mapPane,this._getMapPanePos().subtract(t))},_getZoomSpan:function(){return this.getMaxZoom()-this.getMinZoom()},_panInsideMaxBounds:function(){this._enforcingBounds||this.panInsideBounds(this.options.maxBounds)},_checkIfLoaded:function(){if(!this._loaded)throw new Error("Set map center and zoom first.")},_initEvents:function(t){this._targets={};var e=t?k:S;e((this._targets[h(this._container)]=this)._container,"click dblclick mousedown mouseup mouseover mouseout mousemove contextmenu keypress keydown keyup",this._handleDOMEvent,this),this.options.trackResize&&e(window,"resize",this._onResize,this),b.any3d&&this.options.transform3DLimit&&(t?this.off:this.on).call(this,"moveend",this._onMoveEnd)},_onResize:function(){r(this._resizeRequest),this._resizeRequest=x(function(){this.invalidateSize({debounceMoveend:!0})},this)},_onScroll:function(){this._container.scrollTop=0,this._container.scrollLeft=0},_onMoveEnd:function(){var t=this._getMapPanePos();Math.max(Math.abs(t.x),Math.abs(t.y))>=this.options.transform3DLimit&&this._resetView(this.getCenter(),this.getZoom())},_findEventTargets:function(t,e){for(var i,n=[],o="mouseout"===e||"mouseover"===e,s=t.target||t.srcElement,r=!1;s;){if((i=this._targets[h(s)])&&("click"===e||"preclick"===e)&&this._draggableMoved(i)){r=!0;break}if(i&&i.listens(e,!0)){if(o&&!We(s,t))break;if(n.push(i),o)break}if(s===this._container)break;s=s.parentNode}return n=n.length||r||o||!this.listens(e,!0)?n:[this]},_isClickDisabled:function(t){for(;t&&t!==this._container;){if(t._leaflet_disable_click)return!0;t=t.parentNode}},_handleDOMEvent:function(t){var e,i=t.target||t.srcElement;!this._loaded||i._leaflet_disable_events||"click"===t.type&&this._isClickDisabled(i)||("mousedown"===(e=t.type)&&Me(i),this._fireDOMEvent(t,e))},_mouseEvents:["click","dblclick","mouseover","mouseout","contextmenu"],_fireDOMEvent:function(t,e,i){"click"===t.type&&((a=l({},t)).type="preclick",this._fireDOMEvent(a,a.type,i));var n=this._findEventTargets(t,e);if(i){for(var o=[],s=0;sthis.options.zoomAnimationThreshold)return!1;var n=this.getZoomScale(e),n=this._getCenterOffset(t)._divideBy(1-1/n);if(!0!==i.animate&&!this.getSize().contains(n))return!1;x(function(){this._moveStart(!0,i.noMoveStart||!1)._animateZoom(t,e,!0)},this)}return!0},_animateZoom:function(t,e,i,n){this._mapPane&&(i&&(this._animatingZoom=!0,this._animateToCenter=t,this._animateToZoom=e,M(this._mapPane,"leaflet-zoom-anim")),this.fire("zoomanim",{center:t,zoom:e,noUpdate:n}),this._tempFireZoomEvent||(this._tempFireZoomEvent=this._zoom!==this._animateToZoom),this._move(this._animateToCenter,this._animateToZoom,void 0,!0),setTimeout(a(this._onZoomTransitionEnd,this),250))},_onZoomTransitionEnd:function(){this._animatingZoom&&(this._mapPane&&z(this._mapPane,"leaflet-zoom-anim"),this._animatingZoom=!1,this._move(this._animateToCenter,this._animateToZoom,void 0,!0),this._tempFireZoomEvent&&this.fire("zoom"),delete this._tempFireZoomEvent,this.fire("move"),this._moveEnd(!0))}});function Ue(t){return new B(t)}var B=et.extend({options:{position:"topright"},initialize:function(t){c(this,t)},getPosition:function(){return this.options.position},setPosition:function(t){var e=this._map;return e&&e.removeControl(this),this.options.position=t,e&&e.addControl(this),this},getContainer:function(){return this._container},addTo:function(t){this.remove(),this._map=t;var e=this._container=this.onAdd(t),i=this.getPosition(),t=t._controlCorners[i];return M(e,"leaflet-control"),-1!==i.indexOf("bottom")?t.insertBefore(e,t.firstChild):t.appendChild(e),this._map.on("unload",this.remove,this),this},remove:function(){return this._map&&(T(this._container),this.onRemove&&this.onRemove(this._map),this._map.off("unload",this.remove,this),this._map=null),this},_refocusOnMap:function(t){this._map&&t&&0",e=document.createElement("div");return e.innerHTML=t,e.firstChild},_addItem:function(t){var e,i=document.createElement("label"),n=this._map.hasLayer(t.layer),n=(t.overlay?((e=document.createElement("input")).type="checkbox",e.className="leaflet-control-layers-selector",e.defaultChecked=n):e=this._createRadioElement("leaflet-base-layers_"+h(this),n),this._layerControlInputs.push(e),e.layerId=h(t.layer),S(e,"click",this._onInputClick,this),document.createElement("span")),o=(n.innerHTML=" "+t.name,document.createElement("span"));return i.appendChild(o),o.appendChild(e),o.appendChild(n),(t.overlay?this._overlaysList:this._baseLayersList).appendChild(i),this._checkDisabledLayers(),i},_onInputClick:function(){if(!this._preventClick){var t,e,i=this._layerControlInputs,n=[],o=[];this._handlingClick=!0;for(var s=i.length-1;0<=s;s--)t=i[s],e=this._getLayer(t.layerId).layer,t.checked?n.push(e):t.checked||o.push(e);for(s=0;se.options.maxZoom},_expandIfNotCollapsed:function(){return this._map&&!this.options.collapsed&&this.expand(),this},_expandSafely:function(){var t=this._section,e=(this._preventClick=!0,S(t,"click",O),this.expand(),this);setTimeout(function(){k(t,"click",O),e._preventClick=!1})}})),qe=B.extend({options:{position:"topleft",zoomInText:'',zoomInTitle:"Zoom in",zoomOutText:'',zoomOutTitle:"Zoom out"},onAdd:function(t){var e="leaflet-control-zoom",i=P("div",e+" leaflet-bar"),n=this.options;return this._zoomInButton=this._createButton(n.zoomInText,n.zoomInTitle,e+"-in",i,this._zoomIn),this._zoomOutButton=this._createButton(n.zoomOutText,n.zoomOutTitle,e+"-out",i,this._zoomOut),this._updateDisabled(),t.on("zoomend zoomlevelschange",this._updateDisabled,this),i},onRemove:function(t){t.off("zoomend zoomlevelschange",this._updateDisabled,this)},disable:function(){return this._disabled=!0,this._updateDisabled(),this},enable:function(){return this._disabled=!1,this._updateDisabled(),this},_zoomIn:function(t){!this._disabled&&this._map._zoomthis._map.getMinZoom()&&this._map.zoomOut(this._map.options.zoomDelta*(t.shiftKey?3:1))},_createButton:function(t,e,i,n,o){i=P("a",i,n);return i.innerHTML=t,i.href="#",i.title=e,i.setAttribute("role","button"),i.setAttribute("aria-label",e),Ie(i),S(i,"click",Re),S(i,"click",o,this),S(i,"click",this._refocusOnMap,this),i},_updateDisabled:function(){var t=this._map,e="leaflet-disabled";z(this._zoomInButton,e),z(this._zoomOutButton,e),this._zoomInButton.setAttribute("aria-disabled","false"),this._zoomOutButton.setAttribute("aria-disabled","false"),!this._disabled&&t._zoom!==t.getMinZoom()||(M(this._zoomOutButton,e),this._zoomOutButton.setAttribute("aria-disabled","true")),!this._disabled&&t._zoom!==t.getMaxZoom()||(M(this._zoomInButton,e),this._zoomInButton.setAttribute("aria-disabled","true"))}}),Ge=(A.mergeOptions({zoomControl:!0}),A.addInitHook(function(){this.options.zoomControl&&(this.zoomControl=new qe,this.addControl(this.zoomControl))}),B.extend({options:{position:"bottomleft",maxWidth:100,metric:!0,imperial:!0},onAdd:function(t){var e="leaflet-control-scale",i=P("div",e),n=this.options;return this._addScales(n,e+"-line",i),t.on(n.updateWhenIdle?"moveend":"move",this._update,this),t.whenReady(this._update,this),i},onRemove:function(t){t.off(this.options.updateWhenIdle?"moveend":"move",this._update,this)},_addScales:function(t,e,i){t.metric&&(this._mScale=P("div",e,i)),t.imperial&&(this._iScale=P("div",e,i))},_update:function(){var t=this._map,e=t.getSize().y/2,t=t.distance(t.containerPointToLatLng([0,e]),t.containerPointToLatLng([this.options.maxWidth,e]));this._updateScales(t)},_updateScales:function(t){this.options.metric&&t&&this._updateMetric(t),this.options.imperial&&t&&this._updateImperial(t)},_updateMetric:function(t){var e=this._getRoundNum(t);this._updateScale(this._mScale,e<1e3?e+" m":e/1e3+" km",e/t)},_updateImperial:function(t){var e,i,t=3.2808399*t;5280'+(b.inlineSvg?' ':"")+"Leaflet"},initialize:function(t){c(this,t),this._attributions={}},onAdd:function(t){for(var e in(t.attributionControl=this)._container=P("div","leaflet-control-attribution"),Ie(this._container),t._layers)t._layers[e].getAttribution&&this.addAttribution(t._layers[e].getAttribution());return this._update(),t.on("layeradd",this._addAttribution,this),this._container},onRemove:function(t){t.off("layeradd",this._addAttribution,this)},_addAttribution:function(t){t.layer.getAttribution&&(this.addAttribution(t.layer.getAttribution()),t.layer.once("remove",function(){this.removeAttribution(t.layer.getAttribution())},this))},setPrefix:function(t){return this.options.prefix=t,this._update(),this},addAttribution:function(t){return t&&(this._attributions[t]||(this._attributions[t]=0),this._attributions[t]++,this._update()),this},removeAttribution:function(t){return t&&this._attributions[t]&&(this._attributions[t]--,this._update()),this},_update:function(){if(this._map){var t,e=[];for(t in this._attributions)this._attributions[t]&&e.push(t);var i=[];this.options.prefix&&i.push(this.options.prefix),e.length&&i.push(e.join(", ")),this._container.innerHTML=i.join(' ')}}}),n=(A.mergeOptions({attributionControl:!0}),A.addInitHook(function(){this.options.attributionControl&&(new Ke).addTo(this)}),B.Layers=Ve,B.Zoom=qe,B.Scale=Ge,B.Attribution=Ke,Ue.layers=function(t,e,i){return new Ve(t,e,i)},Ue.zoom=function(t){return new qe(t)},Ue.scale=function(t){return new Ge(t)},Ue.attribution=function(t){return new Ke(t)},et.extend({initialize:function(t){this._map=t},enable:function(){return this._enabled||(this._enabled=!0,this.addHooks()),this},disable:function(){return this._enabled&&(this._enabled=!1,this.removeHooks()),this},enabled:function(){return!!this._enabled}})),ft=(n.addTo=function(t,e){return t.addHandler(e,this),this},{Events:e}),Ye=b.touch?"touchstart mousedown":"mousedown",Xe=it.extend({options:{clickTolerance:3},initialize:function(t,e,i,n){c(this,n),this._element=t,this._dragStartTarget=e||t,this._preventOutline=i},enable:function(){this._enabled||(S(this._dragStartTarget,Ye,this._onDown,this),this._enabled=!0)},disable:function(){this._enabled&&(Xe._dragging===this&&this.finishDrag(!0),k(this._dragStartTarget,Ye,this._onDown,this),this._enabled=!1,this._moved=!1)},_onDown:function(t){var e,i;this._enabled&&(this._moved=!1,ve(this._element,"leaflet-zoom-anim")||(t.touches&&1!==t.touches.length?Xe._dragging===this&&this.finishDrag():Xe._dragging||t.shiftKey||1!==t.which&&1!==t.button&&!t.touches||((Xe._dragging=this)._preventOutline&&Me(this._element),Le(),re(),this._moving||(this.fire("down"),i=t.touches?t.touches[0]:t,e=Ce(this._element),this._startPoint=new p(i.clientX,i.clientY),this._startPos=Pe(this._element),this._parentScale=Ze(e),i="mousedown"===t.type,S(document,i?"mousemove":"touchmove",this._onMove,this),S(document,i?"mouseup":"touchend touchcancel",this._onUp,this)))))},_onMove:function(t){var e;this._enabled&&(t.touches&&1e&&(i.push(t[n]),o=n);oe.max.x&&(i|=2),t.ye.max.y&&(i|=8),i}function ri(t,e,i,n){var o=e.x,e=e.y,s=i.x-o,r=i.y-e,a=s*s+r*r;return 0this._layersMaxZoom&&this.setZoom(this._layersMaxZoom),void 0===this.options.minZoom&&this._layersMinZoom&&this.getZoom()t.y!=n.y>t.y&&t.x<(n.x-i.x)*(t.y-i.y)/(n.y-i.y)+i.x&&(l=!l);return l||yi.prototype._containsPoint.call(this,t,!0)}});var wi=ci.extend({initialize:function(t,e){c(this,e),this._layers={},t&&this.addData(t)},addData:function(t){var e,i,n,o=d(t)?t:t.features;if(o){for(e=0,i=o.length;es.x&&(r=i.x+a-s.x+o.x),i.x-r-n.x<(a=0)&&(r=i.x-n.x),i.y+e+o.y>s.y&&(a=i.y+e-s.y+o.y),i.y-a-n.y<0&&(a=i.y-n.y),(r||a)&&(this.options.keepInView&&(this._autopanning=!0),t.fire("autopanstart").panBy([r,a]))))},_getAnchor:function(){return m(this._source&&this._source._getPopupAnchor?this._source._getPopupAnchor():[0,0])}})),Ii=(A.mergeOptions({closePopupOnClick:!0}),A.include({openPopup:function(t,e,i){return this._initOverlay(Bi,t,e,i).openOn(this),this},closePopup:function(t){return(t=arguments.length?t:this._popup)&&t.close(),this}}),o.include({bindPopup:function(t,e){return this._popup=this._initOverlay(Bi,this._popup,t,e),this._popupHandlersAdded||(this.on({click:this._openPopup,keypress:this._onKeyPress,remove:this.closePopup,move:this._movePopup}),this._popupHandlersAdded=!0),this},unbindPopup:function(){return this._popup&&(this.off({click:this._openPopup,keypress:this._onKeyPress,remove:this.closePopup,move:this._movePopup}),this._popupHandlersAdded=!1,this._popup=null),this},openPopup:function(t){return this._popup&&(this instanceof ci||(this._popup._source=this),this._popup._prepareOpen(t||this._latlng)&&this._popup.openOn(this._map)),this},closePopup:function(){return this._popup&&this._popup.close(),this},togglePopup:function(){return this._popup&&this._popup.toggle(this),this},isPopupOpen:function(){return!!this._popup&&this._popup.isOpen()},setPopupContent:function(t){return this._popup&&this._popup.setContent(t),this},getPopup:function(){return this._popup},_openPopup:function(t){var e;this._popup&&this._map&&(Re(t),e=t.layer||t.target,this._popup._source!==e||e instanceof fi?(this._popup._source=e,this.openPopup(t.latlng)):this._map.hasLayer(this._popup)?this.closePopup():this.openPopup(t.latlng))},_movePopup:function(t){this._popup.setLatLng(t.latlng)},_onKeyPress:function(t){13===t.originalEvent.keyCode&&this._openPopup(t)}}),Ai.extend({options:{pane:"tooltipPane",offset:[0,0],direction:"auto",permanent:!1,sticky:!1,opacity:.9},onAdd:function(t){Ai.prototype.onAdd.call(this,t),this.setOpacity(this.options.opacity),t.fire("tooltipopen",{tooltip:this}),this._source&&(this.addEventParent(this._source),this._source.fire("tooltipopen",{tooltip:this},!0))},onRemove:function(t){Ai.prototype.onRemove.call(this,t),t.fire("tooltipclose",{tooltip:this}),this._source&&(this.removeEventParent(this._source),this._source.fire("tooltipclose",{tooltip:this},!0))},getEvents:function(){var t=Ai.prototype.getEvents.call(this);return this.options.permanent||(t.preclick=this.close),t},_initLayout:function(){var t="leaflet-tooltip "+(this.options.className||"")+" leaflet-zoom-"+(this._zoomAnimated?"animated":"hide");this._contentNode=this._container=P("div",t),this._container.setAttribute("role","tooltip"),this._container.setAttribute("id","leaflet-tooltip-"+h(this))},_updateLayout:function(){},_adjustPan:function(){},_setPosition:function(t){var e,i=this._map,n=this._container,o=i.latLngToContainerPoint(i.getCenter()),i=i.layerPointToContainerPoint(t),s=this.options.direction,r=n.offsetWidth,a=n.offsetHeight,h=m(this.options.offset),l=this._getAnchor(),i="top"===s?(e=r/2,a):"bottom"===s?(e=r/2,0):(e="center"===s?r/2:"right"===s?0:"left"===s?r:i.xthis.options.maxZoom||nthis.options.maxZoom||void 0!==this.options.minZoom&&oi.max.x)||!e.wrapLat&&(t.yi.max.y))return!1}return!this.options.bounds||(e=this._tileCoordsToBounds(t),g(this.options.bounds).overlaps(e))},_keyToBounds:function(t){return this._tileCoordsToBounds(this._keyToTileCoords(t))},_tileCoordsToNwSe:function(t){var e=this._map,i=this.getTileSize(),n=t.scaleBy(i),i=n.add(i);return[e.unproject(n,t.z),e.unproject(i,t.z)]},_tileCoordsToBounds:function(t){t=this._tileCoordsToNwSe(t),t=new s(t[0],t[1]);return t=this.options.noWrap?t:this._map.wrapLatLngBounds(t)},_tileCoordsToKey:function(t){return t.x+":"+t.y+":"+t.z},_keyToTileCoords:function(t){var t=t.split(":"),e=new p(+t[0],+t[1]);return e.z=+t[2],e},_removeTile:function(t){var e=this._tiles[t];e&&(T(e.el),delete this._tiles[t],this.fire("tileunload",{tile:e.el,coords:this._keyToTileCoords(t)}))},_initTile:function(t){M(t,"leaflet-tile");var e=this.getTileSize();t.style.width=e.x+"px",t.style.height=e.y+"px",t.onselectstart=u,t.onmousemove=u,b.ielt9&&this.options.opacity<1&&C(t,this.options.opacity)},_addTile:function(t,e){var i=this._getTilePos(t),n=this._tileCoordsToKey(t),o=this.createTile(this._wrapCoords(t),a(this._tileReady,this,t));this._initTile(o),this.createTile.length<2&&x(a(this._tileReady,this,t,null,o)),Z(o,i),this._tiles[n]={el:o,coords:t,current:!0},e.appendChild(o),this.fire("tileloadstart",{tile:o,coords:t})},_tileReady:function(t,e,i){e&&this.fire("tileerror",{error:e,tile:i,coords:t});var n=this._tileCoordsToKey(t);(i=this._tiles[n])&&(i.loaded=+new Date,this._map._fadeAnimated?(C(i.el,0),r(this._fadeFrame),this._fadeFrame=x(this._updateOpacity,this)):(i.active=!0,this._pruneTiles()),e||(M(i.el,"leaflet-tile-loaded"),this.fire("tileload",{tile:i.el,coords:t})),this._noTilesToLoad()&&(this._loading=!1,this.fire("load"),b.ielt9||!this._map._fadeAnimated?x(this._pruneTiles,this):setTimeout(a(this._pruneTiles,this),250)))},_getTilePos:function(t){return t.scaleBy(this.getTileSize()).subtract(this._level.origin)},_wrapCoords:function(t){var e=new p(this._wrapX?H(t.x,this._wrapX):t.x,this._wrapY?H(t.y,this._wrapY):t.y);return e.z=t.z,e},_pxBoundsToTileRange:function(t){var e=this.getTileSize();return new f(t.min.unscaleBy(e).floor(),t.max.unscaleBy(e).ceil().subtract([1,1]))},_noTilesToLoad:function(){for(var t in this._tiles)if(!this._tiles[t].loaded)return!1;return!0}});var Di=Ni.extend({options:{minZoom:0,maxZoom:18,subdomains:"abc",errorTileUrl:"",zoomOffset:0,tms:!1,zoomReverse:!1,detectRetina:!1,crossOrigin:!1,referrerPolicy:!1},initialize:function(t,e){this._url=t,(e=c(this,e)).detectRetina&&b.retina&&0')}}catch(t){}return function(t){return document.createElement("<"+t+' xmlns="urn:schemas-microsoft.com:vml" class="lvml">')}}(),zt={_initContainer:function(){this._container=P("div","leaflet-vml-container")},_update:function(){this._map._animatingZoom||(Wi.prototype._update.call(this),this.fire("update"))},_initPath:function(t){var e=t._container=Vi("shape");M(e,"leaflet-vml-shape "+(this.options.className||"")),e.coordsize="1 1",t._path=Vi("path"),e.appendChild(t._path),this._updateStyle(t),this._layers[h(t)]=t},_addPath:function(t){var e=t._container;this._container.appendChild(e),t.options.interactive&&t.addInteractiveTarget(e)},_removePath:function(t){var e=t._container;T(e),t.removeInteractiveTarget(e),delete this._layers[h(t)]},_updateStyle:function(t){var e=t._stroke,i=t._fill,n=t.options,o=t._container;o.stroked=!!n.stroke,o.filled=!!n.fill,n.stroke?(e=e||(t._stroke=Vi("stroke")),o.appendChild(e),e.weight=n.weight+"px",e.color=n.color,e.opacity=n.opacity,n.dashArray?e.dashStyle=d(n.dashArray)?n.dashArray.join(" "):n.dashArray.replace(/( *, *)/g," "):e.dashStyle="",e.endcap=n.lineCap.replace("butt","flat"),e.joinstyle=n.lineJoin):e&&(o.removeChild(e),t._stroke=null),n.fill?(i=i||(t._fill=Vi("fill")),o.appendChild(i),i.color=n.fillColor||n.color,i.opacity=n.fillOpacity):i&&(o.removeChild(i),t._fill=null)},_updateCircle:function(t){var e=t._point.round(),i=Math.round(t._radius),n=Math.round(t._radiusY||i);this._setPath(t,t._empty()?"M0 0":"AL "+e.x+","+e.y+" "+i+","+n+" 0,23592600")},_setPath:function(t,e){t._path.v=e},_bringToFront:function(t){fe(t._container)},_bringToBack:function(t){ge(t._container)}},qi=b.vml?Vi:ct,Gi=Wi.extend({_initContainer:function(){this._container=qi("svg"),this._container.setAttribute("pointer-events","none"),this._rootGroup=qi("g"),this._container.appendChild(this._rootGroup)},_destroyContainer:function(){T(this._container),k(this._container),delete this._container,delete this._rootGroup,delete this._svgSize},_update:function(){var t,e,i;this._map._animatingZoom&&this._bounds||(Wi.prototype._update.call(this),e=(t=this._bounds).getSize(),i=this._container,this._svgSize&&this._svgSize.equals(e)||(this._svgSize=e,i.setAttribute("width",e.x),i.setAttribute("height",e.y)),Z(i,t.min),i.setAttribute("viewBox",[t.min.x,t.min.y,e.x,e.y].join(" ")),this.fire("update"))},_initPath:function(t){var e=t._path=qi("path");t.options.className&&M(e,t.options.className),t.options.interactive&&M(e,"leaflet-interactive"),this._updateStyle(t),this._layers[h(t)]=t},_addPath:function(t){this._rootGroup||this._initContainer(),this._rootGroup.appendChild(t._path),t.addInteractiveTarget(t._path)},_removePath:function(t){T(t._path),t.removeInteractiveTarget(t._path),delete this._layers[h(t)]},_updatePath:function(t){t._project(),t._update()},_updateStyle:function(t){var e=t._path,t=t.options;e&&(t.stroke?(e.setAttribute("stroke",t.color),e.setAttribute("stroke-opacity",t.opacity),e.setAttribute("stroke-width",t.weight),e.setAttribute("stroke-linecap",t.lineCap),e.setAttribute("stroke-linejoin",t.lineJoin),t.dashArray?e.setAttribute("stroke-dasharray",t.dashArray):e.removeAttribute("stroke-dasharray"),t.dashOffset?e.setAttribute("stroke-dashoffset",t.dashOffset):e.removeAttribute("stroke-dashoffset")):e.setAttribute("stroke","none"),t.fill?(e.setAttribute("fill",t.fillColor||t.color),e.setAttribute("fill-opacity",t.fillOpacity),e.setAttribute("fill-rule",t.fillRule||"evenodd")):e.setAttribute("fill","none"))},_updatePoly:function(t,e){this._setPath(t,dt(t._parts,e))},_updateCircle:function(t){var e=t._point,i=Math.max(Math.round(t._radius),1),n="a"+i+","+(Math.max(Math.round(t._radiusY),1)||i)+" 0 1,0 ",e=t._empty()?"M0 0":"M"+(e.x-i)+","+e.y+n+2*i+",0 "+n+2*-i+",0 ";this._setPath(t,e)},_setPath:function(t,e){t._path.setAttribute("d",e)},_bringToFront:function(t){fe(t._path)},_bringToBack:function(t){ge(t._path)}});function Ki(t){return b.svg||b.vml?new Gi(t):null}b.vml&&Gi.include(zt),A.include({getRenderer:function(t){t=(t=t.options.renderer||this._getPaneRenderer(t.options.pane)||this.options.renderer||this._renderer)||(this._renderer=this._createRenderer());return this.hasLayer(t)||this.addLayer(t),t},_getPaneRenderer:function(t){var e;return"overlayPane"!==t&&void 0!==t&&(void 0===(e=this._paneRenderers[t])&&(e=this._createRenderer({pane:t}),this._paneRenderers[t]=e),e)},_createRenderer:function(t){return this.options.preferCanvas&&Ui(t)||Ki(t)}});var Yi=xi.extend({initialize:function(t,e){xi.prototype.initialize.call(this,this._boundsToLatLngs(t),e)},setBounds:function(t){return this.setLatLngs(this._boundsToLatLngs(t))},_boundsToLatLngs:function(t){return[(t=g(t)).getSouthWest(),t.getNorthWest(),t.getNorthEast(),t.getSouthEast()]}});Gi.create=qi,Gi.pointsToPath=dt,wi.geometryToLayer=bi,wi.coordsToLatLng=Li,wi.coordsToLatLngs=Ti,wi.latLngToCoords=Mi,wi.latLngsToCoords=zi,wi.getFeature=Ci,wi.asFeature=Zi,A.mergeOptions({boxZoom:!0});var _t=n.extend({initialize:function(t){this._map=t,this._container=t._container,this._pane=t._panes.overlayPane,this._resetStateTimeout=0,t.on("unload",this._destroy,this)},addHooks:function(){S(this._container,"mousedown",this._onMouseDown,this)},removeHooks:function(){k(this._container,"mousedown",this._onMouseDown,this)},moved:function(){return this._moved},_destroy:function(){T(this._pane),delete this._pane},_resetState:function(){this._resetStateTimeout=0,this._moved=!1},_clearDeferredResetState:function(){0!==this._resetStateTimeout&&(clearTimeout(this._resetStateTimeout),this._resetStateTimeout=0)},_onMouseDown:function(t){if(!t.shiftKey||1!==t.which&&1!==t.button)return!1;this._clearDeferredResetState(),this._resetState(),re(),Le(),this._startPoint=this._map.mouseEventToContainerPoint(t),S(document,{contextmenu:Re,mousemove:this._onMouseMove,mouseup:this._onMouseUp,keydown:this._onKeyDown},this)},_onMouseMove:function(t){this._moved||(this._moved=!0,this._box=P("div","leaflet-zoom-box",this._container),M(this._container,"leaflet-crosshair"),this._map.fire("boxzoomstart")),this._point=this._map.mouseEventToContainerPoint(t);var t=new f(this._point,this._startPoint),e=t.getSize();Z(this._box,t.min),this._box.style.width=e.x+"px",this._box.style.height=e.y+"px"},_finish:function(){this._moved&&(T(this._box),z(this._container,"leaflet-crosshair")),ae(),Te(),k(document,{contextmenu:Re,mousemove:this._onMouseMove,mouseup:this._onMouseUp,keydown:this._onKeyDown},this)},_onMouseUp:function(t){1!==t.which&&1!==t.button||(this._finish(),this._moved&&(this._clearDeferredResetState(),this._resetStateTimeout=setTimeout(a(this._resetState,this),0),t=new s(this._map.containerPointToLatLng(this._startPoint),this._map.containerPointToLatLng(this._point)),this._map.fitBounds(t).fire("boxzoomend",{boxZoomBounds:t})))},_onKeyDown:function(t){27===t.keyCode&&(this._finish(),this._clearDeferredResetState(),this._resetState())}}),Ct=(A.addInitHook("addHandler","boxZoom",_t),A.mergeOptions({doubleClickZoom:!0}),n.extend({addHooks:function(){this._map.on("dblclick",this._onDoubleClick,this)},removeHooks:function(){this._map.off("dblclick",this._onDoubleClick,this)},_onDoubleClick:function(t){var e=this._map,i=e.getZoom(),n=e.options.zoomDelta,i=t.originalEvent.shiftKey?i-n:i+n;"center"===e.options.doubleClickZoom?e.setZoom(i):e.setZoomAround(t.containerPoint,i)}})),Zt=(A.addInitHook("addHandler","doubleClickZoom",Ct),A.mergeOptions({dragging:!0,inertia:!0,inertiaDeceleration:3400,inertiaMaxSpeed:1/0,easeLinearity:.2,worldCopyJump:!1,maxBoundsViscosity:0}),n.extend({addHooks:function(){var t;this._draggable||(t=this._map,this._draggable=new Xe(t._mapPane,t._container),this._draggable.on({dragstart:this._onDragStart,drag:this._onDrag,dragend:this._onDragEnd},this),this._draggable.on("predrag",this._onPreDragLimit,this),t.options.worldCopyJump&&(this._draggable.on("predrag",this._onPreDragWrap,this),t.on("zoomend",this._onZoomEnd,this),t.whenReady(this._onZoomEnd,this))),M(this._map._container,"leaflet-grab leaflet-touch-drag"),this._draggable.enable(),this._positions=[],this._times=[]},removeHooks:function(){z(this._map._container,"leaflet-grab"),z(this._map._container,"leaflet-touch-drag"),this._draggable.disable()},moved:function(){return this._draggable&&this._draggable._moved},moving:function(){return this._draggable&&this._draggable._moving},_onDragStart:function(){var t,e=this._map;e._stop(),this._map.options.maxBounds&&this._map.options.maxBoundsViscosity?(t=g(this._map.options.maxBounds),this._offsetLimit=_(this._map.latLngToContainerPoint(t.getNorthWest()).multiplyBy(-1),this._map.latLngToContainerPoint(t.getSouthEast()).multiplyBy(-1).add(this._map.getSize())),this._viscosity=Math.min(1,Math.max(0,this._map.options.maxBoundsViscosity))):this._offsetLimit=null,e.fire("movestart").fire("dragstart"),e.options.inertia&&(this._positions=[],this._times=[])},_onDrag:function(t){var e,i;this._map.options.inertia&&(e=this._lastTime=+new Date,i=this._lastPos=this._draggable._absPos||this._draggable._newPos,this._positions.push(i),this._times.push(e),this._prunePositions(e)),this._map.fire("move",t).fire("drag",t)},_prunePositions:function(t){for(;1e.max.x&&(t.x=this._viscousLimit(t.x,e.max.x)),t.y>e.max.y&&(t.y=this._viscousLimit(t.y,e.max.y)),this._draggable._newPos=this._draggable._startPos.add(t))},_onPreDragWrap:function(){var t=this._worldWidth,e=Math.round(t/2),i=this._initialWorldOffset,n=this._draggable._newPos.x,o=(n-e+i)%t+e-i,n=(n+e+i)%t-e-i,t=Math.abs(o+i)e.getMaxZoom()&&1() + .firstOrNull { it.isShowing && it.title == title } + } + found?.let { return it } + Thread.sleep(50) + } + + throw AssertionError("the window titled '$title' never became visible") + } + private fun openHost(name: String): ComposeWindowHost { val host = ComposeWindowHost(name) host.show( @@ -34,14 +59,8 @@ class ComposeWindowHostCloseTest { heightDp = 120f, ) { Text("close test") } - // Wait for the window to really exist, so the close has something to close. - val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(15) - while (!host.isOpen && System.nanoTime() < deadline) { - Thread.sleep(25) - } - assertTrue(host.isOpen, "the window never opened, so the close proves nothing") - /* Let the first frame land; closing mid-composition is a different test. */ - Thread.sleep(600) + // Wait for the window to really be on screen, so the close has something to close. + awaitShowingFrame("close test") return host } @@ -90,30 +109,19 @@ class ComposeWindowHostCloseTest { onCloseRequest = { asked.countDown() }, ) { Text("close request test") } - val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(15) - while (!host.isOpen && System.nanoTime() < deadline) { - Thread.sleep(25) - } - assertTrue(host.isOpen, "the window never opened") - Thread.sleep(600) + /* + * Matched by title rather than by type: the suite runs in one JVM and other tests' + * Compose windows can still be showing, so a type filter sent the event to the + * wrong window and this latch waited for ever. + */ + val mine = awaitShowingFrame("close request test") try { - /* - * What the window manager sends: a close request on the window itself. Matched - * by title rather than by type — the suite runs in one JVM and other tests' - * Compose windows can still be showing, so a type filter sent the event to the - * wrong window and this latch waited for ever. - */ + // What the window manager sends: a close request on the window itself. EventQueue.invokeAndWait { - val mine = java.awt.Window.getWindows() - .filterIsInstance() - .filter { it.isShowing && it.title == "close request test" } - assertTrue(mine.isNotEmpty(), "this test's own window was not found") - mine.forEach { - it.dispatchEvent( - java.awt.event.WindowEvent(it, java.awt.event.WindowEvent.WINDOW_CLOSING) - ) - } + mine.dispatchEvent( + java.awt.event.WindowEvent(mine, java.awt.event.WindowEvent.WINDOW_CLOSING) + ) } assertTrue( diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/DrawingLayerRegressionTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/DrawingLayerRegressionTest.kt new file mode 100644 index 00000000..3c7bd4c1 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/DrawingLayerRegressionTest.kt @@ -0,0 +1,207 @@ +package kst4contest.view.compose + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.unit.dp +import kst4contest.model.ChatPreferences +import kst4contest.view.compose.tabs.MessageHandlingTab +import kst4contest.view.compose.tabs.MessageHandlingTabState +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** + * The four ways the drawing layer went wrong while the build was green. + * + * Every one of these shipped in the branch at the same time as a passing suite of 382 + * tests, because not one of those tests drew anything. The state-level tests and the two + * settings coverage nets answer "was the setter called"; these answer "did it reach the + * screen", which is a different question and the one the operator asks. + * + * | Class | How it showed up | + * |---|---| + * | A control reads a non-observable value and never redraws | every keystroke in the settings was written through and not displayed | + * | A layout clips instead of wrapping | the tab strip cut "Workedstn database" mid-word | + * | Content is taller than the window and unreachable | a tab's form ended mid-way with nothing to say more was below | + * | Colours do not come from the stylesheet | the window surface took `-fx-base` instead of `derive(-fx-base, 26.4%)`; the accent was Material violet | + * + * All four run without a display: `runComposeUiTest` reaches the semantics tree headless, + * and the colour check reads the palette the theme hands out. That was measured before + * these were written, because the spec left it open and a net that only works on a + * developer's desk is worth saying so about. + */ +class DrawingLayerRegressionTest { + + private fun themed(content: @Composable () -> Unit): @Composable () -> Unit = { + Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) { content() } + } + + // ---------------------------------------------------------------- class 1 + + @OptIn(ExperimentalTestApi::class) + @Test + fun aTypedSettingAppearsOnScreenAndNotOnlyInThePreferences() = runComposeUiTest { + val prefs = ChatPreferences() + prefs.setMessageHandling_autoAnswerTextMainCat("") + val state = MessageHandlingTabState(prefs) + + // The real tab, not a field in isolation: the fault was in the wiring between them. + setContent(themed { MessageHandlingTab(state) }) + + // MessageHandlingTab has exactly one text field, so this finder is unambiguous. + onNode(hasSetTextAction()).performTextInput("qrv 144.300") + + assertEquals( + "qrv 144.300", + prefs.getMessageHandling_autoAnswerTextMainCat(), + "the value did not reach the preferences", + ) + onNodeWithText("qrv 144.300").assertExists() + } + + // ---------------------------------------------------------------- class 2 + + private val tabTitles = listOf( + "Station", "Log synch", "TRX synch", "Airscout", "Notification", + "Message handling", "Beacon", "Shortcuts", "GUI", "Profiles", + "Workedstn database", + ) + + /** + * The strip inside a box of a given width. + * + * The width is forced with a modifier rather than by sizing the test window, which is + * what makes this a measurement instead of a does-it-crash check: the same content is + * laid out twice and the two results are compared. + */ + private fun stripAt(widthDp: Int): @Composable () -> Unit = themed { + Box(Modifier.width(widthDp.dp)) { + SettingsTabStrip( + tabs = tabTitles.map { title -> SettingsTab(title) { Text(title) } }, + selectedIndex = 0, + onSelect = {}, + ) + } + } + + @OptIn(ExperimentalTestApi::class) + @Test + fun theTabStripWrapsRatherThanSqueezingItsTitles() = runComposeUiTest { + setContent(stripAt(WIDE_ENOUGH_FOR_EVERY_TAB)) + + val wide = onNodeWithText(LONGEST_TAB).fetchSemanticsNode() + val wideWidth = wide.size.width + val wideTop = wide.positionInRoot.y + + assertTrue(wideWidth > 0, "the longest tab title was not laid out at all") + + setContent(stripAt(TOO_NARROW_FOR_ONE_ROW)) + + val narrow = onNodeWithText(LONGEST_TAB).fetchSemanticsNode() + + assertEquals( + wideWidth, + narrow.size.width, + "the tab got narrower when the row did, which means it is being squeezed " + + "instead of wrapped — that is what cut '$LONGEST_TAB' mid-word", + ) + assertTrue( + narrow.positionInRoot.y > wideTop, + "eleven tabs fitted on one line at $TOO_NARROW_FOR_ONE_ROW dp, so nothing " + + "was measured; pick a narrower width", + ) + } + + // ---------------------------------------------------------------- class 3 + + @OptIn(ExperimentalTestApi::class) + @Test + fun aFormTallerThanTheWindowCanStillBeReached() = runComposeUiTest { + val fieldCount = 40 + val lastLabel = "field $fieldCount" + + setContent( + themed { + // The window's own scroll region, with content that cannot possibly fit. + SettingsContent( + tabs = listOf( + SettingsTab("Tall") { + Column { + (1..fieldCount).forEach { Text("field $it", Modifier.height(40.dp)) } + } + }, + ), + notices = SettingsNotices(), + buttons = {}, + close = {}, + ) + } + ) + + /* + * performScrollTo fails if nothing in the chain scrolls. That is exactly the fault: + * each tab used to scroll for itself, so content simply ended mid-form with no + * scrollbar and no way down. + */ + onNodeWithText(lastLabel).performScrollTo().assertExists() + } + + // ---------------------------------------------------------------- class 4 + + @Test + fun theWindowSurfaceIsDerivedFromTheBaseAndNotTheBaseItself() { + val evening = JavaFxStylesheet.read("/KST4ContestDefaultEvening.css") + + /* + * The fault was taking -fx-base for the window surface. The sheets derive it, and + * the two are visibly different greys — which is why the first Compose windows + * looked flat against the JavaFX ones beside them. + */ + assertTrue( + evening.windowBackground != evening.base, + "the window surface is the raw base colour again", + ) + } + + @Test + fun theAccentComesFromTheStylesheetAndNotFromMaterial() { + val evening = JavaFxStylesheet.read("/KST4ContestDefaultEvening.css") + val day = JavaFxStylesheet.read("/KST4ContestDefaultDay.css") + + /* + * Material's default primary is a violet (#6650a4 family). The sheets name their + * own accent, and the two sheets name different ones — so a palette that agreed + * with Material, or a palette that was the same in both sheets, would mean the + * stylesheet is not being read. + */ + val materialViolet = androidx.compose.ui.graphics.Color(0xFF6650A4) + + assertTrue(evening.accent != materialViolet, "the accent is Material's, not the sheet's") + assertTrue(day.accent != materialViolet, "the accent is Material's, not the sheet's") + assertTrue( + evening.accent != day.accent || evening.base != day.base, + "both sheets produced the same palette, so neither was really read", + ) + } + + private companion object { + const val LONGEST_TAB = "Workedstn database" + + /** Comfortably more than the eleven tabs need, so none of them wraps. */ + const val WIDE_ENOUGH_FOR_EVERY_TAB = 2000 + + /** Narrow enough that the row must break; the wrap is the point of the test. */ + const val TOO_NARROW_FOR_ONE_ROW = 300 + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/MainMenuModelTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/MainMenuModelTest.kt new file mode 100644 index 00000000..9484b36d --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/MainMenuModelTest.kt @@ -0,0 +1,166 @@ +package kst4contest.view.compose + +import kst4contest.controller.On4KstConnectionState +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 + +/** + * The menus, their labels and which of them can be used. + * + * None of this was covered while the bar was declared straight into Compose's `MenuBar`: + * the structure only existed inside a composable, so it took a running window to look at. + * It is now a plain function of the state, and these are the parts that go wrong silently — + * a label that stops following the away state, an item that stays usable while the station + * is not in the chat, or a menu that loses an entry when a second renderer is added. + */ +class MainMenuModelTest { + + private class RecordingActions : MainMenuActions { + val calls = mutableListOf() + override fun connect() { calls += "connect" } + override fun disconnect() { calls += "disconnect" } + override fun switchOperatorProfile() { calls += "switchOperatorProfile" } + override fun exitApplication() { calls += "exitApplication" } + override fun setQrgAsNameInChat() { calls += "setQrgAsNameInChat" } + override fun toggleAwayState() { calls += "toggleAwayState" } + override fun toggleSettingsWindow() { calls += "toggleSettingsWindow" } + override fun toggleMonitorWindow() { calls += "toggleMonitorWindow" } + override fun toggleStationMap() { calls += "toggleStationMap" } + override fun useDarkDesign() { calls += "useDarkDesign" } + override fun useDefaultDesign() { calls += "useDefaultDesign" } + override fun openDonationPage() { calls += "openDonationPage" } + override fun openHomepage() { calls += "openHomepage" } + override fun openNewsgroup() { calls += "openNewsgroup" } + override fun openChangelog() { calls += "openChangelog" } + override fun openOv3tDonationPage() { calls += "openOv3tDonationPage" } + override fun contactAuthor() { calls += "contactAuthor" } + override fun showAbout() { calls += "showAbout" } + } + + private fun model( + connectionState: On4KstConnectionState = On4KstConnectionState.DISCONNECTED, + away: Boolean = false, + settingsOpen: Boolean = false, + monitorOpen: Boolean = false, + actions: MainMenuActions = RecordingActions(), + ): List { + val state = MainMenuState() + state.connectionState = connectionState + state.awayFromChat = away + return mainMenuModel(state, actions, settingsOpen, monitorOpen) + } + + private fun items(menu: MenuSpec) = menu.entries.filterIsInstance() + + private fun labels(menu: MenuSpec) = items(menu).map { it.label } + + private fun item(model: List, label: String): MenuEntry.Item = + model.flatMap { items(it) }.single { it.label == label } + + @Test + fun theFourMenusAppearInTheOrderTheyAlwaysHad() { + assertEquals(listOf("File", "Options", "Windows", "Info"), model().map { it.title }) + } + + @Test + fun noMenuIsEmptyAndTheSeparatorsSurviveTheTranslation() { + val model = model() + + model.forEach { menu -> + assertTrue(items(menu).isNotEmpty(), "menu '${menu.title}' has no items") + } + + // Windows has two separators, Info one. A renderer that drops them makes the + // design switch read as just another window toggle. + assertEquals(2, model.single { it.title == "Windows" }.entries.count { it is MenuEntry.Separator }) + assertEquals(1, model.single { it.title == "Info" }.entries.count { it is MenuEntry.Separator }) + } + + @Test + fun theConnectItemCarriesTheStatesOwnLabel() { + val state = MainMenuState() + state.connectLabel = "Connect to 144/432 MHz" + + val model = mainMenuModel(state, RecordingActions(), false, false) + + assertEquals( + "Connect to 144/432 MHz", + labels(model.single { it.title == "File" }).first(), + "the label names the chat the station lands in; it must not be hardcoded here", + ) + } + + @Test + fun connectAndDisconnectAreNeverBothUsable() { + for (connectionState in On4KstConnectionState.values()) { + val model = model(connectionState = connectionState) + val connect = model.flatMap { items(it) }.first() + val disconnect = item(model, "Disconnect") + + assertFalse( + connect.enabled && disconnect.enabled, + "both were usable while $connectionState", + ) + assertTrue( + connect.enabled || disconnect.enabled, + "neither was usable while $connectionState, which would strand the operator", + ) + } + } + + @Test + fun theChatActionsNeedASessionThatIsReallyOnline() { + val offline = model(connectionState = On4KstConnectionState.CONNECTING) + + // Logging in is not being in the chat: a command sent then goes nowhere. + assertFalse(item(offline, "Set QRG as name in Chat (main category)").enabled) + assertFalse(item(offline, "Show me as AWAY FROM chat!").enabled) + } + + @Test + fun theAwayLabelNamesTheNextActionAndFollowsTheState() { + assertEquals( + "Show me as AWAY FROM chat!", + item(model(away = false), "Show me as AWAY FROM chat!").label, + ) + assertEquals( + "Show me as ACTIVE in chat!", + item(model(away = true), "Show me as ACTIVE in chat!").label, + ) + } + + @Test + fun theWindowTogglesSayWhatPressingThemWillDo() { + val closed = model(settingsOpen = false, monitorOpen = false) + assertTrue(labels(closed.single { it.title == "Windows" }).any { it == "show options" }) + assertTrue( + labels(closed.single { it.title == "Windows" }) + .any { it == "Show cluster / stranger QSOs" }, + ) + + val open = model(settingsOpen = true, monitorOpen = true) + assertTrue(labels(open.single { it.title == "Windows" }).any { it == "hide options" }) + assertTrue( + labels(open.single { it.title == "Windows" }) + .any { it == "Hide cluster / stranger QSOs" }, + ) + } + + @Test + fun everyItemIsWiredToSomething() { + val actions = RecordingActions() + val model = model(connectionState = On4KstConnectionState.ONLINE, actions = actions) + + model.flatMap { items(it) }.forEach { it.onClick() } + + // One click each, and nothing silently wired to an empty lambda: the JavaFX bar had + // an item whose handler was commented out, and it looked exactly like the others. + assertEquals( + model.flatMap { items(it) }.size, + actions.calls.size, + "every item must reach an action; one of them did nothing", + ) + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/MainMenuRowStateTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/MainMenuRowStateTest.kt new file mode 100644 index 00000000..fc34b02d --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/MainMenuRowStateTest.kt @@ -0,0 +1,83 @@ +package kst4contest.view.compose + +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 + +/** + * Which menu of the in-window row is open. + * + * Small, but it carries one thing that is not cosmetic: [MainMenuRowState.anyOpen] is what + * keeps the window-wide keys out of the way while a menu is down. `MainWindow` hangs + * `WindowShortcuts.handle` on the window's `onPreviewKeyEvent`, and a preview runs *before* + * its children — so without this, pressing Escape to dismiss a menu would first clear the + * operator's half-typed message. + */ +class MainMenuRowStateTest { + + @Test + fun nothingIsOpenToStartWith() { + val state = MainMenuRowState() + + assertNull(state.openMenu) + assertFalse(state.anyOpen) + } + + @Test + fun aTitleOpensItsOwnMenu() { + val state = MainMenuRowState() + + state.toggle("File") + + assertEquals("File", state.openMenu) + assertTrue(state.anyOpen) + } + + @Test + fun theSameTitleAgainClosesIt() { + val state = MainMenuRowState() + state.toggle("File") + + state.toggle("File") + + assertNull(state.openMenu, "pressing the open menu's own title must close it") + assertFalse(state.anyOpen) + } + + @Test + fun anotherTitleSwitchesRatherThanStacking() { + val state = MainMenuRowState() + state.toggle("File") + + state.toggle("Info") + + // Two menus down at once is a thing no menu bar does. + assertEquals("Info", state.openMenu) + } + + @Test + fun closeAlwaysEndsUpClosedAndIsIdempotent() { + val state = MainMenuRowState() + state.toggle("Windows") + + state.close() + state.close() + + assertNull(state.openMenu) + assertFalse(state.anyOpen) + } + + @Test + fun anItemBeingPickedClosesTheMenu() { + val state = MainMenuRowState() + state.toggle("Options") + var ran = false + + state.pick { ran = true } + + assertTrue(ran, "the action must run") + assertNull(state.openMenu, "a picked item must close the menu it came from") + } +}