diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml new file mode 100644 index 0000000..1fabbb7 --- /dev/null +++ b/.github/workflows/CI.yml @@ -0,0 +1,60 @@ +name: CI +on: + push: + branches: + - main + tags: ['*'] + pull_request: + workflow_dispatch: +concurrency: + # skip intermediate builds: always, cancel intermediate builds: only if it is a pull request build + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ startsWith(github.ref, 'refs/pull/') }} +jobs: + test: + name: Julia ${{ matrix.version }} - ${{ matrix.os }} + runs-on: ${{ matrix.os }} + timeout-minutes: 120 + permissions: # needed to allow julia-actions/cache to proactively delete old caches that it has created + actions: write + contents: read + strategy: + fail-fast: false + matrix: + version: + - 'lts' # 1.10 + - '1.11' + - '1.12' + - '1' # latest release + os: + - ubuntu-latest + - macos-latest + - windows-latest + steps: + - uses: actions/checkout@v5 + - uses: julia-actions/setup-julia@v2 + with: + version: ${{ matrix.version }} + - uses: julia-actions/cache@v2 + + # GLMakie needs OpenGL. On Linux, we use a virtual display and the GUI tests are + # required to run. On Windows we install a software OpenGL driver (Mesa); there and + # on macOS the GUI tests are run if an OpenGL context can be created. + - name: Install virtual display (Linux) + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y xvfb libgl1 libglx-mesa0 mesa-utils + - name: Install Mesa OpenGL (Windows) + if: runner.os == 'Windows' + uses: ssciwr/setup-mesa-dist-win@v2 + + - name: Disable GLMakie precompilation (macOS) + # the GLMakie precompile workload needs OpenGL, which the macOS runners don't have + if: runner.os == 'macOS' + run: printf '[GLMakie]\nprecompile_workload = false\n' > LocalPreferences.toml + + - uses: julia-actions/julia-buildpkg@v1 + - uses: julia-actions/julia-runtest@v1 + with: + prefix: ${{ runner.os == 'Linux' && 'xvfb-run -s ''-screen 0 1024x768x24''' || '' }} + env: + ADA_GUI_TESTS: ${{ runner.os == 'Linux' && 'true' || 'auto' }} diff --git a/src/AdA_VizPickTool_V1.jl b/src/AdA_VizPickTool_V1.jl index 6a07b43..0aedf3a 100644 --- a/src/AdA_VizPickTool_V1.jl +++ b/src/AdA_VizPickTool_V1.jl @@ -87,7 +87,7 @@ function start_AdA_Picker(;data=nothing) ############################################################ # LOAD PROFILE DATA @async begin - fn = fetch(Threads.@spawn pick_file("")) + fn = fetch(Threads.@spawn PICK_FILE[]("")) data = load(fn,"Profile") # we need to make this more foolproof, I don't think we can rely on people calling hteir profile structure Profile println(fn*" loaded") @@ -362,7 +362,7 @@ function start_AdA_Picker(;data=nothing) elseif s == "Load Picks..." # load picks from a text file, these will be modifyable @async begin - fn = fetch(Threads.@spawn pick_file("")) + fn = fetch(Threads.@spawn PICK_FILE[]("")) println(fn) @@ -410,7 +410,7 @@ function start_AdA_Picker(;data=nothing) elseif s == "Load Picks (not modifyable)..." # load the picks as point data, these will be treated in a similar way as e.g. the seismicity data @async begin - fn = fetch(Threads.@spawn pick_file("")) + fn = fetch(Threads.@spawn PICK_FILE[]("")) # test plot in ax1 #lines!(ax1, [0,100],[0,-200], color = :red,linewidth = 3) @@ -476,19 +476,20 @@ function start_AdA_Picker(;data=nothing) lat_pick = interp_linear_lat(x_pick) lon_pick = interp_linear_lon(x_pick) - picks = (x = x_pick, depth = y_pick, lat = lat_pick, lon = lon_pick) + # use a new name here: `picks` is the (global) Observable used for picking + pick_data = (x = x_pick, depth = y_pick, lat = lat_pick, lon = lon_pick) pick_info = (user_name = pick_name.stored_string[], date = now(), units = (x = "km", depth = "km", lat = "deg", lon = "deg")) profile_info = (start_lonlat = data.start_lonlat, end_lonlat = data.end_lonlat) @async begin - fn_save = fetch(Threads.@spawn save_file("")) # open native file dialog and choose a filename + fn_save = fetch(Threads.@spawn SAVE_FILE[]("")) # open native file dialog and choose a filename filetype = split(fn_save,".")[end] # get the ending # save depending on file ending if filetype == "jld2" # file should contain: profile information, picker information, picks, lat and lon of the picked points # save as jld2 file - jldsave(fn_save; picks=picks, pick_info=pick_info, profile_info=profile_info) + jldsave(fn_save; picks=pick_data, pick_info=pick_info, profile_info=profile_info) println("... "*fn_save*" saved") elseif filetype == "csv" println("Saving as csv is not implemented yet") @@ -503,7 +504,7 @@ function start_AdA_Picker(;data=nothing) elseif s == "Save Screenshot..." # save a screen shot of the makie window @async begin - fn_screen = fetch(Threads.@spawn save_file("")) + fn_screen = fetch(Threads.@spawn SAVE_FILE[]("")) save(fn_screen, fig, px_per_unit = 2) println(fn_screen*" saved") end diff --git a/src/FileIO/fileIO_utils.jl b/src/FileIO/fileIO_utils.jl index 0410907..dc7f12d 100644 --- a/src/FileIO/fileIO_utils.jl +++ b/src/FileIO/fileIO_utils.jl @@ -4,11 +4,16 @@ using FileIO using NativeFileDialog using JLD2 +# The GUI opens the native file dialogs through these Refs, so that the tests can +# replace them by functions that return a fixed filename (there is no user on CI) +const PICK_FILE = Ref{Function}(pick_file) +const SAVE_FILE = Ref{Function}(save_file) + # function for fileIO menu response function menu_fileIO_response(s) if s == "Load Profile..." @async begin - filename = fetch(Threads.@spawn pick_file("")) + filename = fetch(Threads.@spawn PICK_FILE[]("")) println(filename) #data = load_GMG(filename) # load the profile data, this did not work properly data = load(filename,"Profile") diff --git a/test/runtests.jl b/test/runtests.jl index 30bef4f..e6ae184 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -1,6 +1,57 @@ -using AdriaArrayPicker +using AdriaArrayGeometryPicker +using GLMakie using Test -@testset "AdriaArrayPicker.jl" begin - # Write your tests here. +include("synthetic_profile.jl") + +# The GUI tests need OpenGL. By default they only run if a GLMakie window can be created, +# which is not the case on most headless machines. Set ADA_GUI_TESTS=true to enforce them +# (e.g. on CI with a virtual display) or ADA_GUI_TESTS=false to skip them. +function opengl_available() + try + screen = GLMakie.Screen(visible = false) + close(screen) + return true + catch err + @warn "Cannot create an OpenGL context" exception = err + return false + end +end + +const GUI_TESTS = let setting = lowercase(get(ENV, "ADA_GUI_TESTS", "auto")) + setting == "auto" ? opengl_available() : parse(Bool, setting) +end + +@testset "AdriaArrayGeometryPicker.jl" begin + @testset "Package" begin + @test isdefined(AdriaArrayGeometryPicker, :start_AdA_Picker) + @test :start_AdA_Picker in names(AdriaArrayGeometryPicker) + @test isfile(joinpath(pkgdir(AdriaArrayGeometryPicker), "assets", "AdA_Picker_logo_tr.png")) + end + + @testset "Synthetic profile" begin + # checks that the profile has the structure the GUI relies on + dir = mktempdir() + fn = save_synthetic_profile(joinpath(dir, "profile.jld2")) + P = load(fn, "Profile") + @test P.vertical + @test P.start_lonlat == START_LONLAT && P.end_lonlat == END_LONLAT + @test keys(P.VolData.fields)[end-1:end] == (:FlatCrossSection, :x_profile) + @test haskey(P.SurfData, :Topography) && haskey(P.SurfData.Topography.fields, :x_profile) + @test haskey(P.PointData.Seismicity.fields, :depth_proj) + end + + @testset "Controls" begin + fig = Figure() + menu = AdriaArrayGeometryPicker.set_fileIO_menu!(fig, fig[1, 1]) + @test menu isa Menu + @test ["Load Profile...", "Load Picks...", "Save Picks...", "Save Screenshot..."] ⊆ menu.options[] + end + + if GUI_TESTS + include("test_gui.jl") + else + @warn "Skipping GUI tests (no OpenGL available)" + @test_skip "GUI" + end end diff --git a/test/synthetic_profile.jl b/test/synthetic_profile.jl new file mode 100644 index 0000000..b8f7037 --- /dev/null +++ b/test/synthetic_profile.jl @@ -0,0 +1,40 @@ +# Creates a small synthetic vertical profile with GeophysicalModelGenerator, containing +# the same kind of data the GUI expects: volume data (tomographies), surface data +# (topography + Moho) and point data (seismicity) +using GeophysicalModelGenerator, JLD2 + +const START_LONLAT = (11.0, 45.0) +const END_LONLAT = (19.0, 45.0) + +function create_synthetic_profile(; npoints = 200) + # volume data + lon, lat, z = lonlatdepth_grid(10:0.5:20, 40:0.5:50, -300:10:0) + dVs = @. 0.5 * sin(lon / 2) * cos(z / 50) + dVp = @. 2 * dVs + Vol = GeoData(lon, lat, z, (dVs = dVs, dVp = dVp)) + + # surface data + lon2, lat2, z2 = lonlatdepth_grid(10:0.5:20, 40:0.5:50, 0) + topo = @. 1.0 * sin(lon2) + moho = @. -35 + 3 * cos(lat2) + Topo = GeoData(lon2, lat2, z2 .+ topo, (Topography = topo * km,)) + Moho = GeoData(lon2, lat2, z2 .+ moho, (MohoDepth = moho * km,)) + + # point data, located close to the profile + elon = collect(range(10.5, 19.5, length = npoints)) + elat = 45.0 .+ 0.2 .* sin.(1:npoints) + ez = -100.0 .* (0.5 .+ 0.5 .* cos.(1:npoints)) + Seis = GeoData(elon, elat, ez, (Magnitude = 3.0 .+ sin.(1:npoints),)) + + Profile = ProfileData(start_lonlat = START_LONLAT, end_lonlat = END_LONLAT) + extract_ProfileData!(Profile, Vol, (Topography = Topo, Moho = Moho), (Seismicity = Seis,); + DimsVolCross = (50, 40), Depth_extent = (-300, 0)) + + return Profile +end + +# the GUI loads the variable `Profile` from the jld2 file +function save_synthetic_profile(filename) + jldsave(filename; Profile = create_synthetic_profile()) + return filename +end diff --git a/test/test_gui.jl b/test/test_gui.jl new file mode 100644 index 0000000..aa1dfcc --- /dev/null +++ b/test/test_gui.jl @@ -0,0 +1,125 @@ +# Drives the GUI through its menus, the way a user would. The native file dialogs are +# replaced by functions returning a fixed filename, see `PICK_FILE`/`SAVE_FILE`. +using AdriaArrayGeometryPicker, GLMakie, JLD2, Test + +const AGP = AdriaArrayGeometryPicker + +# menu callbacks run asynchronously, so we have to wait for them to finish +function wait_until(f; timeout = 300) + status = timedwait(f, timeout; pollint = 0.1) + return status == :ok +end + +find_blocks(fig, T) = filter(b -> b isa T, fig.content) +find_plots(ax, T) = filter(p -> p isa T, ax.scene.plots) + +function select!(menu, option) + menu.selection[] = option + return nothing +end + +@testset "GUI" begin + GLMakie.activate!(visible = false) # don't pop up windows during the tests + + dir = mktempdir() + profile_file = save_synthetic_profile(joinpath(dir, "test_profile.jld2")) + picks_file = joinpath(dir, "picks.jld2") + screen_file = joinpath(dir, "screenshot.png") + + fig = start_AdA_Picker() + @test fig isa Figure + + menus = find_blocks(fig, Menu) + main_menu = only(filter(m -> "Load Profile..." in m.options[], menus)) + + @testset "Load profile" begin + AGP.PICK_FILE[] = _ -> profile_file + select!(main_menu, "Load Profile...") + + # loading is done once the colormap menu (the last control) has its callback + loaded() = any(m -> :roma in m.options[] && !isempty(GLMakie.Observables.listeners(m.selection)), find_blocks(fig, Menu)) + @test wait_until(loaded) + @test any(ax -> ax.title[] == "test_profile.jld2", find_blocks(fig, Axis)) + + # data axis = the one with the heatmap + ax = only(filter(ax -> !isempty(find_plots(ax, Heatmap)), find_blocks(fig, Axis))) + @test ax === AGP.ax1 + @test length(find_plots(ax, Scatter)) == 2 # picks + seismicity + + # one toggle per surface (only Moho, topography is plotted separately) and point data set + # (+ the picking and compare toggles) + @test length(find_blocks(fig, Toggle)) == 2 + 1 + 1 + + # the field menu contains the volume data, without the helper fields + field_menu = only(filter(m -> :dVs in m.options[], find_blocks(fig, Menu))) + @test field_menu.options[] == [:dVs, :dVp] + end + + ax = AGP.ax1 + hm = only(find_plots(ax, Heatmap)) + + @testset "Change field and colormap" begin + field_menu = only(filter(m -> :dVs in m.options[], find_blocks(fig, Menu))) + dVs_max = maximum(filter(!isnan, hm[3][])) + select!(field_menu, :dVp) + @test maximum(filter(!isnan, hm[3][])) ≈ 2 * dVs_max + + cmap_menu = only(filter(m -> :roma in m.options[], find_blocks(fig, Menu))) + select!(cmap_menu, :roma) + @test hm.colormap[] == Reverse(:roma) + end + + @testset "Save and load picks" begin + textbox = only(filter(t -> t.placeholder[] == "User name", find_blocks(fig, Textbox))) + textbox.stored_string[] = "CI" + + # add picks, as done by the mouse callbacks + x_picks = [100.0, 300.0, 500.0] + depth_picks = [-40.0, -80.0, -120.0] + AGP.picks[] = [Point3f(x, d, 1000) for (x, d) in zip(x_picks, depth_picks)] + + AGP.SAVE_FILE[] = _ -> picks_file + select!(main_menu, "Save Picks...") + @test wait_until(() -> isfile(picks_file)) + + saved = wait_until(() -> try + load(picks_file); true + catch + false + end, timeout = 30) + @test saved + data = load(picks_file) + @test data["picks"].x ≈ x_picks + @test data["picks"].depth ≈ depth_picks + # the profile runs along a latitude, so lat is constant and lon increases + @test all(data["picks"].lat .≈ 45.0) + @test issorted(data["picks"].lon) && all(11.0 .<= data["picks"].lon .<= 19.0) + @test data["pick_info"].user_name == "CI" + @test data["profile_info"].start_lonlat == (11.0, 45.0) + @test data["profile_info"].end_lonlat == (19.0, 45.0) + + # picking has to keep working after saving + @test AGP.picks isa Observable + + # load them back as modifiable picks + AGP.picks[] = Point3f[] + AGP.PICK_FILE[] = _ -> picks_file + select!(main_menu, "Load Picks...") + @test wait_until(() -> length(AGP.picks[]) == 3) + @test [p[1] for p in AGP.picks[]] ≈ x_picks + @test [p[2] for p in AGP.picks[]] ≈ depth_picks + + # and as fixed picks for comparison, which adds two lines to the profile + nlines = length(find_plots(ax, Lines)) + select!(main_menu, "Load Picks (not modifyable)...") + @test wait_until(() -> length(find_plots(ax, Lines)) == nlines + 2) + end + + @testset "Save screenshot" begin + AGP.SAVE_FILE[] = _ -> screen_file + select!(main_menu, "Save Screenshot...") + @test wait_until(() -> isfile(screen_file) && filesize(screen_file) > 0) + end + + GLMakie.closeall() +end